• 006-spring cloud gateway-GatewayAutoConfiguration核心配置-GatewayProperties初始化加载、Route初始化加载


    一、GatewayProperties

    1.1、在GatewayAutoConfiguration中加载

      在Spring-Cloud-Gateway初始化时,同时GatewayAutoConfiguration核心配置类会被初始化加载如下 :

    NettyConfiguration 底层通信netty配置
    GlobalFilter (AdaptCachedBodyGlobalFilter,RouteToRequestUrlFilter,ForwardRoutingFilter,ForwardPathFilter,WebsocketRoutingFilter,WeightCalculatorWebFilter等)
    FilteringWebHandler
    GatewayProperties
    PrefixPathGatewayFilterFactory
    RoutePredicateFactory
    RouteDefinitionLocator
    RouteLocator
    RoutePredicateHandlerMapping 查找匹配到 Route并进行处理
    GatewayWebfluxEndpoint 管理网关的 HTTP API

      其中在GatewayAutoConfiguration配置加载中含初始化加载GatewayProperties实例的配置:

    查看GatewayAutoConfiguration源码:

        @Bean
        public GatewayProperties gatewayProperties() {
            return new GatewayProperties();
        }

    1.2、再次查看GatewayProperties源码:

    @ConfigurationProperties("spring.cloud.gateway")
    @Validated
    public class GatewayProperties {
        @NotNull
        @Valid
        private List<RouteDefinition> routes = new ArrayList();
        private List<FilterDefinition> defaultFilters = new ArrayList();
        private List<MediaType> streamingMediaTypes;
    
        public GatewayProperties() {
            this.streamingMediaTypes = Arrays.asList(MediaType.TEXT_EVENT_STREAM, MediaType.APPLICATION_STREAM_JSON);
        }
    
        public List<RouteDefinition> getRoutes() {
            return this.routes;
        }
    
        public void setRoutes(List<RouteDefinition> routes) {
            this.routes = routes;
        }
    
        public List<FilterDefinition> getDefaultFilters() {
            return this.defaultFilters;
        }
    
        public void setDefaultFilters(List<FilterDefinition> defaultFilters) {
            this.defaultFilters = defaultFilters;
        }
    
        public List<MediaType> getStreamingMediaTypes() {
            return this.streamingMediaTypes;
        }
    
        public void setStreamingMediaTypes(List<MediaType> streamingMediaTypes) {
            this.streamingMediaTypes = streamingMediaTypes;
        }
    
        public String toString() {
            return "GatewayProperties{routes=" + this.routes + ", defaultFilters=" + this.defaultFilters + ", streamingMediaTypes=" + this.streamingMediaTypes + '}';
        }
    }

    以上会被默认加载并且读取配置信息,如下配置信息:

    • spring.cloud.gateway.routes:网关路由定义配置,列表形式
    • spring.cloud.gateway.default-filters: 网关默认过滤器定义配置,列表形式
    • spring.cloud.gateway.streamingMediaTypes:网关网络媒体类型,列表形式
    其中routes是RouteDefinition集合,defaultFilters是FilterDefinition集合,参看具体的配置字段。实际配置文件可如下:
    spring:
      cloud:
        gateway:
          default-filters:
          - PrefixPath=/httpbin
          - AddResponseHeader=X-Response-Default-Foo, Default-Bar
          routes:
          - id: websocket_test
            uri: ws://localhost:9000
            order: 9000
            predicates:
            - Path=/echo
          - id: default_path_to_httpbin
            uri: ${test.uri}
            order: 10000
            predicates:
            - Path=/**

    注意:default-filters的配置PrefixPath=/httpbin字符串,可以查看FilterDefinition的构造函数,它其中构造函数包含接收一个text字符串解析字符传并创建实例信息。predicates的配置也是如此。

    字符传格式:name=param1,param2,param3

        public FilterDefinition(String text) {
            int eqIdx = text.indexOf("=");
            if (eqIdx <= 0) {
                this.setName(text);
            } else {
                this.setName(text.substring(0, eqIdx));
                String[] args = StringUtils.tokenizeToStringArray(text.substring(eqIdx + 1), ",");
    
                for(int i = 0; i < args.length; ++i) {
                    this.args.put(NameUtils.generateName(i), args[i]);
                }
    
            }
        }

    二、Route初始化加载

    2.1、GatewayAutoConfiguration加载RouteLocator

      Spring-Cloud-Gateway路由信息是通过路由定位器RouteLocator加载以及初始化。
      在Spring-Cloud-Gateway初始化时,同时GatewayAutoConfiguration核心配置类会被初始化加载如下 :
        /**
         * 创建一个根据RouteDefinition转换的路由定位器
         */
        @Bean
        public RouteLocator routeDefinitionRouteLocator(GatewayProperties properties,
                                                       List<GatewayFilterFactory> GatewayFilters,
                                                       List<RoutePredicateFactory> predicates,
                                                       RouteDefinitionLocator routeDefinitionLocator) {
            return new RouteDefinitionRouteLocator(routeDefinitionLocator, predicates, GatewayFilters, properties);
        }
    
        /**
         * 创建一个缓存路由的路由定位器
         * @param routeLocators
         * @return
         */
        @Bean
        @Primary//在相同的bean中,优先使用用@Primary注解的bean.
        public RouteLocator cachedCompositeRouteLocator(List<RouteLocator> routeLocators) {
            
            //1.创建组合路由定位器,根据(容器)已有的路由定位器集合
            //2.创建缓存功能的路由定位器
            return new CachingRouteLocator(new CompositeRouteLocator(Flux.fromIterable(routeLocators)));
        }

    路由定位器的创建流程:

    1、RouteDefinitionRouteLocator
    2、CompositeRouteLocator
    3、CachingRouteLocator
    其中 RouteDefinitionRouteLocator 是获取路由的主要地方,CompositeRouteLocator,CachingRouteLocator对路由定位器做了附加功能的包装,最终使用的是CachingRouteLocator对外提供服务

    2.2、查看RouteLocator源码:

    /**
     * 路由定位器,服务获取路由信息
     * 可以通过 RouteDefinitionRouteLocator 获取 RouteDefinition ,并转换成 Route
     */
    public interface RouteLocator {
    
        /**
         * 获取路由
         */
        Flux<Route> getRoutes();
    }

    查看RouteLocator实现类

    缓存功能实现→CachingRouteLocator
    组合功能实现→CompositeRouteLocator
    通过路由定义转换路由实现→RouteDefinitionRouteLocator

    2.3、缓存功能实现→CachingRouteLocator

    // 路由定位器的包装类,实现了路由的本地缓存功能
    public class CachingRouteLocator implements RouteLocator {
        //目标路由定位器
        private final RouteLocator delegate;
    
        /**
         * 路由信息
         * Flux 相当于一个 RxJava Observable,
         * 能够发出 0~N 个数据项,然后(可选地)completing 或 erroring。处理多个数据项作为stream
         */
        private final Flux<Route> routes;
    
        // 本地缓存,用于缓存路由定位器获取的路由集合
        private final Map<String, List> cache = new HashMap<>();
        public CachingRouteLocator(RouteLocator delegate) {
            this.delegate = delegate;
            routes = CacheFlux.lookup(cache, "routes", Route.class)
                    .onCacheMissResume(() -> this.delegate.getRoutes().sort(AnnotationAwareOrderComparator.INSTANCE));
        }
        @Override
        public Flux<Route> getRoutes() {
            return this.routes;
        }
    
        // 刷新缓存
        public Flux<Route> refresh() {
            this.cache.clear();
            return this.routes;
        }
    
        @EventListener(RefreshRoutesEvent.class)
        void handleRefresh() {
            refresh();
        }
    }

    1、路由信息的本地缓存,通过Map<String, List> cache 缓存路由到内存中;
    2、此类通过@EventListener(RefreshRoutesEvent.class)监听RefreshRoutesEvent事件实现了对缓存的动态刷新;

    注:路由动态刷新,使用GatewayControllerEndpoint发布刷新事件

    @RestControllerEndpoint(id = "gateway")
    public class GatewayControllerEndpoint implements ApplicationEventPublisherAware{
        // 调用url= /gateway/refresh 刷新缓存中的路由信息
            @PostMapping("/refresh")
        public Mono<Void> refresh() {
            this.publisher.publishEvent(new RefreshRoutesEvent(this));
            return Mono.empty();
        }
    }

    2.4、组合功能实现→CompositeRouteLocator

    //组合多个 RRouteLocator 的实现,为Route提供统一获取入口
    public class CompositeRouteLocator implements RouteLocator {
    
        /**
         * 能够发出 0~N 个数据项(RouteLocator),然后(可选地)completing 或 erroring。处理多个数据项作为stream
         */
        private final Flux<RouteLocator> delegates;
    
        public CompositeRouteLocator(Flux<RouteLocator> delegates) {
            this.delegates = delegates;
        }
    
        @Override
        public Flux<Route> getRoutes() {
            //this.delegates.flatMap((routeLocator)-> routeLocator.getRoutes());
            return this.delegates.flatMap(RouteLocator::getRoutes);
        }
    }

    此类将遍历传入的目录路由定位器集合,组合每个路由定位器获取到的路由信息

    2.5、通过路由定义转换路由实现→RouteDefinitionRouteLocator

    //
    // Source code recreated from a .class file by IntelliJ IDEA
    // (powered by Fernflower decompiler)
    //
    
    package org.springframework.cloud.gateway.route;
    
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.Iterator;
    import java.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.function.Consumer;
    import java.util.function.Function;
    import java.util.stream.Collectors;
    import org.apache.commons.logging.Log;
    import org.apache.commons.logging.LogFactory;
    import org.springframework.beans.BeansException;
    import org.springframework.beans.factory.BeanFactory;
    import org.springframework.beans.factory.BeanFactoryAware;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.cloud.gateway.config.GatewayProperties;
    import org.springframework.cloud.gateway.event.FilterArgsEvent;
    import org.springframework.cloud.gateway.event.PredicateArgsEvent;
    import org.springframework.cloud.gateway.filter.FilterDefinition;
    import org.springframework.cloud.gateway.filter.GatewayFilter;
    import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
    import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory;
    import org.springframework.cloud.gateway.handler.AsyncPredicate;
    import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition;
    import org.springframework.cloud.gateway.handler.predicate.RoutePredicateFactory;
    import org.springframework.cloud.gateway.route.Route.AsyncBuilder;
    import org.springframework.cloud.gateway.support.ConfigurationUtils;
    import org.springframework.context.ApplicationEventPublisher;
    import org.springframework.context.ApplicationEventPublisherAware;
    import org.springframework.core.Ordered;
    import org.springframework.core.annotation.AnnotationAwareOrderComparator;
    import org.springframework.expression.spel.standard.SpelExpressionParser;
    import org.springframework.validation.Validator;
    import org.springframework.web.server.ServerWebExchange;
    import reactor.core.publisher.Flux;
    
    public class RouteDefinitionRouteLocator implements RouteLocator, BeanFactoryAware, ApplicationEventPublisherAware {
        protected final Log logger = LogFactory.getLog(this.getClass());
        private final RouteDefinitionLocator routeDefinitionLocator;
        private final Map<String, RoutePredicateFactory> predicates = new LinkedHashMap();
        private final Map<String, GatewayFilterFactory> gatewayFilterFactories = new HashMap();
        private final GatewayProperties gatewayProperties;
        private final SpelExpressionParser parser = new SpelExpressionParser();
        private BeanFactory beanFactory;
        private ApplicationEventPublisher publisher;
        @Autowired
        private Validator validator;
    
        public RouteDefinitionRouteLocator(RouteDefinitionLocator routeDefinitionLocator, List<RoutePredicateFactory> predicates, List<GatewayFilterFactory> gatewayFilterFactories, GatewayProperties gatewayProperties) {
            this.routeDefinitionLocator = routeDefinitionLocator;
            this.initFactories(predicates);
            gatewayFilterFactories.forEach((factory) -> {
                GatewayFilterFactory var10000 = (GatewayFilterFactory)this.gatewayFilterFactories.put(factory.name(), factory);
            });
            this.gatewayProperties = gatewayProperties;
        }
    
        public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
            this.beanFactory = beanFactory;
        }
    
        public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
            this.publisher = publisher;
        }
    
        private void initFactories(List<RoutePredicateFactory> predicates) {
            predicates.forEach((factory) -> {
                String key = factory.name();
                if (this.predicates.containsKey(key)) {
                    this.logger.warn("A RoutePredicateFactory named " + key + " already exists, class: " + this.predicates.get(key) + ". It will be overwritten.");
                }
    
                this.predicates.put(key, factory);
                if (this.logger.isInfoEnabled()) {
                    this.logger.info("Loaded RoutePredicateFactory [" + key + "]");
                }
    
            });
        }
    
        public Flux<Route> getRoutes() {
            return this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute).map((route) -> {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("RouteDefinition matched: " + route.getId());
                }
    
                return route;
            });
        }
    
        private Route convertToRoute(RouteDefinition routeDefinition) {
            AsyncPredicate<ServerWebExchange> predicate = this.combinePredicates(routeDefinition);
            List<GatewayFilter> gatewayFilters = this.getFilters(routeDefinition);
            return ((AsyncBuilder)Route.async(routeDefinition).asyncPredicate(predicate).replaceFilters(gatewayFilters)).build();
        }
    
        private List<GatewayFilter> loadGatewayFilters(String id, List<FilterDefinition> filterDefinitions) {
            List<GatewayFilter> filters = (List)filterDefinitions.stream().map((definition) -> {
                GatewayFilterFactory factory = (GatewayFilterFactory)this.gatewayFilterFactories.get(definition.getName());
                if (factory == null) {
                    throw new IllegalArgumentException("Unable to find GatewayFilterFactory with name " + definition.getName());
                } else {
                    Map<String, String> args = definition.getArgs();
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("RouteDefinition " + id + " applying filter " + args + " to " + definition.getName());
                    }
    
                    Map<String, Object> properties = factory.shortcutType().normalize(args, factory, this.parser, this.beanFactory);
                    Object configuration = factory.newConfig();
                    ConfigurationUtils.bind(configuration, properties, factory.shortcutFieldPrefix(), definition.getName(), this.validator);
                    GatewayFilter gatewayFilter = factory.apply(configuration);
                    if (this.publisher != null) {
                        this.publisher.publishEvent(new FilterArgsEvent(this, id, properties));
                    }
    
                    return gatewayFilter;
                }
            }).collect(Collectors.toList());
            ArrayList<GatewayFilter> ordered = new ArrayList(filters.size());
    
            for(int i = 0; i < filters.size(); ++i) {
                GatewayFilter gatewayFilter = (GatewayFilter)filters.get(i);
                if (gatewayFilter instanceof Ordered) {
                    ordered.add(gatewayFilter);
                } else {
                    ordered.add(new OrderedGatewayFilter(gatewayFilter, i + 1));
                }
            }
    
            return ordered;
        }
    
        private List<GatewayFilter> getFilters(RouteDefinition routeDefinition) {
            List<GatewayFilter> filters = new ArrayList();
            if (!this.gatewayProperties.getDefaultFilters().isEmpty()) {
                filters.addAll(this.loadGatewayFilters("defaultFilters", this.gatewayProperties.getDefaultFilters()));
            }
    
            if (!routeDefinition.getFilters().isEmpty()) {
                filters.addAll(this.loadGatewayFilters(routeDefinition.getId(), routeDefinition.getFilters()));
            }
    
            AnnotationAwareOrderComparator.sort(filters);
            return filters;
        }
    
        private AsyncPredicate<ServerWebExchange> combinePredicates(RouteDefinition routeDefinition) {
            List<PredicateDefinition> predicates = routeDefinition.getPredicates();
            AsyncPredicate<ServerWebExchange> predicate = this.lookup(routeDefinition, (PredicateDefinition)predicates.get(0));
    
            AsyncPredicate found;
            for(Iterator var4 = predicates.subList(1, predicates.size()).iterator(); var4.hasNext(); predicate = predicate.and(found)) {
                PredicateDefinition andPredicate = (PredicateDefinition)var4.next();
                found = this.lookup(routeDefinition, andPredicate);
            }
    
            return predicate;
        }
    
        private AsyncPredicate<ServerWebExchange> lookup(RouteDefinition route, PredicateDefinition predicate) {
            RoutePredicateFactory<Object> factory = (RoutePredicateFactory)this.predicates.get(predicate.getName());
            if (factory == null) {
                throw new IllegalArgumentException("Unable to find RoutePredicateFactory with name " + predicate.getName());
            } else {
                Map<String, String> args = predicate.getArgs();
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("RouteDefinition " + route.getId() + " applying " + args + " to " + predicate.getName());
                }
    
                Map<String, Object> properties = factory.shortcutType().normalize(args, factory, this.parser, this.beanFactory);
                Object config = factory.newConfig();
                ConfigurationUtils.bind(config, properties, factory.shortcutFieldPrefix(), predicate.getName(), this.validator);
                if (this.publisher != null) {
                    this.publisher.publishEvent(new PredicateArgsEvent(this, route.getId(), properties));
                }
    
                return factory.applyAsync(config);
            }
        }
    }
    View Code

    此类的核心方法getRoutes通过传入的routeDefinitionLocator获取路由定位,并循环遍历路由定位依次转换成路由返回,
    代码中可以看到getRoutes通过convertToRoute方法将路由定位转换成路由的

    2.5.1、RouteDefinition转换:convertToRoute

        // RouteDefinition 转换为对应的Route
        private Route convertToRoute(RouteDefinition routeDefinition) {
            //获取routeDefinition中的Predicate信息
            Predicate<ServerWebExchange> predicate = combinePredicates(routeDefinition);
            //获取routeDefinition中的GatewayFilter信息
            List<GatewayFilter> gatewayFilters = getFilters(routeDefinition);
            //构建路由信息
            return Route.builder(routeDefinition)
                    .predicate(predicate)
                    .replaceFilters(gatewayFilters)
                    .build();
        }

    convertToRoute方法功能作用
      获取routeDefinition中的Predicate信息 (通过combinePredicates方法)
      获取routeDefinition中的GatewayFilter信息(通过gatewayFilters方法)
      构建路由信息

    1、convertToRoute中combinePredicates获取routeDefinition中的Predicate信息如下:

        // 返回组合的谓词
        private Predicate<ServerWebExchange> combinePredicates(RouteDefinition routeDefinition) {
            //获取RouteDefinition中的PredicateDefinition集合
            List<PredicateDefinition> predicates = routeDefinition.getPredicates();
    
            Predicate<ServerWebExchange> predicate = lookup(routeDefinition, predicates.get(0));
    
            for (PredicateDefinition andPredicate : predicates.subList(1, predicates.size())) {
                Predicate<ServerWebExchange> found = lookup(routeDefinition, andPredicate);
                 //流程4
                //返回一个组合的谓词,表示该谓词与另一个谓词的短路逻辑AND
                predicate = predicate.and(found);
            }
    
            return predicate;
        }
    
        /**
         * 获取一个谓语定义(PredicateDefinition)转换的谓语
         * @param route
         * @param predicate
         * @return
         */
        @SuppressWarnings("unchecked")
        private Predicate<ServerWebExchange> lookup(RouteDefinition route, PredicateDefinition predicate) {
            //流程1
            //流程1==获取谓语创建工厂
            RoutePredicateFactory<Object> factory = this.predicates.get(predicate.getName());
            if (factory == null) {
                throw new IllegalArgumentException("Unable to find RoutePredicateFactory with name " + predicate.getName());
            }
            //流程2
            //获取参数
            Map<String, String> args = predicate.getArgs();
            if (logger.isDebugEnabled()) {
                logger.debug("RouteDefinition " + route.getId() + " applying "
                        + args + " to " + predicate.getName());
            }
    
            //组装参数
            Map<String, Object> properties = factory.shortcutType().normalize(args, factory, this.parser, this.beanFactory);
            //构建创建谓语的配置信息
            Object config = factory.newConfig();
            ConfigurationUtils.bind(config, properties,
                    factory.shortcutFieldPrefix(), predicate.getName(), validator);
            if (this.publisher != null) {
                this.publisher.publishEvent(new PredicateArgsEvent(this, route.getId(), properties));
            }
            //流程3
            //通过谓语工厂构建谓语
            return factory.apply(config);
        }

    获取Predicate流程:

    • 根据PredicateDefinition name 获取 RoutePredicateFactory
    • 根据PredicateDefinition args 组装 config信息
    • 通过RoutePredicateFactory 根据config信息创建Predicate信息
    • 多个Predicate 以短路逻辑AND组合
    2、convertToRoute中 getFilters获取routeDefinition中的GatewayFilter信息
    private List<GatewayFilter> getFilters(RouteDefinition routeDefinition) {
            List<GatewayFilter> filters = new ArrayList<>();
    
            //校验gatewayProperties是否含义默认的过滤器集合
            if (!this.gatewayProperties.getDefaultFilters().isEmpty()) {
                //加载全局配置的默认过滤器集合
                filters.addAll(loadGatewayFilters("defaultFilters",
                        this.gatewayProperties.getDefaultFilters()));
            }
    
            if (!routeDefinition.getFilters().isEmpty()) {
                //加载路由定义中的过滤器集合
                filters.addAll(loadGatewayFilters(routeDefinition.getId(), routeDefinition.getFilters()));
            }
    
            //排序
            AnnotationAwareOrderComparator.sort(filters);
            return filters;
        }
         /**
         * 加载过滤器,根据过滤器的定义加载
         * @param id
         * @param filterDefinitions
         * @return
         */
        @SuppressWarnings("unchecked")
        private List<GatewayFilter> loadGatewayFilters(String id, List<FilterDefinition> filterDefinitions) {
            //遍历过滤器定义,将过滤器定义转换成对应的过滤器
            List<GatewayFilter> filters = filterDefinitions.stream()
                    .map(definition -> {
                    
                       //流程1    //通过过滤器定义名称获取过滤器创建工厂
                        GatewayFilterFactory factory = this.gatewayFilterFactories.get(definition.getName());
                        if (factory == null) {
                            throw new IllegalArgumentException("Unable to find GatewayFilterFactory with name " + definition.getName());
                        }
                        //流程2
                        //获取参数
                        Map<String, String> args = definition.getArgs();
                        if (logger.isDebugEnabled()) {
                            logger.debug("RouteDefinition " + id + " applying filter " + args + " to " + definition.getName());
                        }
    
                        //根据args组装配置信息
                        Map<String, Object> properties = factory.shortcutType().normalize(args, factory, this.parser, this.beanFactory);
                        //构建过滤器创建配置信息
                        Object configuration = factory.newConfig();
                        ConfigurationUtils.bind(configuration, properties,
                                factory.shortcutFieldPrefix(), definition.getName(), validator);
    
    //流程3
                        //通过过滤器工厂创建GatewayFilter
                        GatewayFilter gatewayFilter = factory.apply(configuration);
                        if (this.publisher != null) {
                            //发布事件
                            this.publisher.publishEvent(new FilterArgsEvent(this, id, properties));
                        }
                        return gatewayFilter;
                    })
                    .collect(Collectors.toList());
    
            ArrayList<GatewayFilter> ordered = new ArrayList<>(filters.size());
            //包装过滤器使其所有过滤器继承Ordered属性,可进行排序
            for (int i = 0; i < filters.size(); i++) {
                GatewayFilter gatewayFilter = filters.get(i);
                if (gatewayFilter instanceof Ordered) {
                    ordered.add(gatewayFilter);
                }
                else {
                    ordered.add(new OrderedGatewayFilter(gatewayFilter, i + 1));
                }
            }
    
            return ordered;
        }
    • getFilters 方法 同时加载 全局配置 gatewayProperties与routeDefinition配置下的所有过滤器定义filterDefinitions
    • loadGatewayFilters 负责将filterDefinition转化成对应的GatewayFilter
      转化流程如下
    1. 根据filterDefinition name 获取 GatewayFilterFactory
    2. 根据filterDefinition args 组装 config信息
    3. 通过GatewayFilterFactory 根据config信息创建PGatewayFilter信息
  • 相关阅读:
    HTTP状态码
    CEFsharp使用代理及切換
    powershell生成时间戳13和10位
    不用Root卸载手机自带应用
    夜间浏览更护眼
    windows 10 1709精简版安装 Microsoft Store
    在elementary os上安装brave 浏览器
    单语言精简版win10下中文网页无法正常
    为输出添加序号
    SpringBoot对静态资源的映射规则
  • 原文地址:https://www.cnblogs.com/bjlhx/p/9785528.html
Copyright © 2020-2023  润新知