1 - 1.基于xml配置的web应用构建

直接用SpringBoot构建web应用可以说非常非常简单了,在使用SpringBoot构建后端服务之前,一直用的是Spring + SpringMVC基于xml的配置方式来玩的,所以在正式进入SpringBoot Web篇之前,有必要看一下不用SpringBoot应该怎么玩的,也因此方便凸显SpringBoot的优越性

I. Web 构建

1. 项目依赖

我们选择使用传统的SpringMVC + Tomcat/Jetty 运行war包方式来运行任务,创建一个maven项目之后,先添加上基本的依赖

<artifactId>201-mvc-xml</artifactId>
<!-- 注意这一行,我们指定war包 -->
<packaging>war</packaging>

<properties>
    <spring.version>5.1.5.RELEASE</spring.version>
</properties>

<dependencies>
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.1.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aop</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring.version}</version>
    </dependency>
</dependencies>

2. 项目结构

对于web项目,和我们传统的不一样的地方在于,会多一个 webapp 目录,在这个目录的 WEB-INF 文件夹下,会存有几个必要的配置文件

项目结构

图中的三个目录,都属于比较重要的

  • java : 存放源码
  • resources: 项目资源文件存放地
  • webapp: web的配置文件,资源文件默认存放地

3. 配置文件说明

java和resources这两个目录没啥好说的,主要来看一下webapp下面的三个xml配置文件

a. web.xml

在我们使用xml配置的生态体系中,这个配置文件至关重要;本节说到SpringMVC构建的应用,是在Servlet的生态上玩耍的;而web.xml这个配置文件,比如我们常见的Servlet定义,filter定义等等,都在这xml文件中

实例如下

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://java.sun.com/xml/ns/j2ee/web-app_3_1.xsd" version="3.1">

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/applicationContext.xml</param-value>
    </context-param>

    <!-- 解决乱码的问题 -->
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <async-supported>true</async-supported>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <servlet>
        <servlet-name>mvc-dispatcher</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
        <async-supported>true</async-supported>
    </servlet>

    <servlet-mapping>
        <servlet-name>mvc-dispatcher</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

上面的配置中,定义了 DispatcherServlet的名字为 mvc-dispatcher,根据规范,会有一个叫做 mvc-dispatcher-servlet.xml的配置文件,其中的配置将应用于DispatcherServlet的上下文

b. mvc-dispatcher-servlet.xml

这个文件主要可以用来定义Servlet相关的配置信息,比如视图解析,资源路径指定等;一个最简单的配置如下

<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:beans="http://www.springframework.org/schema/mvc"
       xmlns="http://www.springframework.org/schema/beans"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd  http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">


    <!--指定扫描的包路径,自动注册包含指定注解的对象到Spring容器,并包含了 context:annotation-config 的作用-->
    <context:component-scan base-package="com.git.hui.spring"/>
</beans>

mvc-dispatcher-servlet.xml中,context:component-scan非常非常重要,用来指定自动扫描并注册bean到容器的包路径,上面这一行配置,简单来讲可以认为做了下面几件事情

  • 扫描包 com.git.hui.spring 下所有的类,如果类上有 @Component, @Service, @Repository, @Contorller, @RestContorller, @Configuration等注解,会实例化为bean对象,并注册到Spring容器中
  • 其次就是实现DI的功能,实现bean的依赖注入

接下来看一下,如果不加上面这一行,也想实现对应的效果改怎样配置呢?

<!-- 这个使用来激活注册的Bean,简单来讲就是使Ioc工作起来 -->
<context:annotation-config/>

<bean name="printServer" class="com.git.hui.spring.PrintServer"/>
<bean name="helloRest" class="com.git.hui.spring.HelloRest"/>

源码后面会给出,首先是主动定义两个bean,其中 helloRest 为Controller, printServer 为一个Service,并被注入到helloRest中

如果只定义了两个bean,而不加上<context:annotation-config/>,则HelloRest中的printService会是null,演示如下图

异常示意图

此外,如果用了旧的Spring版本,直接用前面的配置,可能依然无法访问web服务,这个时候有必要加一下下面的注解; 对于使用aop,希望使用cglib代理的,需要如下配置

<!-- 支持mvc注解-->
<mvc:annotation-driven/>

<!-- 使用cglib实现切面代理 -->
<aop:aspectj-autoproxy proxy-target-class="true"/>

额外说明:现在基本上不怎么用xml配置了,有更简单的注解方式,上面的配置内容了解即可

c. applicationContext.xml

前面的截图中,还有个配置文件,这个是干嘛的呢?

DispatchServlet加载包含在web组件中的bean(如mapper,Controller,ViewResolver);我们应用中,还有些其他的Spring Bean(比如其他rpc访问的服务bean代理,db驱动组件等)则更多的是放在这个配置文件中定义

当然这个里面最简单的配置内容就是啥都没有,比如我们的demo工程

<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns="http://www.springframework.org/schema/beans"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd">
</beans>

4. 实例代码

配置完了之后,我们简单的定义一个reset服务用来测试,比如一个简单dean对象和一个简单的Controller

简单的bean对象

@Component
public class PrintServer {

    public void print() {
        System.out.println(System.currentTimeMillis());
    }

}

Controller如下

@RestController
public class HelloRest {

    @Autowired
    private PrintServer printServer;

    @ResponseBody
    @GetMapping("hello")
    public String sayHello(HttpServletRequest request) {
        printServer.print();
        return "hello, " + request.getParameter("name");
    }
}

5. 测试

上面我们的web应用就搭建完毕了,然后就是把它部署起来,看下能不能愉快的玩耍了;我们有两个方法

方法一:tomcat方式

  • 打包 mvn clean package -DskipTests=true ,然后target目录下会生成一个war包
  • 将war包放在tomcat的webapps目录下,然后启动tomcat进行访问即可

方法二:jetty方式

前面一种方式,有很多公司的服务是这么玩的,将服务达成war包丢到tomcat中,然后服务上线;然而在本地开发测试时,这样有点麻烦(当然可以通过idea配置tomcat调试法,个人感觉,依然麻烦)

我们使用jetty来玩耍就很简单了,首先在pom中添加配置,引入jetty插件

<build>
    <finalName>web-mvc</finalName>
    <plugins>
        <plugin>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-maven-plugin</artifactId>
            <version>9.4.12.RC2</version>
            <configuration>
                <httpConnector>
                    <port>8080</port>
                </httpConnector>
            </configuration>
        </plugin>
    </plugins>
</build>

然后启动方式可以使用命令: mvn jetty:run, 也可以使用idea,如下,直接双击运行或者右键选择debug模式启动

启动说明

然后我们愉快的启动测试过程如下

web测试

到此,一个基于 Spring + SpringMVC + Jetty + xml配置的web应用就搭建起来了;下一篇我们将讲一下,纯java注解方式,抛弃xml配置又可以怎样搭建一个web应用

II. 其他

- 系列博文

web系列:

mvc应用搭建篇:

0. 项目

2 - 2.基于java config无xml配置的web应用构建

前一篇博文讲了SpringMVC+web.xml的方式创建web应用,用过SpringBoot的童鞋都知道,早就没有xml什么事情了,其实Spring 3+, Servlet 3+的版本,就已经支持java config,不用再写xml;本篇将介绍下,如何利用java config取代xml配置

本篇博文,建议和上一篇对比看,贴出上一篇地址

I. Web构建

1. 项目依赖

对于依赖这一块,和前面一样,不同的在于java config 取代 xml

<artifactId>200-mvc-annotation</artifactId>
<packaging>war</packaging>

<properties>
    <spring.version>5.1.5.RELEASE</spring.version>
</properties>

<dependencies>
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.1.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aop</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <dependency>
        <groupId>org.eclipse.jetty.aggregate</groupId>
        <artifactId>jetty-all</artifactId>
        <version>9.2.19.v20160908</version>
    </dependency>
</dependencies>

<build>
    <finalName>web-mvc</finalName>
    <plugins>
        <plugin>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-maven-plugin</artifactId>
            <version>9.4.12.RC2</version>
            <configuration>
                <httpConnector>
                    <port>8080</port>
                </httpConnector>
            </configuration>
        </plugin>
    </plugins>
</build>

细心的童鞋会看到,依赖中多了一个jetty-all,后面测试篇幅会说到用法

2. 项目结构

第二节依然放上项目结构,在这里把xml的结构也截进来了,对于我们的示例demo而言,最大的区别就是没有了webapp,更没有webapp下面的几个xml配置文件

项目结构

3. 配置设定

现在没有了配置文件,我们的配置还是得有,不然web容器(如tomcat)怎么找到DispatchServlet呢

a. DispatchServlet 声明

同样我们需要干的第一件事情及时声明DispatchServlet,并设置它的应用上下文;可以怎么用呢?从官方找到教程

{% blockquote @SpringWebMvc教程 https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-servlet %}

The DispatcherServlet, as any Servlet, needs to be declared and mapped according to the Servlet specification by using Java configuration or in web.xml. In turn, the DispatcherServlet uses Spring configuration to discover the delegate components it needs for request mapping, view resolution, exception handling

{% endblockquote %}

上面的解释,就是说下面的代码和web.xml的效果是一样一样的

public class MyWebApplicationInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletCxt) {
        // Load Spring web application configuration
        AnnotationConfigWebApplicationContext ac = new AnnotationConfigWebApplicationContext();
        ac.register(AppConfig.class);
        ac.refresh();

        // Create and register the DispatcherServlet
        DispatcherServlet servlet = new DispatcherServlet(ac);
        ServletRegistration.Dynamic registration = servletCxt.addServlet("mvc-dispatcher", servlet);
        registration.setLoadOnStartup(1);
        registration.addMapping("/*");
    }
}

当然直接实现接口的方式有点粗暴,但是好理解,上面的代码和我们前面的web.xml效果一样,创建了一个DispatchServlet, 并且绑定了url命中规则;设置了应用上下文AnnotationConfigWebApplicationContext

这个上下文,和我们前面的配置文件mvc-dispatcher-servlet有点像了;如果有兴趣看到项目源码的同学,会发现用的不是上面这个方式,而是及基础接口AbstractDispatcherServletInitializer

public class MyWebApplicationInitializer extends AbstractDispatcherServletInitializer {
    @Override
    protected WebApplicationContext createRootApplicationContext() {
        return null;
    }

    @Override
    protected WebApplicationContext createServletApplicationContext() {
        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
        //        applicationContext.setConfigLocation("com.git.hui.spring");
        applicationContext.register(RootConfig.class);
        applicationContext.register(WebConfig.class);
        return applicationContext;
    }

    @Override
    protected String[] getServletMappings() {
        return new String[]{"/*"};
    }
    
    @Override
    protected Filter[] getServletFilters() {
        return new Filter[]{new HiddenHttpMethodFilter(), new CharacterEncodingFilter()};
    }
}

看到上面这段代码,这个感觉就和xml的方式更像了,比如Servlet应用上下文和根应用上下文

说明

上面代码中增加的Filter先无视,后续会有专文讲什么是Filter以及Filter可以怎么用

b. java config

前面定义了DispatchServlet,接下来对比web.xml就是需要配置扫描并注册bean了,本文基于JavaConfig的方式,则主要是借助 @Configuration 注解来声明配置类(这个可以等同于一个xml文件)

前面的代码也可以看到,上下文中注册了两个Config类

RootConfig定义如下,注意下注解@ComponentScan,这个等同于<context:component-sca/>,指定了扫描并注册激活的bean的包路径

@Configuration
@ComponentScan(value = "com.git.hui.spring")
public class RootConfig {
}

另外一个WebConfig的作用则主要在于开启WebMVC

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
}

4. 实例代码

实例和上一篇一样,一个普通的Server Bean和一个Controller

@Component
public class PrintServer {
    public void print() {
        System.out.println(System.currentTimeMillis());
    }
}

一个提供rest服务的HelloRest

@RestController
public class HelloRest {
    @Autowired
    private PrintServer printServer;

    @GetMapping(path = "hello", produces="text/html;charset=UTF-8")
    public String sayHello(HttpServletRequest request) {
        printServer.print();
        return "hello, " + request.getParameter("name");
    }


    @GetMapping({"/", ""})
    public String index() {
        return UUID.randomUUID().toString();
    }
}

5. 测试

测试依然可以和前面一样,使用jetty来启动,此外,介绍另外一种测试方式,也是jetty,但是不同的是我们直接写main方法来启动服务

public class SpringApplication {

    public static void main(String[] args) throws Exception {
        Server server = new Server(8080);
        ServletContextHandler handler = new ServletContextHandler();

        // 服务器根目录,类似于tomcat部署的项目。 完整的访问路径为ip:port/contextPath/realRequestMapping
        //ip:port/项目路径/api请求路径
        handler.setContextPath("/");

        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
        applicationContext.register(WebConfig.class);
        applicationContext.register(RootConfig.class);

        //相当于web.xml中配置的ContextLoaderListener
        handler.addEventListener(new ContextLoaderListener(applicationContext));

        //springmvc拦截规则 相当于web.xml中配置的DispatcherServlet
        handler.addServlet(new ServletHolder(new DispatcherServlet(applicationContext)), "/*");

        server.setHandler(handler);
        server.start();
        server.join();
    }
}

测试示意图如下

测试示意图

6. 小结

简单对比下xml的方式,会发现java config方式会清爽很多,不需要多个xml配置文件,维持几个配置类,加几个注解即可;当然再后面的SpringBoot就更简单了,几个注解了事,连上面的两个Config文件, ServletConfig都可以省略掉

另外一个需要注意的点就是java config的运行方式,在servlet3之后才支持的,也就是说如果用比较老的jetty是起不来的(或者无法正常访问web服务)

II. 其他

- 系列博文

web系列:

mvc应用搭建篇:

0. 项目

3 - 3.一个web demo应用构建全过程

前面分别通过Spring结合web.xml和java config的方式构建web应用,最终实现效果差不多,但从结构上来看java config的方式明显更优雅一点;而本篇将介绍的SpringBoot方式,则更能让我们感受到便捷

本篇博文,建议与前面两篇对比阅读,效果更佳

II. web构建

1. 项目依赖

与前面一样,搭建SpringBoot web工程,需要引入对应的依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.0.M1</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

开始接触SpringBoot,不熟悉应该引入什么依赖的提前下,一个简单方法就是通过官网来创建项目

对于SpringBoot web应用而言,我们需要引入的包就是

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

2. 配置

SpringBoot抛弃了xml的配置方式,也是基于java config这一套玩耍的,但是它更加的简单

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

在我们的启动类入口上,添加上注解 @SpringBootApplication即可,这样一个SpringBoot应用就完成了

不需要其他的任何配置,默认开启的http端口是8080,如需修改,可以通过 application.properties 或者 application.yml 文件来重新指定

3. 实例代码

然后就可以写我们的Controller代码了

@RestController
public class HelloRest {
    @Autowired
    private PrintServer printServer;

    @GetMapping(path = "hello")
    public String sayHello(HttpServletRequest request) {
        printServer.print();
        return "hello, " + request.getParameter("name");
    }
    
    @GetMapping({"/", ""})
    public String index() {
        return UUID.randomUUID().toString();
    }
}

一个普通的bean

@Service
public class PrintServer {
    public void print() {
        System.out.println(System.currentTimeMillis());
    }
}

4. 测试

测试也比较简单,直接运行前面的main方法即可,如下图

show.gif

5. 小结

使用SpringBoot搭建一个基础的web应用,由于过于简单,也没有什么特别多值得说到地方,这里对比前面两篇,会发现优势特别特别的明显,极大的减少了入门门槛,整个项目更加轻量简洁,个人感觉,今后基于SpringBoot搭建后端应用的趋势,会取代原来的存Spring的方式

II. 其他

- 系列博文

web系列:

mvc应用搭建篇:

0. 项目

4 - 4.实现后端的接口版本支持(应用篇)

作为一个主职的后端开发者,在平时的工作中,最讨厌的做的事情可以说是参数校验和接口的版本支持了。对于客户端的同学来说,业务的历史包袱会小很多,当出现不兼容的业务变动时,直接开发新的就好;然而后端就没有这么简单了,历史的接口得支持,新的业务也得支持,吭哧吭哧的新加一个服务接口,url又不能和之前的相同,怎么办?只能在某个地方加一个类似v1, v2

那么有没有一种不改变url,通过其他的方式来支持版本管理的方式呢?

本文将介绍一种,利用请求头来传递客户端版本,在相同的url中寻找最适合的这个版本请求的接口的实例case

主要用到的知识点为:

  • RequestCondition
  • RequestMappingHandlerMapping

I. 应用场景

我们希望同一个业务始终用相同的url,即便不同的版本之间业务完全不兼容,通过请求参数中的版本选择最合适的后端接口来响应这个请求

1. 约定

需要实现上面的case,首先有两个约定

  • 每个请求中必须携带版本参数
  • 每个接口都定义有一个支持的版本

2. 规则

明确上面两点前提之后,就是基本规则了

版本定义

根据常见的三段式版本设计,版本格式定义如下

x.x.x
  • 其中第一个x:对应的是大版本,一般来说只有较大的改动升级,才会改变
  • 其中第二个x:表示正常的业务迭代版本号,每发布一个常规的app升级,这个数值+1
  • 最后一个x:主要针对bugfix,比如发布了一个app,结果发生了异常,需要一个紧急修复,需要再发布一个版本,这个时候可以将这个数值+1

接口选择

通常的web请求都是通过url匹配规则来选择对应响应接口,但是在我们这里,一个url,可能会有多个不同的接口,该怎么选择呢?

  • 首先从请求中,获取版本参数 version
  • 从所有相同的url接口中,根据接口上定义的版本,找到所有小于等于version的接口
  • 在上面满足条件的接口中,选择版本最大的接口来响应请求

II. 应用实现

明确上面的应用场景之后,开始设计与实现

1. 接口定义

首先我们需要一个版本定义的注解,用于标记web服务接口的版本,默认版本好为1.0.0

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Api {

    /**
     * 版本
     *
     * @return
     */
    String value() default "1.0.0";
}

其次需要一个版本对应的实体类,注意下面的实现中,默认版本为1.0.0,并实现了Comparable接口,支持版本之间的比较

@Data
public class ApiItem implements Comparable<ApiItem> {

    private int high = 1;

    private int mid = 0;

    private int low = 0;

    public ApiItem() {
    }

    @Override
    public int compareTo(ApiItem right) {
        if (this.getHigh() > right.getHigh()) {
            return 1;
        } else if (this.getHigh() < right.getHigh()) {
            return -1;
        }

        if (this.getMid() > right.getMid()) {
            return 1;
        } else if (this.getMid() < right.getMid()) {
            return -1;
        }

        if (this.getLow() > right.getLow()) {
            return 1;
        } else if (this.getLow() < right.getLow()) {
            return -1;
        }
        return 0;
    }
}

需要一个将string格式的版本转换为ApiItem的转换类,并且支持了默认版本为1.0.0的设定

public class ApiConverter {
    public static ApiItem convert(String api) {
        ApiItem apiItem = new ApiItem();
        if (StringUtils.isBlank(api)) {
            return apiItem;
        }

        String[] cells = StringUtils.split(api, ".");
        apiItem.setHigh(Integer.parseInt(cells[0]));
        if (cells.length > 1) {
            apiItem.setMid(Integer.parseInt(cells[1]));
        }

        if (cells.length > 2) {
            apiItem.setLow(Integer.parseInt(cells[2]));
        }
        return apiItem;
    }
}

2. HandlerMapping接口选择

需要一个url,支持多个请求接口,可以考虑通过RequestCondition来实现,下面是具体的实现类

public class ApiCondition implements RequestCondition<ApiCondition> {

    private ApiItem version;

    public ApiCondition(ApiItem version) {
        this.version = version;
    }

    @Override
    public ApiCondition combine(ApiCondition other) {
        // 选择版本最大的接口
        return version.compareTo(other.version) >= 0 ? new ApiCondition(version) : new ApiCondition(other.version);
    }

    @Override
    public ApiCondition getMatchingCondition(HttpServletRequest request) {
        String version = request.getHeader("x-api");
        ApiItem item = ApiConverter.convert(version);
        // 获取所有小于等于版本的接口
        if (item.compareTo(this.version) >= 0) {
            return this;
        }

        return null;
    }

    @Override
    public int compareTo(ApiCondition other, HttpServletRequest request) {
        // 获取最大版本对应的接口
        return other.version.compareTo(this.version);
    }
}

虽然上面的实现比较简单,但是有必要注意一下两个逻辑

  • getMatchingCondition方法中,控制了只有版本小于等于请求参数中的版本的ApiCondition才满足规则
  • compareTo 指定了当有多个ApiCoondition满足这个请求时,选择最大的版本

自定义RequestMappingHandlerMapping实现类ApiHandlerMapping

public class ApiHandlerMapping extends RequestMappingHandlerMapping {
    @Override
    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        return buildFrom(AnnotationUtils.findAnnotation(handlerType, Api.class));
    }

    @Override
    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        return buildFrom(AnnotationUtils.findAnnotation(method, Api.class));
    }

    private ApiCondition buildFrom(Api platform) {
        return platform == null ? new ApiCondition(new ApiItem()) :
                new ApiCondition(ApiConverter.convert(platform.value()));
    }
}

注册

@Configuration
public class ApiAutoConfiguration implements WebMvcRegistrations {

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new ApiHandlerMapping();
    }
}

基于此,一个实现接口版本管理的微框架已经完成;接下来进入测试环节

III. 测试

case1. 方法上添加版本

设计三个接口,一个不加上注解,两外两个添加不同版本的注解

@RestController
@RequestMapping(path = "v1")
public class V1Rest {

    @GetMapping(path = "show")
    public String show1() {
        return "v1/show 1.0.0";
    }

    @Api("1.1.2")
    @GetMapping(path = "show")
    public String show2() {
        return "v1/show 1.1.2";
    }

    @Api("1.1.0")
    @GetMapping(path = "show")
    public String show3() {
        return "v1/show 1.1.0";
    }
}

在发起请求时,分别不带上版本,带指定版本,来测试对应的响应

  • 从上面的截图可以看出,请求头中没有版本时,默认给一个1.0.0的版本
  • 响应的是小于请求版本的接口中,版本最大的哪一个

case2. 类版本+方法版本

每个方法上添加版本有点蛋疼,在上面的注解定义中,就支持了类上注解,从实现上也可以看出,当方法和类上都有注解时,选择最大的版本

@Api("2.0.0")
@RestController
@RequestMapping(path = "v2")
public class V2Rest {

    @Api("1.1.0")
    @GetMapping(path = "show")
    public String show0() {
        return "v2/show0 1.1.0";
    }

    @GetMapping(path = "show")
    public String show1() {
        return "v2/show1 2.0.0";
    }

    @Api("2.1.1")
    @GetMapping(path = "show")
    public String show2() {
        return "v2/show2 2.1.1";
    }

    @Api("2.2.0")
    @GetMapping(path = "show")
    public String show3() {
        return "v2/show3 2.2.0";
    }
}

根据我们的实现规则,show0和show1都会相应 <2.1.1 的版本请求,这个时候会出现冲突;

  • 从上面的截图中,可以看出来版本小于2.0.0的请求,报的是404错误
  • 请求版本小于2.1.1的请求,报的是冲突异常

IV. 其他

0. 项目&相关博文

相关博文

5 - 5.徒手撸一个扫码登录示例工程(应用篇)

不知道是不是微信的原因,现在出现扫码登录的场景越来越多了,作为一个有追求、有理想新四好码农,当然得紧跟时代的潮流,得徒手撸一个以儆效尤

本篇示例工程,主要用到以下技术栈

  • qrcode-plugin:开源二维码生成工具包,项目链接: https://github.com/liuyueyi/quick-media
  • SpringBoot:项目基本环境
  • thymeleaf:页面渲染引擎
  • SSE/异步请求:服务端推送事件
  • js: 原生js的基本操作

I. 原理解析

按照之前的计划,应该优先写文件下载相关的博文,然而看到了一篇说扫码登录原理的博文,发现正好可以和前面的异步请求/SSE结合起来,搞一个应用实战,所以就有了本篇博文

关于扫码登录的原理,请查看: 聊一聊二维码扫描登录原理

1. 场景描述

为了照顾可能对扫码登录不太了解的同学,这里简单的介绍一下它到底是个啥

一般来说,扫码登录,涉及两端,三个步骤

  • pc端,登录某个网站,这个网站的登录方式和传统的用户名/密码(手机号/验证码)不一样,显示的是一个二维码
  • app端,用这个网站的app,首先确保你是登录的状态,然后扫描二维码,弹出一个登录授权的页面,点击授权
  • pc端登录成功,自动跳转到首页

2. 原理与流程简述

整个系统的设计中,最核心的一点就是手机端扫码之后,pc登录成功,这个是什么原理呢?

  • 我们假定app与后端通过token进行身份标识
  • app扫码授权,并传递token给后端,后端根据token可以确定是谁在pc端发起登录请求
  • 后端将登录成功状态写回给pc请求者并跳转首页(这里相当于一般的用户登录成功之后的流程,可以选择session、cookie或者jwt)

借助上面的原理,进行逐步的要点分析

  • pc登录,生成二维码
    • 二维码要求唯一,并绑定请求端身份(否则假定两个人的二维码一致,一个人扫码登录了,另外一个岂不是也登录了?)
    • 客户端与服务端保持连接,以便收到后续的登录成功并调首页的事件(可以选择方案比较多,如轮询,长连接推送)
  • app扫码,授权登录
    • 扫码之后,跳转授权页面(所以二维码对应的应该是一个url)
    • 授权(身份确定,将身份信息与pc请求端绑定,并跳转首页)

最终我们选定的业务流程关系如下图:

流程

II. 实现

接下来进入项目开发阶段,针对上面的流程图进行逐一的实现

1. 项目环境

首先常见一个SpringBoot工程项目,选择版本2.2.1.RELEASE

pom依赖如下

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.1.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>com.github.hui.media</groupId>
        <artifactId>qrcode-plugin</artifactId>
        <version>2.2</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>
<repositories>
    <repository>
        <id>spring-releases</id>
        <name>Spring Releases</name>
        <url>https://repo.spring.io/libs-release-local</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
    <repository>
        <id>yihui-maven-repo</id>
        <url>https://raw.githubusercontent.com/liuyueyi/maven-repository/master/repository</url>
    </repository>
</repositories>

关键依赖说明

  • qrcode-plugin: 不是我吹,这可能是java端最好用、最灵活、还支持生成各种酷炫二维码的工具包,目前最新版本2.2,在引入依赖的时候,请指定仓库地址https://raw.githubusercontent.com/liuyueyi/maven-repository/master/repository
  • spring-boot-starter-thymeleaf: 我们选择的模板渲染引擎,这里并没有采用前后端分离,一个项目包含所有的功能点

配置文件application.yml

server:
  port: 8080

spring:
  thymeleaf:
    mode: HTML
    encoding: UTF-8
    servlet:
      content-type: text/html
    cache: false

获取本机ip

提供一个获取本机ip的工具类,避免硬编码url,导致不通用

import java.net.*;
import java.util.Enumeration;

public class IpUtils {
    public static final String DEFAULT_IP = "127.0.0.1";

    /**
     * 直接根据第一个网卡地址作为其内网ipv4地址,避免返回 127.0.0.1
     *
     * @return
     */
    public static String getLocalIpByNetcard() {
        try {
            for (Enumeration<NetworkInterface> e = NetworkInterface.getNetworkInterfaces(); e.hasMoreElements(); ) {
                NetworkInterface item = e.nextElement();
                for (InterfaceAddress address : item.getInterfaceAddresses()) {
                    if (item.isLoopback() || !item.isUp()) {
                        continue;
                    }
                    if (address.getAddress() instanceof Inet4Address) {
                        Inet4Address inet4Address = (Inet4Address) address.getAddress();
                        return inet4Address.getHostAddress();
                    }
                }
            }
            return InetAddress.getLocalHost().getHostAddress();
        } catch (SocketException | UnknownHostException e) {
            return DEFAULT_IP;
        }
    }

    private static volatile String ip;

    public static String getLocalIP() {
        if (ip == null) {
            synchronized (IpUtils.class) {
                if (ip == null) {
                    ip = getLocalIpByNetcard();
                }
            }
        }
        return ip;
    }
}

2. 登录接口

@CrossOrigin注解来支持跨域,因为后续我们测试的时候用localhost来访问登录界面;但是sse注册是用的本机ip,所以会有跨域问题,实际的项目中可能并不存在这个问题

登录页逻辑,访问之后返回的一张二维码,二维码内容为登录授权url

@CrossOrigin
@Controller
public class QrLoginRest {
    @Value(("${server.port}"))
    private int port;

    @GetMapping(path = "login")
    public String qr(Map<String, Object> data) throws IOException, WriterException {
        String id = UUID.randomUUID().toString();
        // IpUtils 为获取本机ip的工具类,本机测试时,如果用127.0.0.1, localhost那么app扫码访问会有问题哦
        String ip = IpUtils.getLocalIP();

        String pref = "http://" + ip + ":" + port + "/";
        data.put("redirect", pref + "home");
        data.put("subscribe", pref + "subscribe?id=" + id);


        String qrUrl = pref + "scan?id=" + id;
        // 下面这一行生成一张宽高200,红色,圆点的二维码,并base64编码
        // 一行完成,就这么简单省事,强烈安利
        String qrCode = QrCodeGenWrapper.of(qrUrl).setW(200).setDrawPreColor(Color.RED)
                .setDrawStyle(QrCodeOptions.DrawStyle.CIRCLE).asString();
        data.put("qrcode", DomUtil.toDomSrc(qrCode, MediaType.ImageJpg));
        return "login";
    }
}

请注意上面的实现,我们返回的是一个视图,并传递了三个数据

  • redirect: 跳转url(app授权之后,跳转的页面)
  • subscribe: 订阅url(用户会访问这个url,开启长连接,接收服务端推送的扫码、登录事件)
  • qrcode: base64格式的二维码图片

注意:subscribeqrcode都用到了全局唯一id,后面的操作中,这个参数很重要

接着时候对应的html页面,在resources/templates文件下,新增文件login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="SpringBoot thymeleaf"/>
    <meta name="author" content="YiHui"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>二维码界面</title>
</head>
<body>

<div>
    <div class="title">请扫码登录</div>
    <img th:src="${qrcode}"/>
    <div id="state" style="display: none"></div>

    <script th:inline="javascript">
        var stateTag = document.getElementById('state');

        var subscribeUrl = [[${subscribe}]];
        var source = new EventSource(subscribeUrl);
        source.onmessage = function (event) {
            text = event.data;
            console.log("receive: " + text);
            if (text == 'scan') {
                stateTag.innerText = '已扫描';
                stateTag.style.display = 'block';
            } else if (text.startsWith('login#')) {
                // 登录格式为 login#cookie
                var cookie = text.substring(6);
                document.cookie = cookie;
                window.location.href = [[${redirect}]];
                source.close();
            }
        };

        source.onopen = function (evt) {
            console.log("开始订阅");
        }
    </script>
</div>
</body>
</html>

请注意上面的html实现,id为state这个标签默认是不可见的;通过EventSource来实现SSE(优点是实时且自带重试功能),并针对返回的结果进行了格式定义

  • 若接收到服务端 scan 消息,则修改state标签文案,并设置为可见
  • 若接收到服务端 login#cookie 格式数据,表示登录成功,#后面的为cookie,设置本地cookie,然后重定向到主页,并关闭长连接

其次在script标签中,如果需要访问传递的参数,请注意下面两点

  • 需要在script标签上添加th:inline="javascript"
  • [[${}]] 获取传递参数

3. sse接口

前面登录的接口中,返回了一个sse的注册接口,客户端在访问登录页时,会访问这个接口,按照我们前面的sse教程文档,可以如下实现

private Map<String, SseEmitter> cache = new ConcurrentHashMap<>();

@GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter subscribe(String id) {
    // 设置五分钟的超时时间
    SseEmitter sseEmitter = new SseEmitter(5 * 60 * 1000L);
    cache.put(id, sseEmitter);
    sseEmitter.onTimeout(() -> cache.remove(id));
    sseEmitter.onError((e) -> cache.remove(id));
    return sseEmitter;
}

4. 扫码接口

接下来就是扫描二维码进入授权页面的接口了,这个逻辑就比较简单了

@GetMapping(path = "scan")
public String scan(Model model, HttpServletRequest request) throws IOException {
    String id = request.getParameter("id");
    SseEmitter sseEmitter = cache.get(request.getParameter("id"));
    if (sseEmitter != null) {
        // 告诉pc端,已经扫码了
        sseEmitter.send("scan");
    }

    // 授权同意的url
    String url = "http://" + IpUtils.getLocalIP() + ":" + port + "/accept?id=" + id;
    model.addAttribute("url", url);
    return "scan";
}

用户扫码访问这个页面之后,会根据传过来的id,定位对应的pc客户端,然后发送一个scan的信息

授权页面简单一点实现,加一个授权的超链就好,然后根据实际的情况补上用户token(由于并没有独立的app和用户体系,所以下面作为演示,就随机生成一个token来替代)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="SpringBoot thymeleaf"/>
    <meta name="author" content="YiHui"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>扫码登录界面</title>
</head>
<body>

<div>
    <div class="title">确定登录嘛?</div>

    <div>
        <a id="login">登录</a>
    </div>

    <script th:inline="javascript">

        // 生成uuid,模拟传递用户token
        function guid() {

            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);

            });
        }

        // 获取实际的token,补齐参数,这里只是一个简单的模拟
        var url = [[${url}]];
        document.getElementById("login").href = url + "&token=" + guid();
    </script>

</div>
</body>
</html>

5. 授权接口

点击上面的授权超链之后,就表示登录成功了,我们后端的实现如下

@ResponseBody
@GetMapping(path = "accept")
public String accept(String id, String token) throws IOException {
    SseEmitter sseEmitter = cache.get(id);
    if (sseEmitter != null) {
        // 发送登录成功事件,并携带上用户的token,我们这里用cookie来保存token
        sseEmitter.send("login#qrlogin=" + token);
        sseEmitter.complete();
        cache.remove(id);
    }

    return "登录成功: " + token;
}

6. 首页

用户授权成功之后,就会自动跳转到首页了,我们在首页就简单一点,搞一个欢迎的文案即可

@GetMapping(path = {"home", ""})
@ResponseBody
public String home(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null || cookies.length == 0) {
        return "未登录!";
    }

    Optional<Cookie> cookie = Stream.of(cookies).filter(s -> s.getName().equalsIgnoreCase("qrlogin")).findFirst();
    return cookie.map(cookie1 -> "欢迎进入首页: " + cookie1.getValue()).orElse("未登录!");
}

7. 实测

到此一个完整的登录授权已经完成,可以进行实际操作演练了,下面是一个完整的演示截图(虽然我并没有真的用app进行扫描登录,而是识别二维码地址,在浏览器中进行授权,实际并不影响整个过程,你用二维扫一扫授权效果也是一样的)

演示

请注意上面截图的几个关键点

  • 扫码之后,登录界面二维码下面会显示已扫描的文案
  • 授权成功之后,登录界面会主动跳转到首页,并显示欢迎xxx,而且注意用户是一致的

8. 小结

实际的业务开发选择的方案可能和本文提出的并不太一样,也可能存在更优雅的实现方式(请有这方面经验的大佬布道一下),本文仅作为一个参考,不代表标准,不表示完全准确,如果把大家带入坑了,请留言(当然我是不会负责的🙃)

上面演示了徒手撸了一个二维码登录的示例工程,主要用到了一下技术点

  • qrcode-plugin:生成二维码,再次强烈安利一个私以为java生态下最好用二维码生成工具包 https://github.com/liuyueyi/quick-media/blob/master/plugins/qrcode-plugin (虽然吹得比较凶,但我并没有收广告费,因为这也是我写的😂)
  • SSE: 服务端推送事件,服务端单通道通信,实现消息推送
  • SpringBoot/Thymeleaf: 演示项目基础环境

最后,觉得不错的可以赞一下,加个好友有事没事聊一聊,关注个微信公众号支持一二,都是可以的嘛

III. 其他

0. 项目

相关博文

关于本篇博文,部分知识点可以查看以下几篇进行补全


6 - 6.一步步实现一个面向接口的网络访问实例(应用篇)

一个自定义实现的面向接口的网络访问实例,主要使用以下知识点:

  • ClassPathBeanDefinitionScanner 实现自定义bean扫描
  • ImportBeanDefinitionRegistrar 来实现bean注册
  • 代理:基于Cglib生成接口的代理类,实现接口增强
  • RestTemplate: 实现网络访问

1. 背景

有使用过dubbo-rpc框架的小伙伴应该有这样的感受

  • 服务提供方,写API接口,和具体的服务类,
  • 消费者通过引入API包,再Spring的生态下,直接@Autowired注入接口来实现远程服务访问

如客户端定义一个api如下

@Api
public interface UserApi {
    String getName(int id);

    String updateName(String user, int age);
}

对应消费者的使用姿势而言,直接注入即可(这里不展开dubbo的具体使用细节,主要是理解下这个用法)

@Service
public class ConsumerService {
    @Autowired
    private UserApi indexApi;

那么我有一个大胆的想法,如果我的项目中有http请求,我是否可以直接定义一个对应的接口,然后实现一个类似上面的使用方式呢?

  • 比如大名鼎鼎的 retrofit , openfeign

如果我们希望自己来实现这样的功能,应该怎么操作呢?

接下来我们以一个最简单、基础的实例,来演示下这个实现思路

部分代码参考自 https://github.com/LianjiaTech/retrofit-spring-boot-starter

2. 目标

首先明确以下我们希望实现的效果,我们假定,所有的请求都是POST表单,请求路径由类名 + 方法名来确定,如

@Api
public interface UserApi {
    // 对应的url: /UserApi/getName
    // 访问姿势形如 : curl -X POST '/UserApi/getName' -d 'id=xxx'
    String getName(int id);
    
    // 对应的url: /UserApi/updateName
    // 访问姿势形如:  curl -X POST '/UserApi/updateName' -d 'user=xxx&age=xxx'
    String updateName(String user, int age);
}

使用姿势直接像本地方法调用一样

@RestController
public class DemoRest {
    @Autowired
    private UserApi indexApi;

    @GetMapping
    public String call(String name, Integer age) {
        String ans = indexApi.updateName(name, age);
        String a2 = indexApi.getName(1);
        return ans + " | " + a2;
    }
}

3. 实现方式

明确上面的目标之后,接下来的实现,第一步相对清晰,哪些接口是需要生成代理对象的呢?

3.1 @Api

定义一个Api注解,用来修饰需要接口类,表示这些接口需要生成代理类,通过Http的方法访问

@Inherited
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Api {
}

3.2 接口扫描

下一步就是要扫描项目把接口上有@Api注解的都捞出来,需要创建代理类并注册到Spring容器

我们这里借助ClassPathBeanDefinitionScanner来实现扫描

@Slf4j
public class ApiScanner extends ClassPathBeanDefinitionScanner {
    private final ClassLoader classLoader;

    public ApiScanner(BeanDefinitionRegistry registry, ClassLoader classLoader) {
        super(registry, false);
        this.classLoader = classLoader;
        registerFilter();
    }

    public void registerFilter() {
        // 表示要过滤出带有 Api 注解的类
        addIncludeFilter(new AnnotationTypeFilter(Api.class));
    }

    // 扫描包下待有 `@Api` 注解的接口,调用 processBeanDefinitions() 实现接口代理类生成注册
    @Override
    protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
        Set<BeanDefinitionHolder> beanDefinitions = super.doScan(basePackages);
        if (beanDefinitions.isEmpty()) {
            logger.warn("No @Api interface was found in '" + Arrays.toString(basePackages) + "' package. Please check your configuration.");
        } else {
            processBeanDefinitions(beanDefinitions);
        }
        return beanDefinitions;
    }

    /**
     * 重写候选判断逻辑,捞出带有注解的接口
     *
     * @param beanDefinition
     * @return
     */
    @Override
    protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
        if (beanDefinition.getMetadata().isInterface()) {
            try {
                Class<?> target = ClassUtils.forName(beanDefinition.getMetadata().getClassName(), classLoader);
                return !target.isAnnotation();
            } catch (Exception ex) {
                logger.error("load class exception:", ex);
            }
        }
        return false;
    }

    private void processBeanDefinitions(Set<BeanDefinitionHolder> beanDefinitions) {
        GenericBeanDefinition definition;
        for (BeanDefinitionHolder holder : beanDefinitions) {
            definition = (GenericBeanDefinition) holder.getBeanDefinition();
            if (logger.isDebugEnabled()) {
                logger.debug("Creating ApiClient with name '" + holder.getBeanName()
                        + "' and '" + definition.getBeanClassName() + "' Interface");
            }
            definition.getConstructorArgumentValues().addGenericArgumentValue(Objects.requireNonNull(definition.getBeanClassName()));
            // beanClass全部设置为ApiFactoryBean
            definition.setBeanClass(ApiFactoryBean.class);
        }
    }
}

上面的实现中,有几个细节需要注意

  • addIncludeFilter(new AnnotationTypeFilter(Api.class)); 只注册了一个根据@Api注解进行过滤的Filter
  • doScan: 扫描包,捞出满足条件的类
    • isCandidateComponent: 覆盖了父类的判断,用于过滤出我们需要的目标接口,没有它的话会发现捞出来的是一个空集合
    • processBeanDefinitions: 针对捞出来的目标,指定FactoryBean(由它来创建bean对象),构造方法的参数为BeanClass

3.3 代理工厂类

上面再注册bean的时候,主要是借助FactoryBean来实现的,我们这里实现一个ApiFactoryBean,来负责为接口生成代理的访问类,再内部使用RestTemplate来执行POST请求

public class ApiFactoryBean<T> implements FactoryBean<T> {
    private Class<T> targetClass;

    public ApiFactoryBean(Class<T> targetClass) {
        this.targetClass = targetClass;
    }

    @Override
    @SuppressWarnings("unchecked")
    public T getObject() throws Exception {
        return ProxyUtil.newProxyInstance(targetClass, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                if (method.getName().equalsIgnoreCase("toString")) {
                    return method.invoke(proxy, args);
                }

                // 每次访问都创建了要给RestTemplate,可以考虑直接使用容器的bean对象, 方便与ribbon集成,实现负载均衡 
                RestTemplate restTemplate = new RestTemplate();
                MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();

                for (int index = 0; index < args.length; index++) {
                    Parameter p = method.getParameters()[index];
                    params.add(p.getName(), args[index]);
                }
    
                // 这里用于演示host是写死的,可以考虑根据配置来加载(比如 @Api 中指定host,或者 配置参数方式)
                String url = "http://127.0.0.1:8080/" + targetClass.getSimpleName() + "/" + method.getName();
                String response = restTemplate.postForObject(url, params, String.class);
                if (method.getReturnType() == String.class) {
                    return response;
                }

                return JSONObject.parseObject(response, method.getReturnType());
            }
        });
    }

    @Override
    public Class<?> getObjectType() {
        return targetClass;
    }
}

代理类的实现中,有几个可以优化的地方

  • restTemplate: 可以结合ribbon使用,从而实现更友好的负载策略
  • host: 上面是直接写死的,推荐采用配置化策略来替代(最简单的就是在application.yml文件中加一个api.host的参数,从它来获取,项目源码中给出了实例)

代理生成工具类

public class ProxyUtil {
    public static <T> T newProxyInstance(Class<?> targetClass, InvocationHandler invocationHandler) {
        if (targetClass == null) {
            return null;
        } else {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(targetClass);
            enhancer.setUseCache(true);
            enhancer.setCallback(new ProxyUtil.SimpleMethodInterceptor(invocationHandler));
            return (T) enhancer.create();
        }
    }

    private static class SimpleMethodInterceptor implements MethodInterceptor, Serializable {
        private transient InvocationHandler invocationHandler;

        public SimpleMethodInterceptor(InvocationHandler invocationHandler) {
            this.invocationHandler = invocationHandler;
        }

        @Override
        public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
            return this.invocationHandler.invoke(o, method, objects);
        }
    }
}

3.4 bean注册

Scanner通常配合Register使用,实现bean的注册

@Slf4j
public class ApiRegister implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, BeanFactoryAware, BeanClassLoaderAware {
    private ResourceLoader resourceLoader;
    private BeanFactory beanFactory;
    private ClassLoader classLoader;

    @Override
    public void setResourceLoader(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
    }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = beanFactory;
    }

    @Override
    public void setBeanClassLoader(ClassLoader classLoader) {
        this.classLoader = classLoader;
    }

    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        Set<String> packages = getPackagesToScan(importingClassMetadata);
        if (log.isDebugEnabled()) {
            packages.forEach(pkg -> log.debug("Using auto-configuration base package '{}'", pkg));
        }

        ApiScanner apiScanner = new ApiScanner(registry, classLoader);
        if (resourceLoader != null) {
            apiScanner.setResourceLoader(resourceLoader);
        }

        apiScanner.scan(packages.toArray(new String[0]));
    }

    private Set<String> getPackagesToScan(AnnotationMetadata metadata) {
        AnnotationAttributes attributes =
                AnnotationAttributes.fromMap(metadata.getAnnotationAttributes(com.git.hui.boot.web.anno.ApiScanner.class.getName()));
        String[] basePackages = attributes.getStringArray("basePackages");
        Class<?>[] basePackageClasses = attributes.getClassArray("basePackageClasses");

        Set<String> packagesToScan = new LinkedHashSet<>(Arrays.asList(basePackages));
        for (Class clz : basePackageClasses) {
            packagesToScan.add(ClassUtils.getPackageName(clz));
        }

        if (packagesToScan.isEmpty()) {
            packagesToScan.add(ClassUtils.getPackageName(metadata.getClassName()));
        }

        return packagesToScan;
    }
}

最后自定义一个扫描注解,让上面的Register生效

@Inherited
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Import(ApiRegister.class)
public @interface ApiScanner {
    @AliasFor("basePackages") String[] value() default {};

    @AliasFor("value") String[] basePackages() default {};

    Class<?>[] basePackageClasses() default {};
}

3.5 测试

上面就完成了我们的预期目标,接下来写一个demo测试一下

定义一个api,以及提供rest的Controller

项目1

启用端口号 8080

@Api
public interface UserApi {
    String getName(int id);

    String updateName(String user, int age);
}


@RestController
@RequestMapping(path = "UserApi")
public class UserRest implements UserApi {
    @Override
    @RequestMapping(path = "getName")
    public String getName(int id) {
        return "一灰灰blog: " + id;
    }

    @Override
    @PostMapping(path = "updateName")
    public String updateName(String user, int age) {
        return "update " + user + " age to: " + age;
    }
}

项目2

UserApi接口使用姿势,启用端口号 8081

@RestController
public class DemoRest {
    @Autowired
    private UserApi indexApi;

    @GetMapping
    public String call(String name, Integer age) {
        String ans = indexApi.updateName(name, age);
        String a2 = indexApi.getName(1);
        return ans + " | " + a2;
    }
}

测试访问:

curl 'http://127.0.0.1:8081?name=yihui&age=18'

## 输出日志如下
update yihui age to: 18 | 一灰灰blog: 1

II. 其他

0. 项目

7 - 7.最小成本实现REST服务扩展(应用篇)

最小成本的实现服务接口的rest支持,主要借助RequestMappingHandlerMapping来实现自定义的请求映射

1. 场景说明

如何最小成本的为一个非web服务提供rest接口?

什么场景会有上面这种需求场景呢,最近正好遇到了。我们有一个服务,本来是提供gprc的服务接口,结果因为某些原因,现在要提供http接口访问,难不成针对所有的Service都重新写一个对应的RestController,然后再做一层转发么

如果真这样,也着实有点蛋疼,那么有没有什么简单的方式来实现呢?

  • 一个拦截器,解析url,通过反射的方式调用
  • 借助RequestMappingHandlerMapping来实现扩展

接下来将主要介绍第二种方案,自定义url映射规则

2. 项目环境

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA + jdk1.8进行开发

pom核心依赖,web包即可

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

3. 设计方案

实现面向接口的REST服务扩展,通过自定义注解 RestService 来扫描哪些API需要生成对应的rest服务

url生成规则

  • 只支持POST请求
  • url: service/method
  • 参数: 表单方式提交

举例说明

@RestService
public interface UserApi {

    /**
     * getName by id
     *
     * @param id
     * @return
     */
    String getName(int id);
}

上面这个API生成的url访问姿势如下

curl -X POST 'http://127.0.0.1:8080/UserApi/getName' -d 'id=111'

4. 实现

整体的实现过程相对简单,两个核心点

4.1 注解定义

自定义一个注解,用于表明哪些接口需要生成REST服务,特别注意,这个注解上还有@RestController,不能少

@RestController
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RestService {
}

4.2 url映射

核心点,覆盖getMappingForMethod,找到当前类or父类上有RestService注解的目标类,然后生成RequestMappingInfo

public class RestAdapterHandlerMapping extends RequestMappingHandlerMapping {
    @Override
    public Map<RequestMappingInfo, HandlerMethod> getHandlerMethods() {
        return super.getHandlerMethods();
    }

    @Override
    public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) {
        super.registerMapping(mapping, handler, method);
    }

    @Override
    protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
        System.out.println("current handlerType: " + handlerType.getName());
        Class target = getMappingClass(handlerType);
        if (target == null) {
            return super.getMappingForMethod(method, handlerType);
        }

        String prefix = target.getSimpleName();
        RequestMappingInfo info  = createRequestMappingInfo(method, prefix, false);
        if (info != null) {
            return RequestMappingInfo.paths(prefix).build().combine(info);
        }
        return super.getMappingForMethod(method, handlerType);
    }

    private Class getMappingClass(Class<?> handlerType) {
        if (handlerType.isAnnotationPresent(RestService.class)) {
            return handlerType;
        }

        for (Class clz : handlerType.getInterfaces()) {
            if (clz.isAnnotationPresent(RestService.class)) {
                return clz;
            }
        }
        return null;
    }

    protected RequestMappingInfo createRequestMappingInfo(Method method, String prefix, boolean get) {
        RequestMappingInfo.Builder builder =
                RequestMappingInfo.paths(method.getName()).methods(get ? RequestMethod.GET : RequestMethod.POST);
        System.out.println("support url: " + prefix + "/" + method.getName() + (get ? "-X GET" : "-X POST"));
        return builder.build();
    }
}

4.3 注册

上面两个就是最核心的地方了,接下来注册一下就完全可用了

@Configuration
@SpringBootApplication
public class Application implements WebMvcRegistrations {

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new RestAdapterHandlerMapping();
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

5. 测试

接下来测试一下是否能完成我们的目标

一个接口,一个实现Service

@RestService
public interface UserApi {

    String getName(int id);

    String updateName(String user, int age);
}

@Service
public class UserApiImpl implements UserApi {
    Map<String, Integer> cache = new ConcurrentHashMap<>();

    @Override
    public String getName(int id) {
        return "一灰灰blog : " + id;
    }

    @Override
    public String updateName(String user, int age) {
        if (cache.containsKey(user)) {
            Integer old = cache.put(user, age);
            return "update " + user + " old:" + old + " to:" + age;
        }

        cache.put(user, age);
        return "add new: " + user + "| " + age;
    }
}

启动之后测试一下

$ curl -X POST 'http://127.0.0.1:8080/UserApi/updateName' -d 'user=一灰灰Blog&age=18'

add new: 一灰灰Blog| 18

II. 其他

0. 项目

8 - 8.从0到1实现自定义web参数映射器

SpringBoot系列之从0到1实现自定义web参数映射器

在使用SpringMVC进行开发时,接收请求参数属于基本功,当我们希望将传参与项目中的对象关联起来时,最常见的做法是默认的case(即传参name与我们定义的name保持一致),当存在不一致,需要手动指定时,通常是借助注解@RequestParam来实现,但是不知道各位小伙伴是否有发现,它的使用是有缺陷的

  • @RequestParam不支持配置在类的属性上

如果我们定义一个VO对象来接收传承,这个注解用不了,如当我们定义一个Java bean(pojo)来接收参数时,若是get请求,post表单请求时,这个时候要求传参name与pojo的属性名完全匹配,如果我们有别名的需求场景,怎么整?

最简单的如传参为: user_id=110&user_name=一灰灰

而接收参数的POJO为

public class ViewDo {
  private String uesrId;
  private String userName;
}

接下来本文通过从0到1,手撸一个自定义的web传参映射,带你了解SpringMVC中的参数绑定知识点

I. 项目搭建

1. 项目依赖

本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发

开一个web服务用于测试

<dependencies>
    <!-- 邮件发送的核心依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

配置文件application.yml

server:
  port: 8080

II. 别名映射

接下来我们的目的就是希望实现一个自定义的别名注解,来支持传参的别名绑定,核心知识点就是自定义的参数解析器 HandlerMethodArgumentResolver

0. 知识点概要说明

在下面的实现之前,先简单介绍一下我们要用到的知识点

参数处理类:HandlerMethodArgumentResolver,两个核心的接口方法

// 用于判断当前这个是否可以用来处理当前的传参
boolean supportsParameter(MethodParameter parameter);


// 实现具体的参数映射功能,从请求参数中获取对应的传参,然后设置给目标对象
@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;

所以我们的核心逻辑就是实现上面这个接口,然后实现上面的两个方法即可;当然直接实现原始的接口,额外需要处理的内容就稍稍有点多了,我们这里会用到SpringMVC本身提供的两个实现类,进行能力的扩展

  • ServletModelAttributeMethodProcessor:用于后续处理POJO类的属性注解
  • RequestParamMethodArgumentResolver:用于后续处理方法参数注解

1. 自定义注解

自定义的注解,支持挂在类成员上,也支持放在方法参数上

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ParamName {
    /**
     * new name
     */
    String value();
}

2. 自定义参数处理器

接下来我们就是需要定义上面注解的解析器,鉴于方法参数注解与类的成员注解的处理逻辑的差异性(后面说为啥要区分开)

首先来看一下当方法参数上,有上面注解时,对应的解析类

public class ParamArgumentProcessor extends RequestParamMethodArgumentResolver {
    public ParamArgumentProcessor() {
        super(true);
    }

    // 当参数上拥有 ParanName 注解,且参数类型为基础类型时,匹配
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(ParamName.class) && BeanUtils.isSimpleProperty(parameter.getParameterType());
    }


    // 根据自定义的映射name,从传参中获取对应的value
    @Override
    protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
        ParamName paramName = parameter.getParameterAnnotation(ParamName.class);
        String ans = request.getParameter(paramName.value());
        if (ans == null) {
            return request.getParameter(name);
        }
        return ans;
    }
}

上面的实现比较简单,判断是否可以使用当前Resolver的方法实现

  • parameter.hasParameterAnnotation(ParamName.class) && BeanUtils.isSimpleProperty(parameter.getParameterType());
  • 注意上面的实现,两个要求,一是参数注解,二是要求为简单对象(非简单对象则交给下面的Resolver来处理)

其次,另外一个实现方法resolveName就很直观了,根据绑定的name获取具体的传参

  • 注意:内部还做了一个兼容,当绑定的传参name找不到时,使用变量名来取传参
  • 举例说明:
    • 参数定义如 @ParamName("user_name") String userName
    • 那么上面这个传参值,会从传参列表中,取user_name对应的值,当user_name不存在时,则取userName对应的值

接下来则是针对参数为POJO的场景,此时我们的自定义参数解析器实现类ServletModelAttributeMethodProcessor,具体的实现逻辑如下

public class ParamAttrProcessor extends ServletModelAttributeMethodProcessor {
    private final Map<Class<?>, Map<String, String>> replaceMap = new ConcurrentHashMap<>();

    public ParamAttrProcessor() {
        super(true);
    }

    // 要求参数为非基本类型,且参数的成员上存在@ParamName注解
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        if (!BeanUtils.isSimpleProperty(parameter.getParameterType())) {
            for (Field field : parameter.getParameterType().getDeclaredFields()) {
                if (field.getDeclaredAnnotation(ParamName.class) != null) {
                    return true;
                }
            }
        }
        return false;
    }

    // 主要是使用自定义的DataBinder,给传参增加一些别名映射
    @Override
    protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest nativeWebRequest) {
        Object target = binder.getTarget();
        Class<?> targetClass = target.getClass();
        if (!replaceMap.containsKey(targetClass)) {
            Map<String, String> mapping = analyzeClass(targetClass);
            replaceMap.put(targetClass, mapping);
        }
        Map<String, String> mapping = replaceMap.get(targetClass);
        ParamDataBinder paramNameDataBinder = new ParamDataBinder(target, binder.getObjectName(), mapping);
        super.bindRequestParameters(paramNameDataBinder, nativeWebRequest);
    }


    // 避免每次都去解析targetClass对应的别名定义,在实现中添加一个缓存
    private static Map<String, String> analyzeClass(Class<?> targetClass) {
        Field[] fields = targetClass.getDeclaredFields();
        Map<String, String> renameMap = new HashMap<>();
        for (Field field : fields) {
            ParamName paramNameAnnotation = field.getAnnotation(ParamName.class);
            if (paramNameAnnotation != null && !paramNameAnnotation.value().isEmpty()) {
                renameMap.put(paramNameAnnotation.value(), field.getName());
            }
        }
        if (renameMap.isEmpty()) return Collections.emptyMap();
        return renameMap;
    }
}

虽然上面的实现相比较于第一个,代码量要长很多,但是逻辑其实也并不复杂

supportsParameter 判断是否可用

  • 参数为非基本类型
  • 参数至少有一个成员上有注解@ParamName

bindRequestParameters 请求参数绑定

  • 这个方法的核心诉求就是给传参中的key=value,添加一个别名
  • 举例说明:
    • 如原始的传参为 user_name = 一灰灰
    • 类属性定义如 @ParamName("user_name") String userName;
    • 这样,别名映射中,会有一个 user_name = userName 的kv存在
    • 然后在DataBinder中,给别名(userName)也添加进去

下面就是DataBinder的实现逻辑

public class ParamDataBinder extends ExtendedServletRequestDataBinder {
    private final Map<String, String> renameMapping;
    public ParamDataBinder(Object target, String objectName, Map<String, String> renameMapping) {
        super(target, objectName);
        this.renameMapping = renameMapping;
    }

    @Override
    protected void addBindValues(MutablePropertyValues mpvs, ServletRequest request) {
        super.addBindValues(mpvs, request);
        for (Map.Entry<String, String> entry : renameMapping.entrySet()) {
            String from = entry.getKey();
            String to = entry.getValue();
            if (mpvs.contains(from)) {
                mpvs.add(to, mpvs.getPropertyValue(from).getValue());
            }
        }
    }
}

3. 注册与测试

最终也是非常重要的一点就是需要注册我们的自定义参数解析器,实现WebMvcConfigurationSupport,重载addArgumentResolvers方法即可

@SpringBootApplication
public class Application  extends WebMvcConfigurationSupport {
    @Override
    protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new ParamAttrProcessor());
        argumentResolvers.add(new ParamArgumentProcessor());
    }
}

最后给一个基本的测试

@RestController
public class RestDemo {

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ViewDo {
        @ParamName("user_id")
        private Integer userId;
        @ParamName("user_name")
        private String userName;
    }
    
    /**
     * POJO 对应Spring中的参数转换是 ServletModelAttributeMethodProcessor | RequestParamMethodArgumentResolver
     *
     * @param viewDo
     * @return
     */
    @GetMapping(path = "getV5")
    public ViewDo getV5(ViewDo viewDo) {
        System.out.println("v5: " + viewDo);
        return viewDo;
    }

    /**
     * curl 'http://127.0.0.1:8080/postV1' -X POST -d 'user_id=123&user_name=一灰灰'
     * 注意:非json传参,jackson的配置将不会生效,即上面这个请求是不会实现下划线转驼峰的; 但是返回结果会是下划线的
     *
     * @param viewDo
     * @return
     */
    @PostMapping(path = "postV1")
    public ViewDo post(ViewDo viewDo) {
        System.out.println(viewDo);
        return viewDo;
    }
    
    @GetMapping(path = "ano")
    public ViewDo ano(@ParamName("user_name") String userName, @ParamName("user_id") Integer userId) {
        ViewDo viewDo = new ViewDo(userId, userName);
        System.out.println(viewDo);
        return viewDo;
    }
}

上面提供了三个接口

  • ano:参数为基本类型,通过@ParamName定义别名
  • getV5: 参数为非简单类型ViewDo,类成员上通过@ParamName指定别名映射
  • post: 同上,唯一区别在于它是post请求

实测结果如下

4. 小结

本文主要通过实现自定义的参数映射解析器,来支持自定义的参数别名绑定,虽然内容不多,但其基本实现,则主要利用的是SpringMVC的参数解析这一块知识点,当然本文作为应用篇,主要只是介绍了如何实现自定义的HandlerMethodArgumentResolver,当现有的参数解析满足不了我们的诉求时,完全可以仿造上面的实现来实现自己的应用场景(相信也不会太难)

最后抽取一下本文中使用到的知识点

  • 如何判断一个类是否为基本对象:org.springframework.beans.BeanUtils#isSimpleProperty
  • 自定义参数解析器:实现接口 HandlerMethodArgumentResolver
    • 方法1:supportsParameter,判断当前这个解析器是否适用
    • 方法2:resolveArgument,具体的参数解析实现逻辑
  • RequestParamMethodArgumentResolver:默认的方法参数解析器,主要用于简单参数类型的映射,内部封装了类型适配相关逻辑
  • ServletModelAttributeMethodProcessor:用于默认的POJO/ModelAttribute参数解析

III. 不能错过的源码和相关知识点

0. 项目

系列博文:

【WEB系列】如何支持下划线驼峰互转的传参与返回

1. 微信公众号: 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog