ramostear.comramostear.com 谭朝红的技术分享博客

格言 编程是一门技术,也是一门艺术 !

(六讲)Spring Boot REST API异常处理指南

(六讲)Spring Boot REST API异常处理指南

能够正确的处理REST API程序抛出的异常以及返回友好的异常信息是一件非常重要的事情,因为它可以帮助API客户端正确的对服务端的问题作出正确的响应。这有助于提高REST API的服务质量。Spring Boot默认返回的异常信息对于API客户端来说是晦涩难懂的,只有开发者才会关注那些堆栈异常报告。在本讲中,将对如何处理好Spring REST API异常信息做一个梳理。

最近一段时间,Spring Boot成为了Java开发圈子的网红,越来越多的开发者选择Spring Boot来构建REST API。使用Spring Boot,能够帮助开发者减少模板代码和配置文件的编写工作量。Spring Boot开箱即用的特性,受到广大开发者的热宠。在本讲中,通过一个精简的Demo项目,着重介绍一些Spring Boot REST API的异常处理技巧。

如果你不想阅读本次内容,只是想快速获得相关的源码,那你可以直接跳转到文章的结尾,找到Gihub仓库链接,通过该链接,你可以轻松的获得本次内容的全部源码。

1. 定义明确的异常信息

当程序发送错误时,不应该将晦涩的堆栈报告信息返回给API客户端,从某种意义将,这是一种不礼貌的和不负责任的行为。现在,我们将模拟这样一个需求,API客户端可以向服务端发送请求以获取一个或者多个用户信息,同时还可以发送请求创建一个新的用户信息。下面是大致的一个API信息:

API 名称 说明
GET /users/{userId} 根据用户ID检索用户信息,如果没有找到,则返回用户未找到异常信息
GET /users 根据传入的ID集合,检索用户信息,若未找到,返回未找到用户异常信息
POST /users 创建一个新的用户

Spring MVC为我们提供了一些很有用的功能,以帮助我们解决系统的异常信息,并将有用的提示信息返回给API客户端。

以 POST /users 创建一个新用户为例,当我们提供正常的用户数据并请求此接口时,REST API将返回如下的提示信息:

{
  "id": 2,
  "username": "wukong",
  "age": 52,
  "height": 170
}

现在,将用户年龄修改为200岁,身高修改为500厘米,用户名为rulai ,在此请求此REST API,观察API的返回信息:

{
  "restapierror": {
    "status": "BAD_REQUEST",
    "timestamp": "2019-05-19 06:04:47",
    "message": "Validation error",
    "subErrors": [
      {
        "object": "user",
        "field": "height",
        "rejectedValue": 500,
        "message": "用户身高不能超过250厘米"
      },
      {
        "object": "user",
        "field": "age",
        "rejectedValue": 200,
        "message": "用户年龄不能超过120岁"
      }
    ]
  }
}

如上所示,当API客户端提供不正确的数据时,REST API返回了格式良好的异常提示信息,timestamp由原来的整形时间戳格式化为一个可读的日期+时间,同时还详细列举了详细的错误报告。

2. 包装异常信息

为了能够提供一个可读的JSON格式异常信息给API客户端,我们需要在项目中引入Jackson JSR 310的依赖包,使用其提供的@JsonFormat注解将Java中的日期和时间按照我们给定的日期和时间模板进行格式化。现在,将下面的依赖包加入的Maven pom.xml文件中:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.9.8</version>
</dependency>

依赖就绪后,我们需要提供一个异常信息的包装类:RestApiError。它将负责对REST API抛出的异常信息进行封装:

public class RestApiError {

    private HttpStatus status;

    @JsonFormat(shape = JsonFormat.Shape.STRING,pattern = "yyyy-MM-dd hh:mm:ss")
    private LocalDateTime timestamp;

    private String message;

    private String debugMessage;

    private List<RestApiSubError> subErrors;

    private RestApiError(){
        timestamp = LocalDateTime.now();
    }

    RestApiError(HttpStatus status){
        this();
        this.status = status;
    }

    RestApiError(HttpStatus status,Throwable ex){
        this();
        this.status = status;
        this.message = "Unexpected error";
        this.debugMessage = ex.getLocalizedMessage();
    }

    RestApiError(HttpStatus status,String message,Throwable ex){
        this();
        this.status = status;
        this.message = message;
        this.debugMessage = ex.getLocalizedMessage();
    }
}
  • status 属性用于记录响应状态。它沿用了HttpStatus的所有状态吗,如4xx和5xx。
  • timestamp属性用于记录发送错误的时间
  • message属性用于记录自定义的异常消息,通常是对API客户端友好的提示信息
  • debugMessage属性用于记录更为详细的错误报告
  • subErrors属性用于记录异常附带的子异常信息,如用户实体中字段校验信息等

RestApiSubError类用于记录更为细致的异常信息,通常为实体类中字段校验失败的异常报告:

abstract class RestApiSubError{}
@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
class RestApiValidationError extends RestApiSubError{
    private String object;
    private String field;
    private Object rejectedValue;
    private String message;

    RestApiValidationError(String object,String message){
        this.object = object;
        this.message = message;
    }
}

RestApiSubError类是一个抽象的空类,具体的扩展将在RestApiValidationError中进行实现。RestApiValidationError类将记录实体类中(如本讲中的User对象)属性校验失败报告。

现在,我们来校验GET /users/1 API,检索用户ID为1的用户信息:

{
  "id": 1,
  "username": "ramostear",
  "age": 28,
  "height": 170
}

REST API成功的返回了用户信息,接下来我们传入一个系统不存在的用户ID,看看REST API返回什么信息:

GET /users/100

{
  "restapierror": {
    "status": "NOT_FOUND",
    "timestamp": "2019-05-19 06:31:17",
    "message": "User was not found for parameters {id=100}"
  }
}

通过上述的JSON信息我们可以看到,检索不存在的用户信息,REST API返回了友好的提示信息。在一开始的时候我们测试提供不合符规范的用户年龄和身高信息,接下来我们在来测试一下提供一个空的用户名,观察REST API返回的信息:

{
  "restapierror": {
    "status": "BAD_REQUEST",
    "timestamp": "2019-05-19 06:37:46",
    "message": "Validation error",
    "subErrors": [
      {
        "object": "user",
        "field": "username",
        "rejectedValue": "",
        "message": "不能为空"
      }
    ]
  }
}

3. Spring Boot 处理异常信息的流程

Spring Boot 处理REST API异常信息将会涉及到三个注解:

@ControllerAdivice注解是在Spring 3.2版本中新增的一个注解,它能够将单个由@ExceptionHandler注解标注的方法应用到多个控制器中。使用它的好处是我们可以在一个统一的地方同时处理多个控制器抛出的异常,当控制器有异常抛出时,ControllerAdvice会根据当前抛出的异常类型,自动匹配对应的ExceptionHandler;当没有特定的Exception可用时,将调用默认的异常信息处理类来处理控制器抛出的异常(默认的异常信息处理类)。

下面,我们通过一张流程示例图,更为直观的了解Spring Application处理控制器异常信息的全部过程:

在图中,蓝色箭头表示正常的请求和响应过程,红色箭头表示发生异常的请求和响应过程。

4. 自定义异常信息处理类

Spring Framework自带的异常信息处理类往往不能满足我们实际的业务需求,这就需要我们定义符合具体情况的异常信息处理类,在自定义异常信息处理类中,我们可以封装更为详细的异常报告。

自定义异常信息处理类,我们可以站在“巨人”的肩膀上,快速封装自己的异常信息处理类,而不必要从头开始造“轮子”。现在,为了快熟实现自定义异常信息处理类,并让其正常工作,我们可以直接扩展Spring 提供的ResponseEntityExceptionHandler类来定义用户异常信息处理类。ResponseEntityExceptionHandler已经提供了很多可用的功能,我们只需要扩展该类或者覆盖其提供的方法即可。

打开ResponseEntityExceptionHandler类,我们可以看到如下的源码:

public abstract class ResponseEntityExceptionHandler {

 //不支持的HTTP请求方法异常信息处理方法
 protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(...){...}
//不支持的HTTP媒体类型异常处理方法
 protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(...){...}   
//不接受的HTTP媒体类型异常处方法
 protected ResponseEntity<Object> handleHttpMediaTypeNotAcceptable(...){...}
 //请求路径参数缺失异常处方法
 protected ResponseEntity<Object> handleMissingPathVariable(...){...}   
 //缺少servlet请求参数异常处理方法   
 protected ResponseEntity<Object> handleMissingServletRequestParameter(...){...}
 //servlet请求绑定异常
 protected ResponseEntity<Object> handleServletRequestBindingException(...){...}   
//不支持转换
 protected ResponseEntity<Object> handleConversionNotSupported(...){...}
 //类型不匹配
 protected ResponseEntity<Object> handleTypeMismatch(...){...}
 //消息无法检索
 protected ResponseEntity<Object> handleHttpMessageNotReadable(...){...}
 //HTTP消息不可写
 protected ResponseEntity<Object> handleHttpMessageNotWritable(...){...}
 //方法参数无效
 protected ResponseEntity<Object> handleMethodArgumentNotValid(...){...}
 //缺少servlet请求部分
 protected ResponseEntity<Object> handleMissingServletRequestPart(...){...}
 //绑定异常
 protected ResponseEntity<Object> handleBindException(...){...}
 //没有发现处理程序异常
 protected ResponseEntity<Object> handleNoHandlerFoundException(...){...}   
 //异步请求超时异常   
 @Nullable
 protected ResponseEntity<Object> handleAsyncRequestTimeoutException(...){...}
 //内部异常
 protected ResponseEntity<Object> handleExceptionInternal(...){...}       
}

我们选择性的覆盖几个常用的异常处理方法,并添加我们自定义异常处理方法:

public class RestExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    protected ResponseEntity<Object> handleUserNotFound(UserNotFoundException ex){
        RestApiError apiError = new RestApiError(HttpStatus.NOT_FOUND);
        apiError.setMessage(ex.getMessage());
        return buildResponseEntity(apiError);
    }


    @Override
    protected ResponseEntity<Object> handleMissingServletRequestParameter(
            MissingServletRequestParameterException ex, HttpHeaders headers,
            HttpStatus status, WebRequest request) {
        String error = ex.getParameterName() + " parameter is missing";
        return buildResponseEntity(new RestApiError(BAD_REQUEST, error, ex));
    }



    @Override
    protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
            HttpMediaTypeNotSupportedException ex,
            HttpHeaders headers,
            HttpStatus status,
            WebRequest request) {
        StringBuilder builder = new StringBuilder();
        builder.append(ex.getContentType());
        builder.append(" media type is not supported. Supported media types are ");
        ex.getSupportedMediaTypes().forEach(t -> builder.append(t).append(", "));
        return buildResponseEntity(new RestApiError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, builder.substring(0, builder.length() - 2), ex));
    }

    ...

}

UserNotFoundException类为我们自定义异常信息类,在执行GET /users/{userIds}或 GET /users请求时,如果数据库中不存在该ID的记录信息,将抛出UserNotFoundException异常信息,且将响应状态码设置为NOT_FOUND。UserNotFoundException源码如下:

public class UserNotFoundException extends Exception {

    public UserNotFoundException(Class clz,String...searchParams){
        super(UserNotFoundException.generateMessage(clz.getSimpleName(),toMap(String.class,String.class,searchParams)));
    }

    private static String generateMessage(String entity, Map<String,String> searchParams){
        return StringUtils.capitalize(entity)+
                " was not found for parameters "+
                searchParams;
    }

    private static <K,V> Map<K,V> toMap(Class<K> key,Class<V> value,Object...entries){
        if(entries.length % 2 == 1){
            throw new IllegalArgumentException("Invalid entries");
        }
        return IntStream.range(0,entries.length/2).map(i->i*2)
                .collect(HashMap::new,
                        (m,i)->m.put(key.cast(entries[i]),value.cast(entries[i+1])),Map::putAll);
    }
}

下图将更为直观的说明自定义异常处理的整个流程:

当UserService发生异常时,异常信息将向上传递到UserController,此时的异常信息被Spring所捕获,并将其跳转到UserNotFoundException处理方法中。UserNotFoundException将异常报告封装到RestApiError对象中,并回传给API Client。通过此方法,API客户端将获得一份逻辑清晰的响应报告。

本次课程的全部源码已经上传到 Github 仓库,你可以点击此链接获取源码:https://github.com/ramostear/Spring_Boot_2.X_Tutorial/tree/master/spring-boot-exception-handing

(第五讲)自定义Spring Boot Starter

(第五讲)自定义Spring Boot Starter

(第五讲)自定义Spring Boot Starter

第四讲中我们已经了解了Spring Boot自动配置的基本原理,了解其原理之后,你会觉得Spring Boot的自动配置能力其实也没有那么的神秘。在本讲中,我们将尝试实现一个自定义的Spring Boot Starter,以巩固上一件中的内容。

1. 主要内容

  • 1.实现自定义的spring boot starter
  • 2.验证自定义的spring boot starter是否生效
  • 3.验证自动配置是否生效

本讲所涉及的源码已经上传到Github仓库,你可以通过下面的链接获取全部的源码:

https://github.com/ramostear/Spring_Boot_2.X_Tutorial/tree/master/spring-boot-starter-site

2. 实现自定义的spring boot starter

现在,我们来实现这一一个功能,当SiteInformation类在classpath下的时候,系统自动配置SiteInformation类的Bean,并且SiteInformation类中的属性可以在application.properties文件中进行配置。

首先,使用IntelliJ IDEA创建一个Maven工程,在当前Project上选择新建Module,在弹出面板中填写相关信息,如下图所示:

点击下一步,选择module存放路径,如下图所示:

点击完成按钮,生成新的Module,然后修改module中的pom.xml,添加spring-boot-autoconfigure依赖,完整的配置代码如下所示:

添加spring-boot-autoconfigure依赖的目的是让自定义的starter具有自动配置的能力

然后,创建一个名为:com.ramostear.spring.boot.starter.site的包,并在包下创建SiteInformation.java文件,这个类提供了站点的一些基本属性,源码如下:

@ConfigurationProperties(prefix = “site”)注解的作用是当我们需要覆盖站点信息时,在application.properties文件中需要使用“site”作为属性的前缀,如site.name = ramostear。如果不做任何配置,站点信息将使用默认值:unknown

接下来,创建一个SiteInformationProvider.java文件,用于返回站点信息,其源码如下所示:

SiteInformationProvider.java是自动配置的依据类,当引用此自定义Starter时,会依据SiteInformationProvider是否存在类创建这个类的Bean。

准备好上述两个类之后,需要创建一个自动配置类,如SiteInformationAutoConfiguration.java,其源码如下所示:

@ConditionalOnWebApplication注解表名此自动配置可以在Web应用中进行使用,@EnableConfigurationProperties(SiteInformation.class)注解指明提供自动配置的属性对象@ConditionalOnMissingBean(SiteInformationProvider.class)指明当容器中没有SiteInformationProvider这个类的Bean时,自动配置这个类的Bean

最后,需要将此配置类进行注册,Spring Boot才能完成自动配置工作。在resources资源文件夹下创建名为META-INF的文件夹,并添加名为spring.factories的文件,修改其内容为:

若有多个自动配置项,请用“,”进行分割,此处的“\”是为了换行后任然能够读取到属性。

完成上述的所有工作后,点击IDEA右侧Maven工具栏,选择当前项目,找到Plugins中的install选项并点击执行安装,如下图所示:

上述操作运行成功后,你可以在控制台看到如下所示的提示信息:

提示,请使用maven自带的插件对项目进行打包和安装,切勿使用spring-boot-maven-plugin进行打包安装,否则打包后的jar无法使用,请将下面的代码从你的pom文件中剔除:

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

至此,自定义的spring boot starter制作完成。接下来创建一个web工程,引入spring-boot-starter-site,测试此starter是否能够正常使用。

3. 创建Web工程

新建一个测试用的Module,并使用IDEA自带的Spring Initializr创建一个Web工程,如下图所示:

选择Module SDK的版本,选择下一步,填写Module相关信息,如下图所示:

然后点击下一步,将Web依赖添加到项目中,然后点击保存按钮,生成新的Module。如下如所示:

接下来,将spring-boot-starter-site依赖添加到测试用的Web工程中,pom.xml源码如下:

4. 验证自定义Starter

修改CustomStarterTestApplication.java中的源码,添加@RestController注解,并创建一个获取站点信息的方法,源码如下:

接下来在application.properties文件中添加如下的类容:

site.name = 谭朝红的技术分享博客
site.domain = www.ramostear.com
site.keywords = blog
site.description = 谭朝红的技术分享博客
site.copyright = Copyright 2019-Ramostear

logging.level.org.springframework = debug

配置logging.level.org.springframework= debug属性的作用是能够在控制台日志中查看自定义的starter是否生效。

5. 运行并测试

点击运行CustomStarterTestApplication主类,并观察控制台输出,当自定义的starter生效后,你可以在控制台看到如下的信息:

============================
CONDITIONS EVALUATION REPORT
============================


Positive matches:
-----------------
...
   SiteInformationAutoConfiguration matched:
      - @ConditionalOnWebApplication (required) found 'session' scope (OnWebApplicationCondition)

   SiteInformationAutoConfiguration#provider matched:
      - @ConditionalOnMissingBean (types: com.ramostear.spring.boot.starter.site.SiteInformationProvider; SearchStrategy: all) did not find any beans (OnBeanCondition)
...

最后,使用Postman工具请求http://localhost:8080/site/info 并观察返回的信息:

请求成功、并返回了站点的配置信息,详细信息如下所示:

{
  "name": "谭朝红的技术分享博客",
  "domain": "www.ramostear.com",
  "copyright": "Copyright 2019-Ramostear",
  "keywords": "blog",
  "description": "谭朝红的技术分享博客"
}

6. 总结

本小节演示了如何创建一个自定义的Spring Boot Starter,并进行测试。进一步加深和强化了对Spring Boot自动配置原理的理解。本次分享类容到此结束,谢谢~~

(第四讲)Spring Boot 自动化配置原理解析

(第四讲)Spring Boot 自动化配置原理解析

本讲着重介绍Spring Boot的核心技术原理—自动化配置(AutoConfiguration)。通过对Spring Boot自动化配置相关源码的梳理,一步步揭开Spring Boot自动化配置的神秘面纱。

1. 主要内容

通过本讲的内容,你可以找到下面几个问题的具体答案:

  1. 什么是自动化配置?
  2. 为什么需要自动化配置?
  3. 自动化配置是如何实现的?
  4. 如何调试自动化配置?

2. 为什么需要自动化配置

在常规的Spring应用程序中,充斥着大量的配置文件,我们需要手动去配置这些文件,如配置组件扫描、配置servlet、配置视图解析器,配置http编码等。这里以配置http编码为例,我们需要在web.xml文件中配置类似的一个filter:

下面的这段代码向我们展示了配置一个Servlet的经典写法:

如果我们的项目中使用Hibernate/JPA时 ,我们还需要配置诸如数据源、Bean工厂和事务管理器等。

上述的配置可以称得上是Spring应用程序的经典案例,但现在看起来,你是否觉得心累?常规的配置让开发人员将更多的经历耗费在了配置文件上。而这些配置都是一些固定模式的配置方式,甚至很多都是模板代码,那既然是这样一种情况,有没有一种可能性,让Spring自动完成这些模板配置工作呢?答案是肯定的,这就是Spring Boot AutoConfiguration产生的初衷。将开发人员从繁重的配置工作中解放出来,而这些繁琐的配置细节交由Spring Boot去完成,如果我们需要提供自己的配置参数,只需要覆盖自动配置的参数即可。现在是否觉得有点意思了?

3. Spring Boot自动化配置的核心原理

Spring Boot有关自动化配置的源码可以在spring-boot-autoconfigure-2.x.x.x.jar/spring-boot-autoconfigure-1.x.x.x.jar包中找到。在org.springframework.boot.autoconfigure包下,提供了如下的一些自动化配置源码:

如何你想了解更多关于Spring Boot自动配置的知识,可以在这里查看具体的源码。

在spring-boot-autoconfigure.jar包中还有一份重要的文件,它是Spring Boot实施自动配置的关键所在。该文件是/META-INFO/spring.factories。此文件列出了所有可以被Spring Boot实施自动配置的模块清单,下面列举其中的一部分以供参考:

4. 调试自动配置

有三种方式可以调试并查看有关自动配置的相关信息,如当前项目中有哪些组件被自动配置,有哪些组件未被自动配置以及产生的原因等。

  1. 通过java -jar [jarfile] —debug的方式运行项目,可以观察自动化配置的相关信息。

  2. 打开项目的调试日志记录。在application.properties文件中为org.springframework包添加日志记录级别为DEBUG :

    logging.level.org.springframework:DEBUG
    
  3. 为项目添加spring-boot-starter-actuator和spring-data-rest-hal-browser两个依赖:

使用方式一和方式二时,当我们重新启动应用程序时,你可以在控制台找到一份自动配置的报告,这份报告包含了被自动配置的组件信息和未被自动配置的组件信息以及相关的说明。

如果采用第三种方式,重新启动项目后,在浏览器地址栏输入http://localhost:8080/actuator/#http://localhost:8080/autoconfig ,你将收到如下的信息:

5 Spring Boot自动配置的核心原理

在Spring Boot应用程序中,所有的运作都起源于@SpringBootApplication注解,@SpringBootApplication打开了运行程序的大门。该注解是一个组合注解,其核心功能是由@EnableAutoConfiguration注解提供的。现在先让我们看一下@SpringBootApplication@EnableAutoConfiguration注解的源码。

在SpringBootApplication注解类中,最核心的地方是上图1处的@EnableAutoConfiguration注解,它为@SpringBootApplication注解类贡献了大部分的功能。我们再看看@Enableautoconfiguration注解的源码:

这里最关键的地方是由@Import注解导入的自动配置功能,EnableConfigurationImportSelector通过SpringFactoriesLoader.loadFactoryNames()方法来扫描spring-boot-autoconfigure.jar文件下/META-INF/spring.factories文件中配置的jar包信息,这就是一开始提到的那一份spring.factories文件的重要性。我们可以看一下EnableConfigurationImportSelector类中对应的代码:

6. 一个自动配置的示例分析

org.springframework.boot.autoconfigure包下已经提供了很多的自动配置类,接下来我们以DataSourceAutoConfiguration为例子,看看其中配置的细节。通常,所有自动配置类都会查看类路径中可用的其他类。如果类路径中有特定的类,则通过自动配置启用该功能的配置。判断类路径上是否存在特定的东西,这是通过org.springframework.boot.autoconfigure.condition包下的条件注解来完成的,这里列举几个常用的条件注解解:

  1. @ConditionalOnBean:当容器中有指定的Bean的条件下
  2. @ConditionalOnClass:当类路径下有指定的类的条件下
  3. @ConditionalOnMissingBean: 当容器中没有指定Bean的情况下
  4. @ConditionalOnMissingClass: 当类路径下没有指定的类的条件下

@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class }):仅当类路径中有这些类时,才会启用此配置。

@ConditionalOnMissingBean :仅当没有其他bean配置相同名称时才配置此Bean。

7.结束语

通过以上内容,简单的梳理了Spring Boot 自动配置的大致原理,同时回答了一开始提出的4个问题。Spring Boot的出现,是为了提高我们的开发效率,我们需要去了解其核心的基本原理,但任何时候都不应该被一个框架或者技术所绑架,更为重要的是学习其基本的原理,而不是框架本身。基于这个出发点,在下一讲中,将实现一个自定义的Spring Boot Starter,并提供自动化配置的能力。那本次内容到这里就结束了,谢谢~~

(第三讲)使用JUnit对Spring Boot中的Rest Controller进行单元测试

(第三讲)使用JUnit对Spring Boot中的Rest Controller进行单元测试

(第三讲)使用JUnit对Spring Boot中的Rest Controller进行单元测试

本次教程主要讲解如何对Spring Boot中的Rest Service进行单元测试。以往我们主要是使用JUnit对业务层进行单元测试,本次课程将使用一个简单的案例来说明如何使用JUnit对Spring Boot的Rest Service进行单元测试。

1. 主要类容

  • 快速搭建Restfull Service 环境
  • 创建GET请求以检索用户信息
  • 创建GET请求检索用户角色信息
  • 创建POST请求新增用户角色信息
  • 如何使用PostMan请求Restfull Service
  • 使用JUnit对GET请求进行单元测试
  • 使用JUnit对POST请求进行单元测试

2. 你将需要准备的工具

  • JDK 1.8及以上版本
  • Maven 3.0及以上版本的项目构建工具
  • IDEA代码编辑器

3. 你可以通过以下的地址获取本次课程的所有示例代码

项目代码已经上传到GitHub仓库中,你可以通过以下的地址获取示例源码:

https://github.com/ramostear/Spring_Boot_2.X_Tutorial/tree/master/spring-boot-junit-rest-service

4. 项目结构

下面通过一张截图来了解以下本次课程中我们使用到的项目结构。

首先我们需要位单元测试提供一个可用的Rest Controller。UserController文件为我们提供了一个可用于测试的Rest Controller。在UserController类中,我们提供两种请求类型的方法,一种是GET请求,另一种是POST请求。然后我们为这两种请求方式的方法编写单元测试用例。

在接下来的测试过程中,我们将使用Mockito来模拟请求UserService的过程,使用MockMvc来模拟请求UserController。单元测试的目的是将测试范围尽可能的缩小。在本次案例中,我们仅对UserController中的方法进行测试。

5. 初始化项目

我们依然使用Spring Initializr来初始化本次课程的项目,你需要配置如下图中的参数:

现在我们需要提供两个实体类:User和Role:

User.java

Role.java

6. 提供可用的业务服务

所有的应用都需要有数据的存储,本次课程主要的重点是为了Rest Controller的单元测试,因此使用ArrayList来充当数据库的角色。在案例中,一个用户可以有多个角色,一个角色也可以被赋予给多个用户。用户有ID,名字,别名和角色列表,角色具有ID,名称和描述。在UserService类中,将提供如图所示的公共方法。

7. 提供GET请求方法

在UserController类中,我们将提供如下几个公开的GET请求方法:

  • @GetMapping(value=”/users”) : 获取所有的用户信息
  • @GetMapping(value=”/users/{id}/roles”) : 根据用户ID获取该用户的所有角色信息

UserController.java类中的详细代码如下:

package com.ramostear.spring.boot.test.restservice.controller;

import com.ramostear.spring.boot.test.restservice.model.Role;
import com.ramostear.spring.boot.test.restservice.model.User;
import com.ramostear.spring.boot.test.restservice.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
public class UserController {
    private  final UserService userService;

    @Autowired
    public UserController(UserService userService){
        this.userService = userService;
    }

    @GetMapping(value = "/users")
    public List<User> findAllStudents(){
        return userService.findAllUsers();
    }

    @GetMapping(value = "/users/{id}/roles")
    public List<Role> findUserRoles(@PathVariable(value = "id")String id){
        return userService.findUserAllRoles(id);
    }

}

8. 使用Postman对RestController进行测试

我们将使用Postman工具对上述两个Rest API进行请求,首先向Postman地址栏输入http://localhost:8080/users 进行测试,获得的响应信息如下:

[
    {
        "id": "1001",
        "name": "ramostear",
        "alias": "谭朝红",
        "roles": [
            {
                "id": "1001",
                "name": "admin",
                "description": "all permissions for this role."
            }
        ]
    }
]

下图显示了Postman对此API进行测试的实际结果:

9. 为RestController编写单元测试

当我们需要对一个Rest Controller进行单元测试时,我们只想启动和SpringMVC相关的组件,而不必要启动所有的Web组件。我们可以使用WebMvcTest注解来解决这样的测试需求。此注解将禁用Spring Boot的自动化配置,仅仅启动与MVC相关的配置。下面将对测试用例中的几个核心注解做一下介绍:

  • @RunWith(SpringRunner.class) : SpringRunner是SpringJUnit4ClassRunner的简写,它扩展了BlockJUnit4ClassRunner类,用于提供测试时的Spring应用上下文信息。
  • @WebMvcTest(value=UserController.class,secure = false) : 该注解用于测试Spring MVC应用程序,使用此注解的好处是我们只需要加载UserController类并对其中的方法进行单元测试,而不需要加载其他的控制器。
  • MockMvc : MockMvc是测试Spring MVC应用程序的主要入口,它将为我们的测试提供一个模拟的应用上下文环境。
  • @MockBean : MockBean主要是模拟向Spring应用上下文注入一个Bean对象,并使该Bean对象可以在控制器中被访问到。

下面是测试用例的源代码:

package com.ramostear.spring.boot.test.restservice;

import com.ramostear.spring.boot.test.restservice.controller.UserController;
import com.ramostear.spring.boot.test.restservice.model.Role;
import com.ramostear.spring.boot.test.restservice.model.User;
import com.ramostear.spring.boot.test.restservice.service.UserService;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.skyscreamer.jsonassert.JSONAssert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.util.ArrayList;
import java.util.List;

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class,secure = false)
public class UserControllerTests {
    private static Logger logger = LoggerFactory.getLogger(UserControllerTests.class);
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    public void findAllUsers() throws Exception{
        User user = new User();
        user.setId("1001");
        user.setName("ramostear");
        user.setAlias("谭朝红");

        Role role = new Role();
        role.setId("1001");
        role.setName("admin");
        role.setDescription("all permissions for this role.");
        List<Role> roles = new ArrayList<>();
        roles.add(role);
        user.setRoles(roles);
        List<User> users = new ArrayList<>();
        users.add(user);

        Mockito.when(userService.findAllUsers()).thenReturn(users);
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/users");
        MvcResult result = mockMvc.perform(requestBuilder).andReturn();
        String expected = "[{\"id\":\"1001\",\"name\":\"ramostear\",\"alias\":\"谭朝红\",\"roles\":[{\"id\":\"1001\",\"name\":\"admin\",\"description\":\"all permissions for this role.\"}]}]";
        logger.info(result.getResponse().getContentAsString());
        JSONAssert.assertEquals(expected,result.getResponse().getContentAsString(),false);
    }

    @Test
    public void findAllUserRoles() throws Exception{
        Role role = new Role();
        role.setId("1001");
        role.setName("admin");
        role.setDescription("all permissions for this role.");
        List<Role> roles = new ArrayList<>();
        roles.add(role);
        Mockito.when(userService.findUserAllRoles("1001")).thenReturn(roles);
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/users/1001/roles");
        MvcResult result = mockMvc.perform(requestBuilder).andReturn();
        String expected = "[{\"id\":\"1001\",\"name\":\"admin\",\"description\":\"all permissions for this role.\"}]";
       logger.info(result.getResponse().getContentAsString());
        JSONAssert.assertEquals(expected,result.getResponse().getContentAsString(),false);
    }
}

Mockito.when().thenReturn():用于测试UserService在被调用时是否返回和预期一致的结果

mockMvc.perform().andReturn():mockMvc主要用于执行请求并返回响应数据

下面我们执行上述两个方法,看看测试结果:

两个方法均测试通过,且控制台也输出了如下的日志信息:

2019-05-10 05:36:40.567  INFO 18268 --- [           main] c.r.s.b.t.r.UserControllerTests          : [{"id":"1001","name":"admin","description":"all permissions for this role."}]
2019-05-10 05:36:40.585  INFO 18268 --- [           main] c.r.s.b.t.r.UserControllerTests          : [{"id":"1001","name":"ramostear","alias":"谭朝红","roles":[{"id":"1001","name":"admin","description":"all permissions for this role."}]}]

10 . 添加POST请求方法

现在我们在Rest Controller中新增一个为用户添加新角色的方法,当角色被成功设置后将返回状态码为201的一个创建资源状态。代码如下:

 @PostMapping(value = "/users/{id}")
    public ResponseEntity<Object> setUserRole(@PathVariable(value = "id")String id, @RequestBody Role role){
        userService.addUserRole(id,role);
        return new ResponseEntity<>(role, HttpStatus.CREATED);
    }

使用Postman对此接口进行请求,获得如下的响应信息:

{
    "id": "1002",
    "name": "editor",
    "description": "content editor"
}

下图时使用Postman请求的实际结果:

11. 为Post方法提供单元测试用例

在接下来的测试中,我们将使用MockMvcRequestBuilders.post()方法来模拟请求添加用户角色的方法,并使用accept()方法来设置数据格式,另外还需断言请求响应的状态值是否为CREATED且返回的角色信息是否与预期的一致。Post方法的测试源码如下:

@Test
    public void addUserRole() throws Exception{
        String JSON = "{\"id\":\"1002\",\"name\":\"editor\",\"description\":\"content editor\"}";
        RequestBuilder requestBuilder = MockMvcRequestBuilders.post("/users/1001")
                .accept(MediaType.APPLICATION_JSON).content(JSON)
                .contentType(MediaType.APPLICATION_JSON);
        MvcResult result = mockMvc.perform(requestBuilder).andReturn();
        MockHttpServletResponse response = result.getResponse();
        Assert.assertEquals(HttpStatus.CREATED.value(),response.getStatus());
        String expected = "{\"id\":\"1002\",\"name\":\"editor\",\"description\":\"content editor\"}";
        logger.info(result.getResponse().getContentAsString());
        JSONAssert.assertEquals(expected,result.getResponse().getContentAsString(),false);
    }

最后,我们执行此测试用例方法,观察测试结构:

通过图我们可以看到,Post方法已成功通过测试。

今天的课程分享到这里就结束了,在本次课程中,给出了如何测试Rest Controller的方法,同时还使用Postman来进行辅助测试。所有的测试都达到了预期的测试效果。

(第二讲)Spring&Spring MVC&Spring Boot三者之间的区别与联系

(第二讲)Spring&Spring MVC&Spring Boot三者之间的区别与联系

Spring Framework的诞生让开发人员的工作从石器时代跨域到了工业时代,你是否还能记起手撸Servlet和JDBC的岁月?,你是否还对Struts1以及Struts2莫名其妙的404错误记忆犹新?从2004年3月Spring 1.0发布到至今,Spring的发展已经走过了15个年头,其创造的价值让人瞩目。今天,带着这样一个背景来梳理一下Spring Framework,Spring MVC和Spring Boot三者之间的区别。

我们使用Spring家族的系列产品这么长时间,不禁会问这样几个问题:Spring Framework是什么?Spring MVC是什么?Spring Boot又是什么?它们被设计出来的目的是什么?

你需要了解的知识

在接下来的内容中,将梳理这样几个知识点:

  • Spring Framework基本概述
  • Spring Framework主要解决的问题是什么?
  • Spring MVC基本概述
  • Spring MVC主要解决的问题是什么?
  • Spring Boot主要解决的问题是什么?
  • Spring,Spring MVC和Spring Boot三者之间的区别是什么?

Spring Framework 解决了哪些核心问题?

当你仔细思考这个问题的时候你会发现,很多地方它都有渗透到,貌似一个Spring就可以撑起开发的半边天,以至于很难一下子回答这个问题。那Spring Framework到底解决了哪些核心问题?

Spring Framework最重要也是最核心的特性是依赖注入。所有的Spring模块的核心就是DI(依赖注入)或者IoC(控制反转)。

依赖注入或控制反转是Spring Framework最大的特性,当我们正确使用DI(依赖注入)或IoC时,可以开发出一个高内聚低耦合的应用程序,而这一一个低耦合的应用程序可以轻松的对其实施单元测试。这就是Spring Framework解决的最核心的问题。

无依赖注入

请考虑这一一个案例:UserAction依赖于UserService来获取用户信息,在没有依赖注入的情况下,我们需要手动在UserAction中实例化一个UserService对象,这样的手工作业意味着UserAction和UserService必须精密的联系在一起,才能正常工作。如果一个Action需要多个Service提供服务,那实例化这些Service将是一个繁重的工作。下面我们给出一个不使用依赖注入的代码片段加以说明:

UserService.java

public interface UserService{
    User profile();
}

UserServiceImpl.java

public class UserServiceImpl implements UserService{
    @Override
    User profile(){
        // TODO
    }
}

UserAction.java

@RestController
public class UserAction{

    private UserService userService = new UserServiceImpl();
    // other services...

    @GetMapping("/profile")
    public User profile(){
        return userService.profile();
    }
}

引入依赖注入

引入依赖注入将会使整个代码看起来很清爽。为了能够开发出高内聚低耦合的应用程序,Spring Framework为我们做了大量的准备工作。下面我们使用两个简单的注解@Component@Autowired来实现依赖注入。

  • @Component : 该注解将会告诉Spring Framework,被此注解标注的类需要纳入到Bean管理器中。
  • @Autowired : 告诉Spring Framework需要找到一与其类型匹配的对象,并将其自动引入到所需要的类中。

在接下来的示例代码中,我们会看到Spring Framework将为UserService创建一个Bean对象,并将其自动引入到UserAction中。

UserService.java

public interface UserService{
    User profile();
}

UserServiceImpl.java

@Component
public class UserServiceImpl implements UserService{
    @Override
    User profile(){
        // TODO
    }
}

UserAction.java

@RestController
public class UserAction{
    @Autowired
    private UserService userService;
    // other services...

    @GetMapping("/profile")
    public User profile(){
        return userService.profile();
    }
}

对比上下两部分的代码,你是否发现了他们之间的区别?Action所依赖的Service的初始化工作全部交由Spring Framework来管理,我们只需要在适当的地方向Spring Framework索取想要服务即可。这就好比当我想要吃薯片的时候,我不需要自己亲自种土豆,施肥,收获…清洗,切片…一直到最后炸土豆片,想想都觉得累,而更简单的方法是直接去超市购买自己想要的薯片即可。

Spring Framework还有其他的核心特性吗?

#1:衍生的特性

Spring Framework的依赖注入是核心中的核心,在依赖注入核心特性的基础上,Spring Framework还衍生出了很多的高级模块:

  • Spring JDBC
  • Spring MVC
  • Spring AOP
  • Spring ORM
  • Spring JMS
  • Spring Test

对于这些新的高级模块,可能会产生这一一个问题:它们是否是一个全新的功能?答案是否定的,在不使用Spring Framework的情况下,我们依然能够使用JDBC连接数据库,依然能够对视图和数据模型进行控制,依然能够使用第三方的ORM框架。那Spring Framework干了什么?Spring Framework站在巨人的肩膀上,对这些原生的模块进行了抽象,而抽象可以带来这样一些好处:

  • 减少了应用中模板代码的数量
  • 降低了原生框架的技术门槛
  • 基于依赖注入特性,实现了代码的解耦,真正的高内聚、低耦合
  • 更细粒度的单元测试

这样的好处是显而易见的,比如与传统的JDBC相比,使用JDBCTemplate操作数据库,首先是代码量小了,其次是我们不需要再面对恐怖的try-catch。

#2:优秀的集成能力

Spring Framework还具备另外一个重要特性,那就是能够快速的与其他三方框架进行整合。与其自己造轮子,还不如想办法将好的轮子整合在一起,我想这句话应该可以用来概况Spring Framework这一特性。Spring Framework对于整合其他的框架,给出了不错的解决方案,下面将列举一些常见的方案:

  • 与Hibernate ORM框架整合
  • 与MyBatis 对象映射框架整合
  • 与Junit单元测试框架整合
  • 与Log4J日志记录框架整合

Spring MVC是什么?

Spring MVC提供了构建Web应用程序的全功能MVC模块,实现了Web MVC设计模式以及请求驱动类型的轻量级Web框架,即采用了MVC架构模式的思想,将Web层进行职责解耦。基于请求驱动指的是使用请求-响应模型,视图与数据模型分离,以简化Web应用的开发。

使用Spring MVC提供的Dispatcher Servlet,ModelAndView和ViewResolver等功能,可以轻松的开发出一个Web应用程序。

Spring Boot出现的原因是什么?

我们都知道,使用Spring Framework来开发应用程序,需要进行大量的配置工作以及依赖包的管理,工作繁重而且极易出现配置错误,尤为明显的是依赖包之间的版本冲突问题。

举一个简单的案例,当我们使用Spring MVC来开发Web应用程序时,我们大致需要经历这样几个步骤:

  • 1:配置组件扫描的包路径

  • 2:配置对应的Servlet程序

  • 3:配置视图解析器

  • 4:配置页面模板引擎(JSP、Freemarker等)

下面给出了一个小范围的举例:

...
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix">
        <value>/WEB-INF/pages</value>
    </property>
    <property name="suffix">
        <value>.jsp</value>
    </property>
</bean>
<mvc:resources mapping="/static/**" location="/static/"/>
...

此外,我们还需要配置先关的Servlet处理程序,它们大致是这样的:

<servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>
        org.springframework.web.servlet.DispatcherServlet
    </servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/todo-servlet.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>

<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

如果我们的应用程序还需要链接数据,则还需要配置数据源,实体对象管理器,事务管理器等众多配置:

<bean id="datasource" class="">
    ...
</bean>
<bean id="entityManagerFactory" class="">
    ...
</bean>
<bean id="transactionManager" class="">
    ...
</bean>
...

面对众多的配置文件,我们需要花费大量的时间去处理,这时你可能会问,我为什么要花费那么多的时间去管理Spring的配置工作?不是应该专注于应用本身的业务逻辑吗?现在,有了Spring Boot,这些烦心事就不需要你去操心了。

#1:Spring Boot的自动化配置能力

我为什么会把Spring Boot的自动化配置能力放在第一位,因为它极大的降低了我们使用Spring Framework所付出的成本。这是Spring Boot的自动化配置是一个最具价值的解决方案。

这难道不值得我们拍案叫好吗?如果你想要开发一个Web应用程序,你需要做的事情就是将Spring Boot Web包引入到项目的类路径下,Spring Boot就可以帮你解决后续的大多数配置工作。

  • 如果Hibernate的依赖被放到了类路径上,Spring Boot会自动配置数据源
  • 如果Spring MVC的依赖被放到了类路径上,Spring Boot又会自动配置Dispatcher Servlet

当Spring Boot检测到有新的依赖包添加到类路径上,Spring Boot会采用默认的配置对新的依赖包进行设置,如果我们想自己配置依赖包时,只需要手动覆盖默认的配置项即可。

  1. Spring Boot扫描类路径上可用的框架信息
  2. 获取应用程序现有的配置信息
  3. 如果应用程序没有提供框架的配置信息,Spring Boot将采用默认的配置来配置框架,这就是Spring Boot的自动配置特性(Auto Configuration)

#2:Spring Boot Starter项目

在传统模式的开发过程中,我们需要反复的确认应用程序所需要的第三方JAR包,以及这些JAR的版本和依赖关系。例如,现在我们打算开发一款Web应用程序,应用程序大概需要如下的一些依赖包:Spring MVC,Jackson Databind(用于数据绑定),Hibernate-Validator(用于服务端的数据校验)和Log4j(用于日志记录)。现在,我们需要去下载对应的jar包到应用程序中,并且还需要处理依赖包之间版本冲突的问题。

<dependency>
   <groupId>org.springframework</groupId>
   <artifactId>spring-webmvc</artifactId>
   <version>4.2.2.RELEASE</version>
</dependency>

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.5.3</version>
</dependency>

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>5.0.2.Final</version>
</dependency>

<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

这不是一件简单的事情,特别是发生版本冲突的时候,让应用程序能够正常运行起来就需要花费一定的时间。

Spring Boot Starter是一组用于管理依赖关系的描述符,通过这些描述符,我们可以在应用程序中轻松的管理依赖包,你可以以开箱即用的方式获取想要的依赖包,而无需去Maven仓库总检索对应的依赖,并将依赖配置复制粘贴到应用程序的pom文件中。例如,如果你想要使用Spring和JPA进行数据库访问,只需要在pom中添加spring-boot-starter-data-jpa依赖项就可以。

现在,如果我们想要开发一个Web应用程序,使用Spring Boot Starter Web依赖会是一个不错的选择。我们可以通过使用Spring INitializr快速构建一个Web应用程序,并将Spring Boot Starter Web添加到项目中。此时我们的pom文件只需要很少的配置:

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

Spring Boot Starter Web会为我们预装如下的一些依赖:

  • Spring : core,beans,context,aop
  • Web MVC : Spring MVC
  • Jackson : JSON Binding
  • Validation : Hibernate Validator,Validation API
  • Embedded Servlet Container: Tomcat
  • Logging : logback,slf4j

对于开发人员而言,我们不需要去担心这些依赖项的管理工作以及解决他们之间的兼容性问题,Spring Boot已经帮我们解决了。

Spring Boot的核心目标

Spring Boot的核心目标在于快速实现生产就绪的应用程序,这将包含这样几个部分:

  • 执行器 : 启用高级监控和跟踪应用程序功能
  • 嵌入式服务器:Spring Boot已经内置了多个Web服务器,如Undertow,jetty,tomcat,因此我们不需要再额外的配置服务器,就可以完成应用程序的调试工作。
  • 默认的异常处理机制
  • 开箱即用的依赖项管理机制
  • 自动化配置

总结

通过上述的梳理,我们可以看到,Spring Framework是一个提供了DI(依赖注入)和IoC(控制反转)的开发框架,使用Spring Framework可以帮助我们开发出高内聚,低耦合的应用程序,Spring MVC是在Spring Framework基础上发展出来的基于MVC模式的全功能Web开发框架,实现了Model,View和Controller之间的职责解耦;Spring Boot为我们提供了一个能够快速使用Spring Framework的优秀解决方案,通过最小化的配置,我们就可以使用Spring Framework,严格意义上讲,Spring Boot并不是某种框架,它只是为开发人员提供了一个更好的更方便的使用Spring Framework的解决方案。

(第一讲)Spring Initializr-快速入门Spring Boot的最好选择

(第一讲)Spring Initializr-快速入门Spring Boot的最好选择

Spring Initializr [http://start.spring.io/]是引导你快速构建Spring Boot项目的不二选择。

它允许你通过简单的操作步骤,就可以构建出一个完整的Spring Boot应用程序。你可以通过Spring Initializr引导界面构建如下类型的Spring Boot应用:

  • Web应用程序

  • Restful 应用程序

  • Batch应用程序

Spring Boot对很多第三方框架提供了良好的支持,可以通过对应的artifactId获得他们,这里列举其中的一部分供参考:

  • spring-boot-starter-web-services:用于构建可与外界交互的SOAP Web服务应用程序
  • spring-boot-starter-web:可用于构建Web应用程序或者基于Restful风格的应用程序
  • spring-boot-starter-test:可用于构建并编写单元测试和集成测试
  • spring-boot-starter-jdbc:构建基于JDBC的应用程序
  • spring-boot-starter-hateoas:通过引入HATEOAS功能,让你轻松实现RESTful服务
  • spring-boot-starter-security:使用Spring Security对系统用户进行身份验证和鉴权
  • spring-boot-starter-data-jpa:基于Hibernate实现的Spring Data JPA
  • spring-boot-starter-cache:开启基于Spring Framework的缓存支持
  • spring-boot-starter-data-rest:使用Spring Data REST提供REST服务

在本讲中,我将通过使用Spring Initializr来演示如何快速创建一个简单的Web应用程序。

使用Spring Initializr构建Web应用程序

使用Spring Initializr构建Web应用程序是一件非常简单快速的事情。

如上图所示,我们需要执行如下的几个操作:

  • 通过浏览器访问Spring Initializr官网 ,然后再执行下面的几个选择项
    • 设置groupId : com.ramostear.spring.boot
    • 设置artifactId: spring-boot-quick-start
    • 项目名称:默认为spring-boot-quick-start
    • 基础包名:默认即可(你也可以选择修改)(通过点击More options展开)
    • 在搜索框中分别检索并选择如下几个组件:Web,Actuator,DevTools
  • 最后,点击“Generate Project”生成并下载项目
  • 将项目导入到IntelleJ IDEA中

Spring Boot项目目录结构

下图显示了在IDEA中导入刚才下载的项目目录结构:

  • SpringBootQuickStartApplication.java:Spring Boot运行的主文件,它负责初始化Spring Boot自动配置和Spring应用程序上下文
  • application.properties : 应用程序配置文件
  • SpringBootQuickStartApplicationTests : 用于单元测试的简单启动器
  • pom.xml : Maven构建项目的配置文件,包括了Spring Boot Starter Web等相关依赖项。特别指出,它会自动将Spring Boot Starter Parent作为整个工程的父依赖。

核心的代码

src/main/java 包下放置我们主要的逻辑代码,src/test/java包下放置项目的测试代码,src/main/resources包下放置项目的配置文件以及一些静态资源文件,如页html文件,css文件和js文件等。我们从上到下依次进行介绍。

SpringBootQuickStartApplication.java

package com.ramostear.spring.boot.springbootquickstart;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringBootQuickStartApplication {

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

}
  • @SpringBootApplication : 负责初始化Spring Boot 自动化配置项和Spring应用程序上下文
  • SpringApplication.run() : 负责启动Spring Boot应用程序的静态方法

application.properties

Spring Boot应用程序的配置文件,这里我们简单的设置一下项目启动的端口为8080(默认端口8080)和应用名称为Spring Boot Quick Start

server.port= 8080
spring.application.name= Spring Boot Quick Start

SpringBootQuickStartApplicationTests

package com.ramostear.spring.boot.springbootquickstart;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringBootQuickStartApplicationTests {

   @Test
   public void contextLoads() {
   }

}
  • 集成了对Spring Boot应用程序进行单元测试所需要的全部环境

运行应用程序

如上如所示,点击运行按钮一键运行Spring Boot应用程序,成功启动后你将看到控制台中输出的如下日志信息:

"C:\Program Files\Java\jdk1.8.0_144\bin\java" ...

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.4.RELEASE)

 ...
 2019-04-21 16:44:16.784  INFO 2320 --- [  restartedMain] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 2 endpoint(s) beneath base path '/actuator'
2019-04-21 16:44:16.849  INFO 2320 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-04-21 16:44:16.852  INFO 2320 --- [  restartedMain] .r.s.b.s.SpringBootQuickStartApplication : Started SpringBootQuickStartApplication in 3.526 seconds (JVM running for 5.448)

总结

现在,你已经掌握了如何通过Spring Initializr来快速构建Spring Boot Weby9应用程序,你可以在Github上获取本次内容的全部源码。

(译)使用Spring Boot和Axon实现CQRS&Event Sourcing

(译)使用Spring Boot和Axon实现CQRS&Event Sourcing

上一篇中,我们讲述了CQRS和Event Sourcing的相关概念以及他们能解决什么问题。尽管可以在不适用任何其他框架或库的情况下实现CQRS/ES,但我们还是建议使用已有的一些工具。这些工具可以简化开发过程,同时运行开发人员专注于业务逻辑的处理,避免重复的造轮子。在本节中,我们将选择Axon框架来实现CQRS/ES。

什么是Axon?

Axon是一个轻量级的Java开源框架,可以帮助构建你构建基于CQRS模式的可伸缩、可扩展和库维护的Java应用程序。它还可以帮助你准备Event Sourcing所需要的环境。Axon提供了所有重要构建模块的实现,如聚合、存储库,命令和时间总线。Axon可以让开发人员的工作更为轻松。

为何选择Axon?

Axon让我们避免了负载的配置和对数据流的操作,我们可以专注于应用程序业务规则的定制,而不是创建样板代码。使用Axon可以获得如下的一些优势:

  • 优化事件处理:我们应该注意到,事件的发布具有先后顺序。Axon框架保证了事件被执行的先后顺序。
  • 内置测试环境:Axon提供了一个测试工具集,允许在特定的时间上对系统进行测试,这使得单元测试更为容易。
  • Spring Boot AutoConfiguration:在Spring Boot应用程序中配置Axon是一件非常简单的事情。只需要提供必要的依赖项,Axon将会自动配置一些基本组件。
  • 支持注解:Axon提供了注解支持,这使得我们的代码更清晰可读,我们可以使用这些注解轻松构建聚合和事件处理程序,而无需关心Axon特定的处理逻辑

Spring Boot应用程序中快速配置Axon

在默认情况下,Axon以经提供了对Spring Boot的集成支持。我们只需要通过一些简单的配置步骤就可以将Axon与Spring Boot整合在一起。

步骤1

第一步是使用合适的项目构建工具在项目中配置Axon依赖项。以下是使用Gradle来配置Axon的方法:

dependencies{
    compile("org.axonframework:axon-spring-boot-starter:3.2")
    compile("org.axonframework:axon-mongo:3.2")
    testCompile("org.axonframework:axon-test:32.")
}

第一个依赖项为我们提供了与Spring Boot集成的最基本的Axon所有必要组件,如命令中线,事件总线和聚合。第二个依赖项是为我们的聚合或事件配置库提供所需的基本环境。最后一个依赖项用于构建先关的测试环境。

步骤2

根据需要配置Axon所需要的一些Spring Bean。比如EventHandlerConfiguration(负责控制事件处理程序行为的组件),如果其中有一个事件执行失败,则终止处理后续的所有事件。当然这是非必须的,但是还是值得在应用程序中进行此配置,以防止系统中数据的不一致。配置代码如下:

@Configuration
public class AxonConfig {

private final EventHandlingConfiguration eventHandlingConfiguration;

@Autowired
public AxonConfig(EventHandlingConfiguration eventHandlingConfiguration) {
   this.eventHandlingConfiguration = eventHandlingConfiguration;
}

@PostConstruct
public void registerErrorHandling() {
   eventHandlingConfiguration.configureListenerInvocationErrorHandler(configuration -> (exception, event, listener) -> {
       String msg = String.format(
               "[EventHandling] Event handler failed when processing event with id %s. Aborting all further event handlers.",
               event.getIdentifier());
       log.error(msg, exception);
       throw exception;
   });
}}

这里的主要思想是创建一个额外的配置文件(使用@Configuration注释的类)。该类的构造函数注入由Spring自身所管理的EventHandlingConfiguration依赖项。由于绑定依赖,我们可以在此对象上调用configureListenerInvocationErrorHandler()并通过记录异常将异常传播到上层去处理错误。

步骤3

我们使用MongoDB来存储Event Store中所发生的所有事件。要实现此功能,可以通过如下的方法来实现:

@Bean
public EventStorageEngine eventStore(MongoTemplate mongoTemplate) {
   return new MongoEventStorageEngine(
           new JacksonSerializer(), null, mongoTemplate, new DocumentPerEventStorageStrategy());
}

这样,在事件总线上发布的所有事件都将自动保存到MongoDB数据库中。通过这种简单的配置,我们就可以在应用程序中使用MongoDB的数据源。

在Axon配置方面就是这样的简单。当然,我们还有其他很多的配置方式。但我们可以通过上述简单的配置,就可以使用Axon的功能了。

使用Axon实现CQRS

根据上图,创建命令,将命令传递给命令总线然后创建事件并将事件放在事件总线上还不是CQRS。我们必须记住改变写入存储库的状态并从读取数据库中读取当前状态,这才是CQRS模式的关键点。

配置此流程也并不复杂。在将命令传递给命令网关时,Spring将名利类型作为参数以搜索带有@CommandHandler 注释的方法。

@Value
class SubmitApplicationCommand {
   private String appId;
   private String category;
}

@AllArgsConstructor
public class ApplicationService {
   private final CommandGateway commandGateway;

   public CompletableFuture<Void> createForm(String appId) {
       return CompletableFuture.supplyAsync(() -> new SubmitExpertsFormCommand(appId, "Android"))
               .thenCompose(commandGateway::send);
   }
}

除其他事项外,命令处理程序负责将创建的事件发送到事件总线。它将事件对象放置到AggregateLifecycle静态导入的apply()方法中。然后调度该事件以查找预期的处理程序,并且由于我们配置了事件存储库,所有事件都自动保存在数据库中。

@Value
class ApplicationSubmittedEvent {
   private String appId;
   private String category;
}

@Aggregate
@NoArgsConstructor
public class ApplicationAggregate {
   @AggregateIdentifier
   private String id;

   @CommandHandler
   public ApplicationAggregate(SubmitApplicationCommand command) {
      //some validation
       this.id = command.getAppId;
       apply(new ApplicationSubmittedEvent(command.getAppId(), command.getCategory()));
   }
}

要更改写库的状态,我们需要提供一个使用@EventHandler注释的方法。该应用程序可以包含多个事件处理程序。他们每个人都应该执行一个特定的任务,如发送电子邮件,记录或保存在数据库中。

@RequiredArgsConstructor
@Order(1)
public class ProjectingEventHandler {
   private final IApplicationSubmittedProjection projection;

   @EventHandler
   public CompletableFuture<Void> onApplicationSubmitted(ExpertsFormSubmittedEvent event) {
       return projection.submitApplication(event.getApplicationId(), event.getCategory());
   }

如果我们想确定所有事件处理程序的处理顺序,我们可以用@Order注释一个类并设置一个序列号。submitApplication()方法负责进行所有必要的更改并将新的数据存储到写库中。

这些都是使我们的应用程序采用CQRS模式原则的关键点。当然,这些原则只能应用于我们应用程序的某些部分,具体取决于业务需求。Event Sourcing不适合我们正在构建的每个应用程序或模块。在实现此模式时也要谨慎,因为更复杂的应用程序可能难以维护。

总结

使用Axon框架,CQRS和Event Sourcing的实现变得简单化。有关高级配置的更多详细信息,请访问Axon的网站https://docs.axonframework.org/

原文作者:ŁukaszKucik

原文地址:https://www.nexocode.com/blog/posts/smooth-implementation-cqrs-es-with-sping-boot-and-axon/

译 者:谭朝红

译文地址:https://www.ramostear.com/articles/impl_cqrs_es_by_spring_boot_and_axon.html

(译)CQRS & Event Sourcing — 解决检索应用程序状态问题的一剂良方

(译)CQRS & Event Sourcing — 解决检索应用程序状态问题的一剂良方

CQRS & Event Sourcing — 解决检索应用程序状态问题的一剂良方

现在,每个开发人员都很熟悉MVC标准体系结构设计模式。大多数的应用程序都是基于这种体系结构进行创建的。它允许我们创建可扩展的大型企业应用程序,但近期我们还听到了另外的一些有关于CQRS/ES的相关信息。这些方法应该被放在MVC中一起使用吗?他们可以解决什么问题?现在,让我们一起来看看CQRS/ES是什么,以及他们都有哪些优点和缺点。

CQRS — 模式介绍

CQRS(Command Query Responsibility Segregation)是一种简单的设计模式。它衍生与CQS,即命令和查询分离,CQS是由Bertrand Meyer所设计。按照这一设计概念,系统中的方法应该分为两种:改变状态的命令和返回值的查询。Greg young将引入了这个设计概念,并将其应用于对象或者组件当中,这就是今天所要将的CQRS。它背后的主要思想是应用程序更改对象或组件状态(Command)应该与获取对象或者组件信息(Query)分开。

下面,将通一张图来说明应用程序中有关CQRS部分的组成结构:

CQRS模式介绍

Commands(命令)—表示用户的操作意图。它们包含了与用户将要对系统执行操作的所有必要信息。

  • Command Bus(命令总线):是一种接收命令并将命令传递给命令处理程序的队列。
  • Command Handler(命令处理程序):包含实际的业务逻辑,用于验证和处理命令中接收到的数据。Command handler负责生成和传播域事件(Event)到事件总线(Event Bus)。
  • Event Bus(事件总线):将事件发布给订阅特定事件类型的事件处理程序。如果存在连续的事件依赖,事件总线可以使用异步或者同步的方式将事件发布出去。
  • Event Handler(事件处理程序):负责处理特定类型的事件。它们的职责是将应用程序的最新状态保存到读库中,并执行终端的相关操作,如发送电子邮件,存储文件等。

Query(查询):表示用户实际可用的应用程序状态。获取UI的数据应该通过这些对象完成。

下面我们将介绍有关CQRS的诸多优点,它们是:

  • 我们可以给处理业务逻辑部分和处理查询部分的开发人员分别分配任务,但需要小心的是,这种模式可能会破坏信息的完整性。
  • 通过在多个不同的服务器上扩展Commands和Query,我们可以进一步提升应用程序的读/写性能。
  • 使用两个不同的数据库(读库/写库)进行同步,可以实现自动备份,无需额外的干预工作。
  • 读取数据时不会涉及到写库的操作,因此在使用事件源是读数据操作会更快。
  • 我们可以直接为视图层构建数据,而无需考虑域逻辑,这可以简化视图层的工作并提高性能。

尽管使用CQRS模式具有上述诸多的优点,但是在使用前还需要慎重考虑。对于只具有简单域的简单项目,其UI模型与域模型紧密联系的,使用CQRS反而会增加项目的复杂度和冗余度,这无疑是过度的设计项目。此外,对于数据量较少或者性能要求较低的项目实施CQRS模式不会带来显著的性能提升。

Event Sourcing — 案例研究

有这样一个案例,我们想要检索任何一个域对象的历史状态数据,而且在任何时间都可以生成统计数据。我们想要检查上个月、上个季度或者过去任何时间的状态汇总。想要解决这个问题并不容易。我们可以在特定的时间范围内将额外的数据保存在数据库中,但这种方法也存在一些缺点。我们不知道范围应该是什么样子,以及未来统计数据需要哪些数据项。为了避免这些问题,我们可以每天为所有聚合创建快照,但它们同样会产生大量的冗余数据。

Event Sourcing(ES)似乎是目前解决这些问题的最佳方案。Event Sourcing允许我们将Aggregate(聚合)状态的每一个更改事件保存在Event Store的事件存储库中。通过Command Handler将事件写入到事件存储库中,并处理相关的逻辑。要创建Aggregate(聚合)对象的当前状态,我们需要运行创建预期域对象的所有事件并对其执行所有的更改。下面我们将通过一张图来说明这一架构设计方式:

event-sourcing

下面我们将列举一些使用ES的优点:

  • 时间穿梭机:可以及时重建特定聚合的状态。每个事件都包含一个时间戳。根据这些时间戳可以在特定的时间内运行事件或者停止事件。
  • 自动审计:我们不需要额外的工作就可以检查出在特定的时间范围内谁做了什么以及改变了什么。这和可以显示更改历史记录的系统日志不同,事件可以告知我们每次更改背后所对应的操作意图。
  • 易于引入纠正措施:当数据库中的数据发生错误时,我们可以将应用程序的状态回退到特定的时间点上,并重建当时的应用程序状态。
  • 易于调试:如果应用程序出现问题,我们可以将特定事件内的所有事件取出,并逐条的重建应用状态,以检查应用程序可能出现问题的地方。这样我们可以更快的找到问题,缩短调试所需的时间。

Aggregates

Aggregate(聚合)一词在本文中多次被提及,那它到底是什么意思?Aggregate(聚合)来自于领域驱动设计(DDD)的一个概念,它指的是始终保持一致状态的实体或者相关实体组。我们可以简单的理解为接收和处理Command(包含Command Handler)的一个边界,然后根据当前状态生成事件。在通常情况下,Aggregate root(聚合根)由一个域对象构成,但它可以由多个对象组成。我们还需要注意整个应用程序可以包含多个Aggregate(聚合),并且所有事件都存储在同一个存储库中。

总结

CQRS/ES可以作为特定问题的解决方案。它可以在标准N层架构设计的应用程序的某些层中进行引入,它可以解决非标准问题,常规架构中我们所拿到的是最终状态,在很多情况下,固然当前状态很重要,但我们还需要知道当前状态是如何产生的。CQRS和ES两种概念应该一起使用吗?事实表明,并没有。我们想要统计任何时间范文内的域对象状态,而写库只能存储当前状态。引入CQRS并没能帮助我们解决这一问题。在下一章节中,我们将引入Axon框架,Axon框架时间了CQRS/ES,用于解决某些域对象的一些特定问题,尤其是收集历史统计数据。我们将阐述如何使用Axon框架实现CQRS/ES并实现与Spring Boot应用程的整合。

作者:LukaszKucik ,译:谭朝红,原文:CQRS and Event Sourcing as an antidote for problems with retrieving application states

WebMvcConfigurerAdapter被弃用后的两个选择

WebMvcConfigurerAdapter被弃用后的两个选择

WebMvcConfigurerAdapter类型被弃用后的选择

1. 介绍

在本文中,将介绍将spring 4.xx(或者更低)版本升级到Spring 5.xx以及将Spring Boot 1.xx版本升级到Spring Boot 2.xx版本后会报的一个严重警告:“Warning:The type WebMvcConfigurerAdapter is deprecated.” ,以及快速的分析产生这个严重警告的原因和处理办法。

2. 出现警告的原因

如果我们使用Spring 5.xx(或者Spring Boot 2.xx)版本来构建或者升级应用程序,在配置WebMvc时,则会出现此警告,这是因为在早期的Spring版本中,如果要配置Web应用程序,可以通过扩展WebMvcConfigurerAdapter类快熟实现配置,大致代码如下:

package com.ramostear.page;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

/**
 * Spring 4(或者Spring Boot 1.x)版本配置Web应用程序示例
 * @author ramostear
 * @create-time 2019/4/18 0018-1:38
 */
@Configuration
public class OldMvcConfig extends WebMvcConfigurerAdapter{

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        super.configurePathMatch(configurer);
        configurer.setUseSuffixPatternMatch(false);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/static/")
                .addResourceLocations("classpath:/META-INF/resources/")
                .addResourceLocations("classpath:/public/")
                .addResourceLocations("classpath:/resources/");
        super.addResourceHandlers(registry);
    }
}

WebMvcConfigurerAdapter 是一个实现了WebMvcConfigurer 接口的抽象类,并提供了全部方法的空实现,我们可以在其子类中覆盖这些方法,以实现我们自己的配置,如视图解析器,拦截器和跨域支持等…,由于Java的版本更新,在Java 8中,可以使用default关键词为接口添加默认的方法,Spring在升级的过程中也同步支持了Java 8中这一新特性。下面是在Java 8 中给接口定义一个默认方法的简单实现:

public interface MyInterface{

    default void sayHello(){
        //...
    }

    void sayName(){}

    String writeName(){}

    //...
}

3. 解决方案

如前面所述,从Spring 5开始,WebMvcConfigure接口包含了WebMvcConfigurerAdapter类中所有方法的默认实现,因此WebMvcConfigurerAdapter这个适配器就被打入冷宫了,下面是WebMvcConfigurerAdapter类部分源码示例:

/**
 * An implementation of {@link WebMvcConfigurer} with empty methods allowing
 * subclasses to override only the methods they're interested in.
 *
 * @author Rossen Stoyanchev
 * @since 3.1
 * @deprecated as of 5.0 {@link WebMvcConfigurer} has default methods (made
 * possible by a Java 8 baseline) and can be implemented directly without the
 * need for this adapter
 */
@Deprecated
public abstract class WebMvcConfigurerAdapter implements WebMvcConfigurer {

    /**
     * {@inheritDoc}
     * <p>This implementation is empty.
     */
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
    }

    /**
     * {@inheritDoc}
     * <p>This implementation is empty.
     */
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    }

    ...
}

趣味提示:我们可以通过实现WebMvcConfigure接口中的方法来配置Web应用程序,而不需要让WebMvcConfigurerAdapter这个中间商 赚差价。

如此这般,我们找到了一个消除警告的方法:直接实现WebMvcConfigurer接口。在我们准备与WebMvcConfigurer打交道之前,先看看此接口的基本情况:

public interface WebMvcConfigurer {

    default void configurePathMatch(PathMatchConfigurer configurer) {
    }

    /**
     * Configure content negotiation options.
     */
    default void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    }

    /**
     * Configure asynchronous request handling options.
     */
    default void configureAsyncSupport(AsyncSupportConfigurer configurer) {
    }

   ...
}

现在,我们就可以动手配置Web应用程序了,大致的代码如下:

/**
 * Spring 5 (或者Spring Boot 2.x)版本配置Web应用程序示例
 * @author ramostear
 * @create-time 2019/4/18 0018-1:40
 */
@Configuration
public class MvcConfigure implements WebMvcConfigurer{

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.setUseSuffixPatternMatch(false);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/static/")
                .addResourceLocations("classpath:/public/")
                .addResourceLocations("classpath:/resources/");
    }
}

就这样简单地将警告消除了,将原来的继承WebMvcConfigurerAdapter类改为实现WebMvcConfigurer接口,其余的地方都没有变化。但有一点需要注意,如果你是升级旧有的应用程序,需要将方法中对super()的调用代码清除。

至此,我们的程序又可以愉快的玩耍了。那么,除了消除中间商 赚差价的方式来规避警告外,还有没有其他的途径呢?答案当然是肯定的。我们除了消除中间商从WebMvcConfigurer中获得配置Web应用程序的途径外,还可以直接从WebMvcConfigurationSupport这个配置“供应商“的手中获取配置途径。WebMvcConfigurationSupport是一个提供了以Java编程方式来配置Web应用程序的配置主类,所以我们可以从这个配置供应商的手中获取Web应用程序的配置方式。方法很简单,只需要扩展此类并重写对应的方法即可。和上面的方式一样,我们先看看此类的内部大致结构:

public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {
    ...
    /**
     * Provide access to the shared handler interceptors used to configure
     * {@link HandlerMapping} instances with.
     * <p>This method cannot be overridden; use {@link #addInterceptors} instead.
     */
    protected final Object[] getInterceptors() {
        ...
    }

    /**
     * Return a handler mapping ordered at Integer.MAX_VALUE-1 with mapped
     * resource handlers. To configure resource handling, override
     * {@link #addResourceHandlers}.
     */
    @Bean
    @Nullable
    public HandlerMapping resourceHandlerMapping() {
        ...
        handlerMapping.setPathMatcher(mvcPathMatcher());
        handlerMapping.setUrlPathHelper(mvcUrlPathHelper());
        handlerMapping.setInterceptors(getInterceptors());
        handlerMapping.setCorsConfigurations(getCorsConfigurations());
        return handlerMapping;
    }
}

是不是看到很熟悉的东西,有拦截器,静态资源映射等等…,现在我们只需要扩展此类并重写其中的方法,就可以配置我们的Web应用程序(还需要使用@Configuration对扩展类进行注释),示例代码如下:

/**
 * 消除警告的第二种配置选择
 * @author ramostear
 * @create-time 2019/4/7 0007-4:10
 */
@Configuration
public class MvcConfig extends WebMvcConfigurationSupport {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        super.configurePathMatch(configurer);
        configurer.setUseSuffixPatternMatch(false);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/static/")
                .addResourceLocations("classpath:/META-INF/resources/")
                .addResourceLocations("classpath:/public/")
                .addResourceLocations("classpath:/resources/");
        super.addResourceHandlers(registry);
    }
}

4. 结束语

在本文中,通过快速的梳理,给出了两种不同的方案来消除由于升级Spring(或者Spring Boot)版本所带来的WebMvcConfigurerAdapter类被弃用的严重警告。本次技术分享到这里就结束了,感谢你耐心的阅读。如果你在遇到同样问题时还有更好的解决方案,可以在下方的评论区给我留言。

基于Base64编/解码算法的Spring Boot文件上传技术解析

基于Base64编/解码算法的Spring Boot文件上传技术解析

导读

文件上传时Web应用最为常见的功能之一,传统的文件上传需要定制一个特殊的form表单来上传文件,以上传图片为例,常规的做法是先上传图片,然后回传图片地址,最后在使用图片。这无疑会带来一个严重的问题:如果在接下来使用图片的过程中web请求中断了或者其他原因导致请求关闭,那么在服务器上就会遗留下未被使用的脏数据,还需要通过其他的方式进行清理。我将这种设计模式称之为“粗犷型经济”模式,不管市场(业务)是否消费,先生产(上传)了再说,最后会导致资源的极度浪费。而本次分享要谈的是另外一种设计模式,我称之为“节约型经济”模式,将生产活动(上传)以“责任承包”制度承包(下方)给具体的业务,采用Base64解码算法的方式,通过二进制文本同步传输到业务方法,最后将文件解码存储,以达到节约资源的效果。

基本术语

1. Base64编码

Base64编码是从二进制到字符的过程,可用于在HTTP环境下传递较长的标识信息。例如,在Java Persistence系统Hibernate中,就采用Base64来将一个较长的唯一标识符(一般为128-bit的UUID)编码成一个字符串,用作HTTP表单和HTTP GET URL中的参数。在其他的应用场景中,也常常需要把二进制数据编码为合适放在URL(包括隐藏表单域)中的形式。此时,采用Base64编码具有不可读性,需要解码后才能阅读。

2. 文件上传

文件上传就是将信息从个人计算机(本地计算机)传送到中央服务器(远程计算机)系统上,让网络上的其他用户可以进行访问。文件上传又分为Web上传和FTP上传,前者直接通过点击网页上的连接即可操作,后者需要专门的FTP工具进行操作。

案例解析

以添加文章的需求为一个案例,一篇文章需要有ID,标题,封面,简介,正文等信息。针对文章封面的设置,通常的做法是在添加文章的页面中通过异步的方式先将图片上传至服务器,然后回传图片存储地址(URL或者URI)绑定到一个隐藏域中和一个用于预览的IMG节点上。此时,文章主体信息是没有提交到服务器的,但与文章相关的图片已经先于文章到达了服务器,这就好比你想要去洗手间放翔,结果翔还没有出来,先从嘴里呕吐了一些东东。虽然看起来都是一个“异化”过程,但总觉得让人“恶心”。原本放完翔(提交请求)冲一下马桶(提交事务)就完事了,你现在还需要额外的擦拭一下地上的呕吐物(清理垃圾文件)。

基于上述的一个应用背景,提出了采用Base64编/解码的方式同步上传文件,让文章的图片随文章主体信息一起到达服务端,如果在请求的过程中服务意外终止,那么在服务器上也不会产生任何脏数据。需求和出发点就聊这么多,接下来进入本次分享的正题,看看如何实现同步上传文件的功能。

功能实现

1. 解码器

我们需要定义一个解码器对前端传入的二进制的图片数据进行解码,对于前端如何将图片文件采用Base64算法编码,在接下来的内容当中单独介绍。此时解码器的做用主要是获取Base64编码的二进制文本中header信息(编码方式)和文件类型信息。然后对数据域进行解码。完成解码工作后,再讲字节码转换成我们熟悉的MultipartFile类型对象。解码器的实现代码如下:

package com.ramostear.jfast.common.utils;

import org.springframework.web.multipart.MultipartFile;

import java.io.*;

/**
 * @author ramostear|谭朝红
 * @create-time 2019/3/19 0019-23:54
 * @modify by :
 * @since:
 */
public class Base64Decoder implements MultipartFile{

    private final byte[] IMAGE;

    private final String HEADER;

    private Base64Decoder(byte[]image,String header){
        this.IMAGE = image;
        this.HEADER = header;
    }

    public static MultipartFile multipartFile(byte[]image,String header){
        return new Base64Decoder(image,header);
    }

    @Override
    public String getName() {
        return System.currentTimeMillis()+Math.random()+"."+HEADER.split("/")[1];
    }

    @Override
    public String getOriginalFilename() {
        return System.currentTimeMillis()+(int)Math.random()*10000+"."+HEADER.split("/")[1];
    }

    @Override
    public String getContentType() {
        return HEADER.split(":")[1];
    }

    @Override
    public boolean isEmpty() {
        return IMAGE == null || IMAGE.length == 0;
    }

    @Override
    public long getSize() {
        return IMAGE.length;
    }

    @Override
    public byte[] getBytes() throws IOException {
        return IMAGE;
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return new ByteArrayInputStream(IMAGE);
    }

    @Override
    public void transferTo(File file) throws IOException, IllegalStateException {
        new FileOutputStream(file).write(IMAGE);
    }
}

2. 转换器

现在,需要定义一个转换器,将前端传入的图片字符信息转换成Base64编码的字节数组,然后调用解码器获得最终的MultipartFile类型对象。转换器的实现比较简单,器代码如下:

package com.ramostear.jfast.common.utils;

import org.springframework.web.multipart.MultipartFile;

import java.util.Base64;

/**
 * @author ramostear|谭朝红
 * @create-time 2019/3/20 0020-0:00
 * @modify by :
 * @since:
 */
public class Base64Converter {

    public static MultipartFile converter(String source){
        String [] charArray = source.split(",");
        Base64.Decoder decoder = Base64.getDecoder();
        byte[] bytes = new byte[0];
        bytes = decoder.decode(charArray[1]);
        for (int i=0;i<bytes.length;i++){
            if(bytes[i]<0){
                bytes[i]+=256;
            }
        }
        return Base64Decoder.multipartFile(bytes,charArray[0]);
    }
}

重点介绍一下转换器的方法:

首先我们先看看基于Base64算法编码后的图片二进制字符的格式:

data:image/png;base64,iVBOR....Px1yGQ9EOFXNAAAAAE1FTkSuQmcc

因此,先通过“,”分割字符串,拿到数据的头部信息data:image/png;base64 ,再将数据的主体部分通过Base64进行转码,获得一个byte数组,最后调用解码器的解码方法获取MultipartFile对象。

3. 前端的Base64编码

后端的核心逻辑已经完成,接下来将介绍前端如何将一张图片采用Base64算法进行编码。

    1. 首先,需要有一个添加文章的form表单,同时将图片域设置为隐藏状态,提供一个图片预览的dom节点和一个浏览本地图片的input输入框,表单的核心代码如下:
    ...
     <form action="/articles" method="POST">
         ...
         <div class="file-preview">
              <div class="file-upload-zone">
                    <div class="file-upload-zone-title">Upload & preview img here …</div>
              </div>
         </div>
         <div class="clearfix"></div>
         <input type="hidden" name="cover" id="cover"/>
         <div class="input-group-btn">
             <button class="btn btn-blue" type="button" id="upload-btn">
                 <i class="fa fa-folder-open"></i>
                 <input id="upload-cover" name="upload-cover" multiple="multiple"
                        onchange="fileChange(this)" type="file"
                        accept="image/*"/>
             </button>
         </div>
         ...
    </form>
    ...
    
    1. 然后是定义一个fileChange方法来处理文件编码的工作,代码如下:
    function fileChange(obj){
            try{
                var file = obj.files[0];
                var reader = new FileReader();
                var fileName="";
                if(typeof(fileName) != "undefined"){
                    fileName = $(obj).val().split("\\").pop();
                }
                reader.onload = function(){
                    var img = new Image();
                    img.src = reader.result;
                    img.onload = function(){
                        var w = img.width,h = img.height;
                        var canvas = document.createElement("canvas");
                        var ctx = canvas.getContext("2d");
                        $(canvas).attr({
                            width:w,
                            height:h
                        });
                        ctx.drawImage(img,0,0,w,h);
                        var base64 = canvas.toDataURL("image/png",0.5);
                        var result = {
                            url:window.URL.createObjectURL(file),
                            base64:base64,
                            clearBase64:base64.substr(base64.indexOf(',')+1),
                          suffix:base64.substring(base64.indexOf(',')+1,base64.indexOf(';'))
                        };
                        $(".file-upload-zone-title").hide();
                        $(".file-upload-zone").empty();
                        $("#cover").val(result.base64);
                        $("<img src=\""+result.base64+"\" class=\"img img-responsive center-block\">").appendTo(".file-upload-zone");
                        $(".file-upload-zone").trigger("create");
                        $(".file-name").val(fileName);
                    }
                }
                reader.readAsDataURL(obj.files[0]);
            }catch(e){
                layer.msg("error");
            }
        };
    

关于这段代码的核心逻辑,其实与后端的解码过程刚好相反,这里不再赘述。

到现在,通过Base64编码方式同步上传文件的核心功能已经完成,在接下来的内容中,使用Spring Boot 2.0快速的演示本次分享的内容。

添加文章服务组件#文件上传

1. 添加文章的服务组件

接一开始的需求背景,图片信息属于文章对象的一个属性值,所以处理文件上传的逻辑后置到service中,在本次测试代码中,最终的文件存储采用的是七牛云的CDN服务,关于CDN部分的代码不进行展开,可以上传到本地,两者操作的对象都是MultipartFile,关于如何存储不是本次分享的重点。文章服务组件主要代码如下:

package com.ramostear.jfast.domain.service.impl;

import com.ramostear.jfast.common.ext.Translate;
import com.ramostear.jfast.domain.repo.ArticleRepo;
import com.ramostear.jfast.domain.service.ArticleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
 * @author ramostear|谭朝红
 * @create-time 2019/3/19 0019-23:37
 * @modify by :
 * @since:
 */
@Service(value = "articleService")
@Transactional(readOnly = true)
public class ArticleServiceImpl implements ArticleService {
    @Autowired
    private ArticleRepo articleRepo;

    @Override
    @Transactional
    public void save(ArticleVo vo) {
        Article article = Translate.toArticle(vo);
        articleRepo.save(article);
    }

在ArticleService服务组件中,涉及到一个Translate类,它的作用主要是讲前端传输过来的ValueObject映射到POJO类中,同时将文件存储的逻辑也封装进去了,主要代码如下:

package com.ramostear.jfast.common.ext;

import com.ramostear.jfast.common.factory.CdnFactory;
import com.ramostear.jfast.common.factory.cdn.CdnRepository;
import com.ramostear.jfast.common.utils.Base64Converter;
import com.ramostear.jfast.domain.model.Article;
import com.ramostear.jfast.domain.vo.ArticleVo;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.web.multipart.MultipartFile;

import java.util.Date;

/**
 * @author ramostear|谭朝红
 * @create-time 2019/3/18 0018-3:39
 * @modify by :
 * @since:
 */
public class Translate {

    private static CdnRepository cdnRepo = CdnFactory.builder(CdnFactory.CdnType.Qiniu);

    public static Article toArticle(ArticleVo vo){
        Article article = new Article();
        BeanUtils.copyProperties(vo,article);
        if(StringUtils.isNotBlank(vo.getCover())){
            MultipartFile file = Base64Converter.converter(vo.getCover());
            article.setCover(cdnRepo.save(file));
        }
        return article;
    }
}

此处由于使用的是七牛云的CDN服务,所以通过一个CND的工厂类获取一个CND仓储实例,用于将文件写入到仓储中,并回传一个文件访问地址。除了上述的方法,还可以调用file.transferTo()方法将文件写入到本地(应用服务器)磁盘中。

这里的CND工厂类实现细节由于篇幅原因不再展开。需要了解更多关于CDN SDK使用方法,可以在文章末尾给我留言。

2. 文章控制器

最后,定义一个控制器,提供给前端添加文章时进行调用,文章控制器主要工作是获得前端传入的文章信息,然后调用文章服务组件,完成添加文章工作。核心代码如下:

package com.ramostear.jfast.domain.controller;

@RestController
public class ArticleController{
    @Autowired
    ArticleService articleService;

    @Postmapping(value="/articles")
    public ResponseEntity<Object> createArticle(@RequestBody ArticleVo vo){
        try{
            articleService.save(vo);
            return new ResponseEntity<>("已经成功将文字写入数据库",HttpStatus.CREATED);
        }catch(Exception e){
            return new ResponseEntity<>(e.getMessage(),HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

}

结束语

本次分享只给出了核心部位的实现,其中涉及到的如CDN、HTML、JS等的知识没有展开,如果给你带来了困惑,可以在评论区给我留言,我们再一起讨论。再次感谢大家赏光拜读,谢谢~~~