文 by / 林本托
Tips
做一个终身学习的人。
源代码:下的/code01/ch2。
配置 Web 应用程序
在上一章中,我们学习了如何创建一个基本的应用程序模板,并添加了一些基本功能,并建立与数据库的连接。 在本章中,我们将继续增强BookPub
应用程序,并提供 Web 支持。
在本章,主要包括以下内容:
- 创建一个基本的 RESTful 风格的应用程序;
- 创建一个 Spring Data REST 服务;
- 配置一个自定义的 Servlet 的过滤器;
- 配置一个自定义的拦截器;
- 配置一个HttpMessageConverters的转换器;
- 配置一个自定义的PropertyEditors编辑器;
- 配置一个自定义的类型格式化类。
一. 创建一个基本的 RESTful 风格的应用程序
虽然命令行应用程序确实有其用途,但今天的大多数应用程序开发都围绕着 Web,REST 和数据服务。 我们开始增强 BookPub 应用程序,提供一个基于Web 的 API,以便访问图书目录。
我们继续使用前一章创建的应用程序框架,其中定义了实体对象和存储库服务,并配置了与数据库的连接。
首先,第一件事情是我们需要在build.gradle 文件中添加新的依赖模块spring-boot-starter-web,以便获取基于 web 服务的所需的类库。具体的代码片段如下:
dependencies { compile("org.springframework.boot:spring-boot-starter-data-jpa") compile("org.springframework.boot:spring-boot-starter-jdbc") compile("org.springframework.boot:spring-boot-starter-web") runtime("com.h2database:h2") testCompile("org.springframework.boot:spring-boot- starter-test")}
接下来,创建一个 Spring 控制器,用于处理我们应用程序中获取目录数据的 Web 请求。 新建一个包目录来存放控制器相关的 Java 程序,以便我们的代码按照适当的目的分组。 在 src/main/java/org/test/bookpub 目录下创建一个名为 controllers的包目录。
因为需要暴露图书的数据,所以,在新建的包下,创建一个控制器类BookController
。
package org.test.bookpub.controllers;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.propertyeditors.StringTrimmerEditor;import org.springframework.web.bind.WebDataBinder;import org.springframework.web.bind.annotation.*;import org.test.bookpub.entity.Book;import org.test.bookpub.entity.Reviewer;import org.test.bookpub.repository.BookRepository;import java.util.Collections;import java.util.List;@RestController@RequestMapping("/books")public class BookController { @Autowired private BookRepository bookRepository; @RequestMapping(value = "", method = RequestMethod.GET) public IterablegetAllBooks() { return bookRepository.findAll(); } @RequestMapping(value = "/{isbn}", method = RequestMethod.GET) public Book getBook(@PathVariable String isbn) { return bookRepository.findBookByIsbn(isbn); }}
然后, 使用./gradlew clean bootRun.
命令启动应用程序。
最后,当应用程序程序启动以后,在浏览器中输入:http://localhost:8080/books, 然后会在页面上显示“[]”,表示目前没有图书的数据。
获取暴露于Web请求的服务的关键是@RestController注解。这是一个元注解使用的例子,正如Spring文档指出的那样,我们在以前的代码看到过。 在@RestController中,定义了两个注解:@Controller和@ResponseBody。 所以我们可以轻松给BookController
类添加注解,如下所示:
@Controller@ResponseBody@RequestMapping("/books")public class BookController {...}
@Controller是一个类似于@Bean 和@Repository的Spring的元注解,并将已经添加注解的类声明为 MVC 控制器。
@ResponseBody是一个Spring MVC的注解,指示来自Web请求映射方法的响应,构成HTTP响应主体有效负载的整个内容,这是RESTful应用程序比较典型的使用场景。
二. 创建一个 Spring Data REST 服务
在上一个例子中,我们使用REST控制器来展示我们的BookRepository,通过Web RESTful API访问后台的数据。 虽然这是数据访问的一种快捷方便的方式,但它要求我们手动创建一个控制器并定义所有所需操作的映射。 为了最小化代码,Spring为我们提供了一种更方便的方法:spring-boot-starter-data-rest模块。 这允许我们简单地向存储库接口添加一个注解,而Spring将做剩下的事情用以将数据暴露给Web。
首先,我们需要在build.gradle文件中添加spring-boot-starter-data-rest模块。
dependencies { ... compile("org.springframework.boot:spring-boot-starter-data-rest") ...}
第二步,在src/main/java/org/test/bookpub/repository目录下新建AuthorRepository接口,代码如下:
package org.test.bookpub.repository;import org.springframework.data.repository.CrudRepository;import org.springframework.data.repository.PagingAndSortingRepository;import org.springframework.data.rest.core.annotation.RepositoryRestResource;import org.springframework.stereotype.Repository;import org.test.bookpub.entity.Author;@RepositoryRestResourcepublic interface AuthorRepository extends PagingAndSortingRepository{}
接下来为剩下的实体模型创建对应的接口,
package org.test.bookpub.repository;import org.springframework.data.repository.PagingAndSortingRepository;import org.springframework.data.rest.core.annotation.RepositoryRestResource;import org.test.bookpub.entity.Publisher;@RepositoryRestResourcepublic interface PublisherRepository extends PagingAndSortingRepository{}
package org.test.bookpub.repository;import org.springframework.data.repository.PagingAndSortingRepository;import org.springframework.data.rest.core.annotation.RepositoryRestResource;import org.test.bookpub.entity.Reviewer;@RepositoryRestResourcepublic interface ReviewerRepository extends PagingAndSortingRepository{}
代码完成后,执行./gradlew clean bootRun
。当启动成功以后,访问http://localhost:8080/profile/authors,在 Chrome 浏览器下,显示如下内容:
{ "_embedded" : { "authors" : [ ] }, "_links" : { "self" : { "href" : "http://localhost:8080/authors{?page,size,sort}", "templated" : true }, "profile" : { "href" : "http://localhost:8080/profile/authors" } }, "page" : { "size" : 20, "totalElements" : 0, "totalPages" : 0, "number" : 0 }}
从浏览器里显示的内容可以看出,我们将获得比我们编写BookController控制器更多的信息。 之所以显示了更多的信息,由于我们没有扩展CrudRepository接口,而是扩展了PagingAndSortingRepository,而它又是CrudRepository的扩展。 这样做的原因是获得PagingAndSortingRepository提供的额外好处。 这将添加额外的功能,使用分页检索实体,并能够对它们进行排序。
@RepositoryRestResource注解是可选项,但可以让我们更好地控制作为Web数据服务的存储库的暴露。 例如,如果我们要将URL路径由“authers”改成“rel”,则可以此注解调整,如下所示:
@RepositoryRestResource(collectionResourceRel = "writers", path = "writers")
修改以后,之前的 url 现在改完“http://localhost:8080/reviewers”。
由于我们在构建依赖项中包含spring-boot-starter-data-rest模块,我们还获得spring-hateoas类库的支持,此库给我们提供了很好的ALPS元数据,比如_links对象。 这在构建API驱动的UI时可能非常有用,这可以从元数据推导出导航功能并以适合的方式呈现它们。
Tips
关于更多 ALPS 元数据的信息,请参考https://spring.io/blog/2014/07/14/spring-data-rest-now-comes-with-alps-metadata。
三. 配置一个自定义的 Servlet 的过滤器
在一个真实的Web应用程序中,我们几乎总是需要为服务请求添加装饰器或包装器,用来记录它们,过滤XSS(跨站脚本攻击)的非法字符,执行认证等。Spring Boot自动添加OrderedCharacterEncodingFilter和HiddenHttpMethodFilter 两个过滤器,除此之外,还有其他的过滤器。 让我们看看Spring Boot如何帮助我们实现这个任务。
在Spring Boot,Spring Web,Spring MVC等的各种框架中,已经有各种不同的servlet过滤器可用,我们所要做的就是将它们作为一个bea定义到配置中。 假设应用程序将在负载平衡器代理之后运行,并且当我们的应用程序实例收到请求时,我们希望将用户使用的实际请求IP转换为代理的IP。 幸运的是,Tomcat 8已经为我们提供了一个实现:RemoteIpFilter。 我们需要做的就是将其添加到我们的过滤器链中。
根据功能的不同,便于管理和职责清晰,我们需要把不同的类放在不同的包下,我们创建一个WebConfiguration.java
的文件,放在src/main/java/org/test/bookpub 目录下。
package org.test.bookpub;import org.apache.catalina.filters.RemoteIpFilter;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;@Configurationpublic class WebConfiguration extends WebMvcConfigurerAdapter { @Bean public RemoteIpFilter remoteIpFilter() { return new RemoteIpFilter(); }}
第二步,执行./gradlew clean bootRun
命令,在启动中,查看 log,会出现以下信息,表示过滤器已经添加:
o.s.b.w.servlet.FilterRegistrationBean : Mapping filter: 'remoteIpFilter' to: [/*]
这个功能背后的功能其实很简单。 我们从单独的配置类开始,并且将我们的方法用于过滤器bean的检测。
我们看看主类BookPubApplication,这个类添加了@SpringBootApplication注解,此注解是是一个元注解,它声明了@ComponentScan等其他注解。@ComponentScan的存在指示Spring Boot将WebConfiguration类检测为@Configuration注解的标记类,并将其定义添加到应用程序上下文中。所以,我们将在WebConfiguration类中声明的任何事情就好像我们将它放在BookPubApplication类中是一样的。
@Bean public RemoteIpFilter remoteIpFilter(){...}
声明为RemoteIpFilter类创建一个Spring bean。当Spring Boot 检测到javax.servlet.Filter的所有bean时,它将自动将它们添加到过滤器链中。所以我们要做的就是,如果要添加更多的过滤器,那就是将它们声明为@Bean配置。例如,对于更高级的过滤器配置,如果希望特定的过滤器仅适用于特定的URL模式,可以创建一个FilterBistrationBean类型的@Bean配置,并用来配置精确的设置。
四. 配置一个自定义的拦截器
Servlet过滤器是Servlet API的一部分,与Spring完全没有任何关系,除了自动添加到过滤器链中,Spring MVC为我们提供了另一种方式来包装Web请求:HandlerInterceptor拦截器。根据文档,HandlerInterceptor就像一个Filter,但是,拦截器不是在嵌套链中包含请求,而是在处理请求、处理视图或在页面渲染之前,在不同的阶段向我们提供了拦截点,对请求进行拦截。到最后,请求已经完成。它不改变有关请求的任何内容,但是如果拦截器逻辑返回false,它允许我们通过抛出异常来停止执行。
与过滤器的情况类似,Spring MVC附带了一些预定义的HandlerInterceptor。常用的是LocaleChangeInterceptor和ThemeChangeInterceptor。接下来在应用程序中添加LocaleChangeInterceptor,看看它是如何完成的。
添加一个拦截器并不像刚才声明一个bean那么简单。 实际上需要通过实现WebMvcConfigurer接口或重写WebMvcConfigurationSupport来实现。
第一步,WebConfiguration
类继承WebMvcConfigurerAdapter
类。
public class WebConfiguration extends WebMvcConfigurerAdapter {…}
接下来,为LocaleChangeInterceptor
拦截器增加@Bean注解,
@Beanpublic LocaleChangeInterceptor localeChangeInterceptor() { return new LocaleChangeInterceptor();}
这实际上只会创建拦截 Spring bean,但不会将其添加到请求处理链中。 为了实现这一点,我们需要重写addInterceptors方法,注册拦截器。
@Overridepublic void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(localeChangeInterceptor());}
整个代码如下:
package org.test.bookpub;import org.apache.catalina.filters.RemoteIpFilter;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;@Configurationpublic class WebConfiguration extends WebMvcConfigurerAdapter { @Bean public RemoteIpFilter remoteIpFilter() { return new RemoteIpFilter(); } @Bean public LocaleChangeInterceptor localeChangeInterceptor() { return new LocaleChangeInterceptor(); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(localeChangeInterceptor()); }}
执行./gradlew clean bootRun
。
应用程序启动以后,在浏览器中输入http://localhost:8080/books?locale=foo。
这时,浏览器报错:
后台的错误 log 如下:
org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.UnsupportedOperationException: Cannot change HTTP accept header - use a different locale resolution strategy
Tips
上面的错误不是因为我们输入了无效的区域设置,而是因为默认语言环境解析策略不允许重置浏览器请求的语言环境。出现一个错误,实际上是证明了拦截器已经生效了。
当涉及到配置Spring MVC内部组件时,它并不像只是定义一堆bean那样简单—— 至少不总是这样。 这是因为需要向请求提供更精细的MVC组件映射。 为了简化难度,Spring为我们提供了WebMvcConfigurerAdapter适配器,它是WebMvcConfigurer接口实现,我们可以扩展和覆盖我们需要的设置。
在配置拦截器的特定情况下,我们可以重写addInterceptors(InterceptorRegistry registry)
方法。 这是一个典型的回调方法,我们给予一个注册类,以便根据需要注册多个附加拦截器。 在MVC自动配置阶段,Spring Boot就像过滤器一样检测到WebMvcConfigurer的实例,并依次调用所有这些回调方法。 这意味着如果需要其他逻辑分离,可以有多个WebMvcConfigurer类的实现。
五. 配置一个HttpMessageConverters转换器
在构建RESTful Web服务时,我们定义了控制器,资源库,并在其上面添加了注解,但是从Java实体bean到HTTP数据流输出没有任何类型的对象转换。 实际上,Spring Boot自动配置了HttpMessageConverters转换器将实体bean对象转换为使用了Jackson类库的JSON的格式,将生成的JSON数据写入HTTP响应输出流。 当多个转换器可用时,根据消息对象类和请求的内容类型选择最适用的转换器。
HttpMessageConverters的目的是将各种对象类型转换为相应的HTTP输出格式。 转换器可以支持一系列多种数据类型或多种输出格式,或两者的组合。 例如,MappingJackson2HttpMessageConverter类可以将任何Java对象转换为application/json的格式,而ProtobufHttpMessageConverter类只能对com.google.protobuf.Message的实例进行操作,但可以将其作为application/json,application/xml,text/plain或application/xprotobuf格式。 HttpMessageConverters不仅支持写入HTTP流,还支持将HTTP请求转换为适当的Java对象。
我们可以通过多种方式配置转换器。 这一切都取决于你喜欢哪一个,或者想要实现多少控制。
首先,我们在WebConfiguration类中增加ByteArrayHttpMessageConverter,并加上@Bean注解。
@Beanpublic ByteArrayHttpMessageConverter byteArrayHttpMessageConverter() { return new ByteArrayHttpMessageConverter();}
另一种实现方式是重写WebConfiguration类中的configureMessageConverters方法,首先需要继承WebMvcConfigurerAdapter类,具体的代码如下:
@Overridepublic void configureMessageConverters(List> converters) { converters.add(new ByteArrayHttpMessageConverter());}
如果想获取更多的控制,还可以重写extendMessageConverters
方法。
@Overridepublic void extendMessageConverters(List> converters) { converters.clear(); converters.add(new ByteArrayHttpMessageConverter());}
如上所示,Spring给了我们多种方式来实现同样的事情,这一切都取决于我们的偏好或具体的实现细节。
我们介绍了将HttpMessageConverter添加到应用程序中的三种不同的方法。 那有什么区别呢?
将HttpMessageConverter声明为@Bean是向应用程序添加自定义转换器的最快捷,最简单的方法。 它类似于我们在前面的例子中添加了Servlet过滤器。 如果Spring检测到一个HttpMessageConverter类型的bean,它将自动将其添加到列表中。 如果WebConfiguration类没有继承WebMvcConfigurerAdapter父类,那么这是首选方法。
当应用程序需要指定WebMvcConfigurerAdapter的扩展以配置其他的东西,如拦截器,那么重写configureMessageConverters
方法并将我们的转换器添加到列表将更为协调一致。可以从Spring Boot 的模块中添加多个WebMvcConfigurers实例并自动配置,但是不能保证我们的方法可以以任何特定的顺序被调用。
如果我们需要做一些更加具体的事情,比如从列表中删除所有其他转换器或清除重复的转换器,这需要重写extendMessageConverters方法的地方。 所有WebMvcConfigurer被调用到configureMessageConverter方法并且转换器列表被完全填充后调用此方法。 当然,WebMvcConfigurer的其他一些实例完全可以重写extendMessageConverters, 但是这样做的机会并不多。
六. 配置一个自定义的PropertyEditors编辑器
在前面的例子中,我们学习了如何为HTTP请求和响应数据配置转换器。 还有其他类型的转换,特别是在将参数动态转换为各种对象时,例如String类型转换为Date或Integer。
当我们在控制器中声明一个映射方法时,Spring使用确切的对象类型来自由定义方法签名。 这个方式是通过使用PropertyEditor实现的。 PropertyEditor是一个默认概念,定义为JDK的一部分,旨在允许将文本值转换为给定类型。 它最初用于构建Java Swing / AWT GUI,后来被证明是适合Spring需要将Web参数转换为方法参数类型的需要。
Spring MVC已经为很多常见类型(如布尔型,货币型和类)提供了大量的PropertyEditor实现。 假设我们要创建一个Isbn类对象,并在我们的控制器中使用它,而不是一个纯粹的String类型。
首先,我们需要在WebConfiguration
类中移除extendMessageConverters
方法,因为调用converters.clear()
这段代码会中断渲染,因为删除了所有支持的类型转换器。
然后,定义Isbn
类,和对应的IsbnEditor
属性编辑器,以及重写initBinde
方法给我们的BookController
类,使用以下内容配置IsbnEditor
:
public class Isbn { private String isbn; public Isbn(String isbn) { this.isbn = isbn; } public String getIsbn() { return isbn; }}
public class IsbnEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { if (StringUtils.hasText(text)) { setValue(new Isbn(text.trim())); } else { setValue(null); } } @Override public String getAsText() { Isbn isbn = (Isbn) getValue(); if (isbn != null) { return isbn.getIsbn(); } else { return ""; } }}@InitBinderpublic void initBinder(WebDataBinder binder) { binder.registerCustomEditor(Isbn.class, new IsbnEditor());}
第三步,在BookController
类中修改getBook
方法,以便可以接受Isbn
类型的对象,
@RequestMapping(value = "/{isbn}", method = RequestMethod.GET)public Book getBook(@PathVariable Isbn isbn) { return bookRepository.findBookByIsbn(isbn.getIsbn());}
第四步,启动./gradlew clean bootRun
,启动成功以后,在浏览器中输入http://localhost:8080/books/978-1-78528-415-1。
虽然我们不会观察到任何可见的更改,但IsbnEditor
确实在工作,从{isbn}参数中创建Isbn类对象实例。我们打印了传递过来的Isbn实例,重写了toString()
方法。
Spring自动配置大量的默认编辑器,但是对于自定义类型,我们必须明确地为每个Web请求实例化新的编辑器。 这是在控制器中使用@InitBinder注解的方法完成的。 扫描此注解,所有检测到的方法应具有接受WebDataBinder作为参数的签名。 除此之外,WebDataBinder还为我们提供了注册尽可能多的自定义编辑器的能力,要求控制器的方法被正确绑定。
Tips
PropertyEditor不是线程安全的! 因此,我们必须为每个Web请求创建一个新的自定义编辑器实例,并将其注册到WebDataBinder。
如果需要新的PropertyEditor,最好通过扩展PropertyEditorSupport类并自定义重写所需的方法来创建。
七. 配置一个自定义的类型格式化类
PropertyEditor因为它的状态和非线程安全,从版本3起,Spring添加了一个Formatter接口作为PropertyEditor的替代。 格式化类旨在提供类似的功能,但是以完全线程安全的方式,并专注于解析对象类型中的String并将对象转换为其字符串表示形式的非常具体的任务。
对于我们的应用程序,希望有一个格式化程序可以使用一个字符串形式的书籍的ISBN号码并将其转换为一个Book实体对象。 这样,当请求URL签名仅包含ISBN号码或数据库ID时,就可以使用Book类型的参数定义控制器请求的方法。
首先,在src/main/java/org/test/bookpub目录下创建一个新的包formatters,在此包下,创建BookFormatter
类并实现Formatter
接口,代码示例如下:
public class BookFormatter implements Formatter{ private BookRepository repository; public BookFormatter(BookRepository repository) { this.repository = repository; } @Override public Book parse(String bookIdentifier, Locale locale) throws ParseException { Book book = repository.findBookByIsbn(bookIdentifier); return book != null ? book : repository.findOne(Long.valueOf(bookIdentifier)); } @Override public String print(Book book, Locale locale) { return book.getIsbn(); }}
然后,在WebConfiguration
类中,重写addFormatters(FormatterRegistry registry)
方法,并把BookFormatter
类注册进去。
@RequestMapping(value = "/{isbn}/reviewers", method = RequestMethod.GET)public ListgetReviewers(@PathVariable("isbn") Book book) { return book.getReviewers();}
接下来,在BookController
类中,新增一个请求方法,用来根据给定的图书的 isbn来显示评论者,
@RequestMapping(value = "/{isbn}/reviewers", method = RequestMethod.GET)public ListgetReviewers(@PathVariable("isbn") Book book) { return book.getReviewers();}
为了一些数据,现在手动添加一些测试数据填充数据库,通过向StartupRunner类添加两个自动装配的资源库:
@Autowired private AuthorRepository authorRepository;@Autowired private PublisherRepository publisherRepository;
下面这些代码添加到StartupRunner
类的run(...)
方法中:
Author author = new Author("Alex", "Antonov");author = authorRepository.save(author);Publisher publisher = new Publisher("Packt");publisher = publisherRepository.save(publisher);Book book = new Book("978-1-78528-415-1", "Spring Boot Recipes", author, publisher);bookRepository.save(book);
输入./gradlew clean bootRun
,在控制台,启动应用程序。
访问,在浏览器中可以看到如下结果:
格式化功能旨在提供与PropertyEditors类似的功能。 通过将FormatterRegistry注册在重写的addFormatter
方法中,告诉Spring使用Formatter将Book的文本表示转换为实体对象并返回。 由于格式化是无状态的,因此我们无需在控制器中每次都要注册; 我们只做一次就好,这确保Spring为每个Web请求使用它。
Tips
如果要定义一个常用类型的转换(例如String或Boolean),就像我们在IsbnEditor示例中所做的那样,最好是通过Controller的InitBinder
方法中的PropertyEditors初始化来做,因为这样的改变可能不是全局所期望的,只是针对特定的控制器的功能。
你可能已经注意到,我们还将BookRepository自动装配到WebConfiguration类,因为这是创建BookFormatter所需的。 这是Spring的一个很酷的东西,它让我们可以组合配置类,并使它们同时依赖于其他bean。 正如我们指出,为了创建一个WebConfiguration类,我们需要一个BookRepository,Spring确保在创建WebConfiguration类时首先创建BookRepository,然后自动注入作为依赖。 实例化WebConfiguration之后,将对其进行处理以进行配置说明。