• Spring Cloud Zuul API服务网关之请求路由


    一、Zuul 介绍

    ​ 通过前几篇文章的介绍,我们了解了Spring Cloud Eureka 如何搭建注册中心,Spring Cloud Ribbon 如何做负载均衡,Spring Cloud Hystrix 断路器如何保护我们的服务,以防止雪崩效应的出现,Spring Cloud Feign进行声明式服务调用都有哪些应用,相比Ribbon和Hystrix都有哪些改善。可以说,以上几个组件都是搭建一套微服务架构所必须的。通过以上思路,能够梳理出下面这种基础架构:

    在此架构中,我们的服务集群是内部ServiceAServiceB,他们都会向Eureka Server集群进行注册与订阅服务。而OpenService是一个对外的Restful API 服务,它通过F5,Nginx等网络设备或工具软件实现对各个微服务的路由与负载,公开给外部客户端调用

    ​ 那么上述的架构存在什么问题呢?从运维的角度来看,当客户端单机某个功能的时候往往会发出一些请求到后端,这些请求通过F5,Nginx等设施的路由和负载均衡分配后,被转发到各个不同的实例上,而为了让这些设施能够正确的路由与分发请求,运维人员需要手动维护这些实例列表,当系统规模增大的时候,这些看似简单的维护回变得越来越不可取。 从开发的角度来看,为了保证服务的安全性,我们需要在调用内部接口的时候,加一层过滤的功能,比如权限的校验,用户登陆状态的校验等;同时为了防止客户端在请求时被篡改等安全方面的考虑,还会有一些签名机制的存在。

    ​ 正是由于上述架构存在的问题,API网关被提出,API网关更像是一个智能的应用服务器,它的定义类似于设计模式中的外观模式,它就像是一个门面的角色,结婚时候女方亲属堵门时候的角色,我去参加婚礼当伴郎的时候去村子里面见新娘,女方亲属会把鞋子藏起来,有可能藏在屋子里有可能藏在身上,这得需要你自己去寻找,找到了鞋子之后,你才能够给新娘穿上才能正式的会见家长。API网关真正实现的功能有请求路由负载均衡校验过滤请求转发的熔断机制服务的聚合等一系列功能。

    Spring Cloud Zuul通过与Spring Cloud Euerka进行整合,将自身注册为Eureka服务治理下的应用,同时从Eureka中获得了所有的微服务的实例信息。者可以通过使用Zuul来创建各种校验过滤器,然后指定哪些规则的请求需要执行校验逻辑,只有通过校验的才会被路由到具体的微服务接口。下面我们就来搭建一下Spring Cloud Zuul服务网关

    二、构建Spring Cloud Zuul网关

    ​ 下面我们就来实际搭建一下Zuul网关,来体会一下网关实际的用处

    构建网关

    ​ 在实现各种API网关服务的高级功能之前,我们先来启动一下前几章搭建好的服务server-providerfeign-consumereureka-server,虽然之前我们一直将feign-consumer视为消费者,但是在实际情况下,每个服务既时服务消费者,也是服务提供者,之前我们访问的http://localhost:9001/feign-consumer等一系列接口就是它提供的服务。这里就来介绍一下详细的构建过程

    • 创建一个Spring Boot功能,命名为api-gateway,并在Pom.xml文件中引入如下内容
        <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>1.3.7.RELEASE</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.api.gateway</groupId>
        <artifactId>api-gateway</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>api-gateway</name>
        <description>Demo project for Spring Boot</description>
    
        <properties>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-zuul</artifactId>
                <version>1.3.5.RELEASE</version>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-eureka</artifactId>
            </dependency>
    
        </dependencies>
    
        <dependencyManagement>
            <dependencies>
                <dependency>
                    <groupId>org.springframework.cloud</groupId>
                    <artifactId>spring-cloud-dependencies</artifactId>
                    <version>Brixton.SR5</version>
                    <type>pom</type>
                    <scope>import</scope>
                </dependency>
            </dependencies>
        </dependencyManagement>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    
    </project>
    
    

    对于spring-cloud-starter-zuul 依赖,可以通过查看依赖配置了解到,它不仅包含了Netflix Zuul的核心依赖zuul-core,还包括了下面这些网关的重要依赖

    • spring-cloud-starter-hystrix: 该依赖用在网关服务中实现对微服务转发时候的保护机制,通过线程隔离和断路器,防止因为微服务故障引发的雪崩效应
    • spring-cloud-starter-ribbon: 该依赖用在实现网关服务进行负载均衡和请求重试
    • spring-cloud-starter-actuactor: 该依赖用来提供常规的微服务管理端点。另外,Spring Cloud Zuul 中还特别提供了/routes端点来返回当前的路由规则
    • 在ApiGatewayApplication 主入口中添加@EnableZuulProxy注解开启服务网关功能
        @EnableZuulProxy
        @SpringBootApplication
        public class ApiGatewayApplication {
    
            public static void main(String[] args) {
                SpringApplication.run(ApiGatewayApplication.class, args);
            }
        }
    
    • 在application.properties 中配置Zuul应用的基础信息,包括应用名,端口号,具体如下
      spring.application.name=api-gateway
      server.port=5555
    

    请求路由

    ​ 下面,我们通过一个简单的示例来为上面构建的网关增加请求路由的功能,为了演示请求路由的功能,我们先将之前的Eureka服务注册中心和微服务应用都启动起来。观察下面的服务列表,可以看到两个微服务应用已经注册成功了

    传统路由方式

    ​ 使用Spring Cloud Zuul实现路由功能非常简单,只需要对api-gateway服务增加一些关于路由的配置规则,就能实现传统路由方式

      zuul.routes.api-a-url.path=/api-a-url/**
      # 映射具体的url路径
      zuul.routes.api-a-url.url=http://localhost:8080/
    

    该配置定义了发往API网关服务的请求中,所有符合/api-a-url/ 规则的访问都将被路由转发到 http://localhost:8080 的地址上,也就是说,当我们访问http://localhost:5555/api-a-url/hello 的时候,API网关服务会将该请求路由到http://localhost:8080/hello 提供的微服务接口中。其中,配置属性zuul.routes.api-a-url.path 中的api-a-url部分为路由的名字,可以任意定义,但是一组path和url映射关系的路由名要相同**

    面向服务的路由

    ​ 很显然,传统的配置方式对我们来说并不友好,他同样需要运维人员花费大量的时间维护各个路由path 和url的关系。为了解决这个问题,Spring Cloud Zuul实现了与Spring Cloud Eureka的无缝衔接,我们可以让路由的path不是映射具体的url,而是让它映射到具体的服务,而具体的url则交给Eureka的服务发现机制去自动维护

    • 为了实现与Eureka的整合,我们需要在api-gateway的pom.xml中引入spring-cloud-starter-eureka依赖
    		<dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-eureka</artifactId>
    		</dependency>
    
    • 在api-gateway服务中对应的application.properties文件中加入如下代码
      zuul.routes.api-a.path=/api-a/**
      zuul.routes.api-a.serviceId=server-provider
     
      zuul.routes.api-b.path=/api-b/**
      zuul.routes.api-b.serviceId=feign-consumer
      
      eureka.client.service-url.defaultZone=http://localhost:1111/eureka/
    

    针对我们之前准备的两个微服务应用server-providerfeign-consumer,在上面的配置中分别定义了api-a 和 api-b 的路由来映射它们。然后这个api-gateway的默认注册中心是默认注册中心地址

    • 完成上述配置后,我们可以将四个服务启动起来,分别是eureka-server, server-provider, feign-consumer, api-gateway服务,启动完毕,会在eureka-server信息面板中看到多了一个api-gateway网关服务。

    • http://localhost:5555/api-a/hello: 这个接口符合 /api-a/**的规则,由api-a 路由负责转发,该路由映射的serviceId 为 server-provider,所以最终/hello请求会被发送到server-provider服务的某个实例上去
    • http://localhost:9001/api-b/feign-consumer: 这个接口符合 /api-b/**的规则,由api-b 进行路由转发,实际的地址由Eureka负责映射,该路由的serviceId是feign-consumer, 所以最终 /feign-consumer 请求会被路由到 feign-consumer 服务上。

    请求过滤

    ​ 在实现了请求路由功能之后,我们的微服务应用提供的接口就可以通过统一的API网关入口被客户端访问到了,但是,每个客户端用户请求微服务应用提供的接口时,它们的访问权限往往都有一定限制。为了实现客户端请求的安全校验和权限控制,最简单和粗暴的方法就是为每个微服务应用都实现一套用于校验签名和鉴别权限的过滤器或拦截器。但是,这样的方法并不可取,因为同一个系统中会有很多校验逻辑相同的情况,最好的方法是将这些校验逻辑剥离出去,构成一个独立的服务。

    ​ 对于上面这种问题,更好的做法是通过前置的网关服务来完成非业务性质的校验。为了在API网关中实现对客户端请求的校验,我们将继续介绍Spring Cloud Zuul的另外一个核心功能:请求过滤,实现方法比较简单,我们只需要继承ZuulFilter抽象类并实现它定义的4个抽象函数即可

    ​ 下面的代码定义了一个简单的Zuul过滤器,它实现了在请求被路由之前检查HttpServletRequest中是否带有accessToken参数

    	public class AccessFilter extends ZuulFilter {
    
          private static Logger log = LoggerFactory.getLogger(AccessFilter.class);
    
          /**
           * 过滤器的执行时序
           * @return
           */
          @Override
          public String filterType() {
              return "pre";
          }
    
          /**
           * 过滤器的执行顺序
           * @return
           */
          @Override
          public int filterOrder() {
              return 0;
          }
    
          /**
           * 判断过滤器是否应该执行
           * @return
           */
          @Override
          public boolean shouldFilter() {
              return true;
          }
    
          /**
           * 过滤器的具体执行逻辑
           * @return
           */
          @Override
          public Object run() {
              RequestContext rc = RequestContext.getCurrentContext();
              HttpServletRequest request = rc.getRequest();
              log.info("send {} request to {}", request.getMethod(),request.getRequestURL().toString());
    
              String accessToken = request.getParameter("accessToken");
              if(null == accessToken){
                  log.warn("access token is null");
                  rc.setResponseStatusCode(401);
                  rc.setSendZuulResponse(false);
              }
              log.info("access token ok");
              return null;
          }
      }
    

    在上面实现的过滤器代码中,我们通过继承ZuulFilter 抽象类并重写了四个方法

    • filterType : 过滤器类型,它决定过滤器的请求在哪个生命周期中执行,这里定义为pre,意思是在请求前执行
    • filterOrder : 过滤器的执行顺序,当请求在一个阶段存在多个过滤器时,需要根据方法的返回值来判断过滤器的执行顺序
    • shouldFilter: 过滤器是否需要执行,这里直接返回true,因为该过滤器对所有的请求都生效
    • run: 过滤器的具体逻辑,这里我们通过rc.setResponseStatusCode(401)设置失效的标志,rc.setSendZuulResponse(false)令Zuul过滤该请求

    在实现了自定义过滤器之后,它并不会直接生效,我们还需要为其创建具体的Bean才能启动该过滤器。

        @EnableZuulProxy
        @SpringBootApplication
        public class ApiGatewayApplication {
    
            public static void main(String[] args) {
                SpringApplication.run(ApiGatewayApplication.class, args);
            }
    
            @Bean
            public AccessFilter filter(){
                return new AccessFilter();
            }
        }
    

    在对api-gateway服务完成了上面的改造之后,我们可以重新启动它,并发起下面的请求,对上面的过滤器做一个验证

    ​ 到这里,对于API网关的快速入门示例就搭建完成了,通过对Spring Cloud Zuul 网关的搭建,我们能认知到网关的重要性,可以总结如下:

    • 它作为系统的统一入口, 屏蔽了系统内部各个微服务的细节。
    • 它可以与服务治理框架结合,实现自动化的服务实例维护以及负载均衡的路由转发。
    • 它可以实现接口权限校验与微服务业务逻辑的解耦。
    • 通过服务网关中的过滤器, 在各生命周期中去校验请求的内容, 将原本在对外服务层做的校验前移, 保证了微服务的无状态性, 同时降低了微服务的测试难度, 让服务本身更集中关注业务逻辑的处理。

    三、路由详解

    ​ 在上面快速入门的请求路由示例中,我们对Spring Cloud zuul中的两类路由功能已经做了简单的介绍,在本节中,将详细再介绍Spring Cloud Zuul的路由功能

    传统路由配置

    ​ 所谓的传统路由配置就是不依赖于服务发现的机制下,通过配置文件中具体指定每个路由表达式与服务实例关系来实现API网关对外部请求的路由。

    • **单实例配置: **通过zuul.routes.<route>.path zuul.routes.<route>.url参数对的方式进行配置,例如:
      zuul.routes.api-a-url.path=/api-a-url/**
      # 映射具体的url路径
      zuul.routes.api-a-url.url=http://localhost:8080/
    

    该实例配置实现了/api-a-url/ 规则的请求路径转发到http://localhost:8080/ 地址的路由规则**

    比如,当一个请求http://localhost:5555/api-a-url/hello 被发送到API网关之后,由于/api-a-url/能够被配置类映射到,所以API网关会进行转发,转发到http://localhost:8080/hello 上

    • **多实例配置: **通过zuul.routes.<route>.pathzuul.routes.<route>.serviceId参数对的方式进行配置,例如
      zuul.routes.api-a-url.path=/api-a-url/**
      zuul.routes.api-a-url.service-id=api-a-url
      ribbon.eureka.enabled=false
      api-a-url.ribbon.listOfServers=http://localhost:8080/, http://localhost:8081
    

    该配置实现了对符合/api-a-url/ 规则的请求路径转发到 http://localhost:8080/ 和 http://localhost:8081两个实例地址的路由规则。它的配置方式与服务路由的配置方式一样,都采用了zuul.routes..path 与 zuul.routes..serviceId参数对的映射方式,只是这里的serviceId 是由手工命名的服务名称,配合 ribbon.listOfServers 参数实现服务与实例的维护。由于列表中有 8080 和 8081 两个实例,所以还需要ribbon 进行负载均衡的配置,因为Zuul 默认带有Ribbon,所以就可以直接使用**

    ribbon.eureka.enabled : 由于zuul.routes.<route>.serviceId 指的是具体的服务名称,默认情况下ribbon 会根据服务发现机制来获取配置服务名对应的实例清单。但是,该示例并没有整合类似Eureka之类的服务治理框架,所以需要将该参数设置为false, 否则 配置 的serviceId获取不到对应实例的清单

    api-a-url.ribbon.listOfServers: 该参数内容与zuul.routes.<route>.serviceid的配置相对应, 开头的user-service 对应了serviceId的值,这两个参数的配置相当于在该应用内部手工维护了服务与实例的对应关系。

    服务路由配置

    ​ 对于服务路由,我们在上面的文字中已经讨论过,Spring Cloud Zuul通过与Spring Cloud eureka的整合,实现了对服务实例的自动化维护,所以在使用服务路由配置的时候,不再需要像传统的方式指定服务的具体url地址,而是可以通过指定服务的名称来配置,比如下面的例子就能很好说明:

      zuul.routes.api-a.path=/api-a/**
      zuul.routes.api-a.serviceId=server-provider
    

    zuul.routes..serviceId 中的serviceId 就是需要映射的具体服务

    对于面向服务的配置,除了使用path 和 serviceId 的配置外,还有一种更简便的配置方式:

    	zuul.routes.api-a=/api-a/**
    

    上面的这种配置方式使用了 zuul.routes.=的配置方式,与上面的 path 和 serviceId 共同使用的方式等价

    默认路由规则

    ​ 由于默认情况下所有Eureka上的服务都会被Zuul自动地创建映射关系来进行路由,这会使得一些我们不希望对外开放的服务也能被外部访问到,这个时候我们就需要屏蔽一些外部访问的服务

    	zuul.ignored-services=*
    

    使用zuul.ignored-services 参数来设置一个服务名匹配表达式来定义不自动创建路由的规则

    路径匹配

    ​ 还记得我们上面配置过的 /api-a/**吗? 后面的 ** 代表什么意思呢?其实这是一种路由匹配风格,路由匹配路径表达式采用Ant风格定义,具体如下

    通配符 说明
    ? 匹配任意单个字符
    * 匹配任意数量的字符
    ** 匹配任意数量的字符,支持多级目录

    通过如下的示例,可以让你参考使用

    URL 路径 说明
    /api-a/? 它可以匹配/api-a/之后拼接的一个单个字符的路径,比如/api-a/b, /api-a/c
    /api-a/* 它可以匹配/api-a/之后拼接的任意字符,但是不能跨层访问,比如可以访问到/api-a/bbb,/api-a/ccc,但是不能访问到/api-a/b/a
    /api-a/** 它可以访问任意路径,比如 /api-a/bbb, /api-a/bbb/aaa, /api-a/ccc/bbb/aaa

    properties文件 无法保证匹配顺序

    ​ 例如我因为版本上线,需要在properties文件中重新配置一下路由的路径,我第一个路由的路径是/api-a/**,对应的服务是api-a, 我第二个路由的路径是/api-a-pro/pro/**,对应的服务是api-a-pro相当于第二个路径是第一个路径的子集,这样就无法保证映射的顺序,也就是说 properties 配置的内容无法保证有序性,所以为了避免这种情况,采用YAML文件来配置,能够解决上述问题

    		zuul:
    			routes:
    				api-a-pro:
    					path: /api-a-pro/pro/**
    					serviceId: api-a-pro
    				api-a:
    					path: /api-a/**
    					serviceId: api-a
    

    注意: : 右边必须要有一个空格,这个yaml文件的书写规范

    忽略表达式

    ​ 通过path参数定义的Ant表达式已经能够完成API网关上的路由规则配置功能,但是为了更细粒度和更为灵活的配置路由规则,zuul还提供了一个忽略表达式参数zuul.ignored.patterns,该参数可以用来设置不希望被API网关进行路由的URL表达式

    比如,以上述示例为基础,如果不希望/hello接口被路由,那么我们可以这样设置

    # 过滤请求
    zuul.ignored-patterns=/**/hello/**
    zuul.routes.api-a.path=/api-a/**
    zuul.routes.api-a.serviceId=server-provider
    

    那么启动程序,访问http://localhost:5555/api-a/hello?accessToken=true,在api-gateway 的console 控制台上会显示如下信息

     WARN 28605 --- [io-5555-exec-10] o.s.c.n.z.f.pre.PreDecorationFilter      : No route found for uri: /api-a/hello
    
    路由前缀

    ​ 为了方便全局地为路由规则增加前缀信息,Zuul提供了zuul.prefix参数来进行设置。比如,希望为网关上的路由规则都增加/api 前缀,那么我们可以在配置文件中增加配置: zuul.prefix=/api。另外,对于代理前缀会默认从路径中移除,我们可以通过设置zuul.stripPrefix=false来关闭该移除代理前缀的动作,也可以通过zuul.routes.<route>.strip-prefix=true 来对指定路由关闭移除代理前缀的动作。

    本地跳转

    ​ 在实现的API网关的路由功能中,还支持forward形式的服务端跳转配置。实现方式也比较简单,直接在forward:/xxx 请求路径就可以了。下述这种方式就实现了服务跳转。

    zuul.routes.api-b.path=/api/b/**
    zuul.routes.api-b.url=forward:/local
    

    经过http://localhost:5555/api/b/** 的请求会被转发到 http://localhost:5555/local/**,下面进行简单验证。需要在api-gateway 中创建一个HelloController,如下:

    @RestController
    public class HelloController {
    
        @RequestMapping("/local/hello")
        public String hello(){
            return "Hello World Local";
        }
    }
    
    Cookie与头信息

    ​ 默认情况下,Spring Cloud Zuul 在请求路由时,会过滤掉HTTP请求头信息中的一些敏感信息,用来防止它们被传递到下游的服务器。默认的敏感头信息通过zuul.sensitiveHeaders参数定义,包括Cookie、Set-Cookie、Authorization 三个属性。

    有两种方式,一种是对全局的设置方式,但是这种设置方式比较暴力,不推荐

    zuul.sensitiveHeaders=
    

    还有一种方式是对指定的服务开启自定义敏感头,这种方式比较推荐

    # 方法一: 对指定路由开启自定义敏感头
    zuul.routes.<router>.customSensitiveHeaders=true
    # 方法二: 将指定路由的敏感头设置为空
    zuul.routes.<router>.sensitiveHeaders=
    
    Hystrix 和 Ribbon 支持

    ​ 点开pom.xml看到spring-cloud-starter-zuul的起步依赖中包括spring-cloud-starter-hystrixspring-cloud-starter-ribbon的依赖,所以Zuul天生就有线程隔离和断路器的自我保护功能。但是有一点需要注意:当使用path与url的映射关系来配置路由的时候,对于路由转发请求不会采用HystrixCommand来包装,所以没有线程隔离和断路器保护。所以我们在使用zuul的时候尽量使用path 和 service的组合来进行配置。

    在使用Zuul网关的时候,可以通过Hystrix和Ribbon的参数来调整路由请求的各种超时时间配置

    • hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds:该参数用来设置API网关中转发路由请求的HystrixCommand超时时间,单位为毫秒。当路由转发的请求时间大于配置的时间之后。Hystrix会将该执行该命令标记为TIMEOUT并抛出异常。
    {
        "timestamp": 1559269203828,
        "status": 500,
        "error": "Internal Server Error",
        "exception": "com.netflix.zuul.exception.ZuulException",
        "message": "TIMEOUT"
    }
    
    • ribbon.ReadTimeoutribbon.SocketTimeout ,一个是ribbon的读取超时时间,一个是ribbon的socket连接超时时间。
    • ribbon.ConnectTimeout 表示用来转发请求的时候,创建链接的超时时间。

    如果ribbon.ConnectTimeout的配置时间大于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds 的时间,就表示为连接还未建立的情况下就被熔断,不会触发重试机制,直接返回 TIMEOUT 的超时信息。

    如果ribbon.ReadTimeout的配置时间小于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds 的超时时间,此时若路由请求的处理时间超过该配置值且依赖服务的请求还未响应的时候,会自动进行重试路由请求。如果还没有路由到的话,会返回NUMBEROF_RETRIES_NEXTSERVER_EXCEEDED 错误。

    如果大于的话,会直接返回TIMEOUT超时信息。

    通过上文的描述我们知道,在ribbon.ReadTimeout 超时时间小于hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds的超时时间的话,会自动进行请求的重试,我们可以通过下面的配置,禁用请求重试机制

    # 全局关闭请求重试机制
    zuul.retryable=false 
    # 指定路由关闭请求重试机制
    zuul.routes.<route>.retryable=false
    

    文章来源:

    《Spring Cloud 微服务实战》

    https://cloud.spring.io/spring-cloud-static/spring-cloud-netflix/2.1.0.RELEASE/multi/multi__router_and_filter_zuul.html

  • 相关阅读:
    C#语法造成的小问题(编译原理知识)
    COM套间对.NET程序使用COM对象的影响
    为什么连接字符串一定要用StringBuilder(介绍CLR Profiler)
    编译原理系列文章
    .NET与COM互操作系列
    Windows XP SidebySide功能对VC程序的影响
    引起FileNotFoundException原因通用分析过程
    Flex组件的项目渲染器(ItemRenderer)使用总结
    Flex组件开发总结20090209
    如何去掉超链接图片外蓝色的边框
  • 原文地址:https://www.cnblogs.com/cxuanBlog/p/10955333.html
Copyright © 2020-2023  润新知