上周接手了一个微服务迁移的项目,即从原来的腾讯云迁移到阿里云的EDAS平台,这个项目非常的简单技术层面基本是没有什么可说的,接口也不多,而且都是使用请求阿里获取一些数据。但是随着和测试以及甲方的对接有了一些新的变化。
本来测试环境和生产是两套代码,区别在于测试环境是有数据库的,而生产环境没有数据库的(至于为啥生产环境不能加一个数据库….这个说来话长)。测试环境需要根据接口请求参数来判断是直接从数据库返回数据还是调用阿里接口获取数据后返回。原来的测试环境代码逻辑其实也简单,就是添加了一个拦截器获取请求参数,然后根据这个参数去查询数据库,数据库有结果直接返回,没有则需要调用阿里接口获取结果并返回。对接之后需求变了,首先测试环境和生产环境代码是一套,生产依然没有数据库,这就引起了一个很尴尬的问题(下面会详细说),还有就是本来测试环境的请求参数是一个额外添加的RequestParam,现在没有这个参数了,需要根据接口的具体请求参数来进行判断,关键部分接口的参数是在RequestBody…..本着尽量不改动原有代码的情况下,我决定继续使用原来的拦截器,接下来就是遇到了几个问题。
一、RequestBody丢失问题
第一个问题就是在拦截器我需要获取用户请求参数,这里可能会有一个疑问,拦截器不是能够拿到HttpServletRequest吗,直接获取就行了呀,这么想确实没问题,但是如果是POST请求,且请求参数在RequestBody中就会出现问题,看下面拦截器的preHandle方法代码:
@Override
publicboolean preHandle(HttpServletRequestrequest, HttpServletResponseresponse, Object handler) throws Exception {
log.info(">>>> requestInterceptor preHandle method start <<<<");
StringjsonRequest = HttpRequestUtil.getRequestBody(request);
StringrequestParam ="";
if(StringUtils.isNotBlank(jsonRequest)) {
Map requestMap =newGson().fromJson(jsonRequest,Map.class);
requestParam = requestMap !=null? requestMap.get("username") :"";
}else{
requestParam =request.getParameter("username");
}
if(StringUtils.isNotBlank(requestParam)) {
User user = userRepository.findByUsername(requestParam);
if(user !=null) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(newGson().toJson(user));
returnfalse;
}
}
returntrue;
}
上面的代码中使用一个工具类读取HttpServletRequest的请求体,代码如下:
@Slf4j
publicclassHttpRequestUtil{
publicstaticStringgetRequestBody(HttpServletRequest request) {
StringBufferstringBuffer =newStringBuffer();
try(ServletInputStream servletInputStream = request.getInputStream()){
Stringline =null;
BufferedReader bufferedReader =newBufferedReader(newInputStreamReader(servletInputStream));
while((line = bufferedReader.readLine()) !=null) {
stringBuffer.append(line);
}
}catch(IOException e) {
log.error(">>>> error occurred while get request inputStream, error message={} <<<<",e.getMessage());
e.printStackTrace();
}
returnstringBuffer.toString();
}
}
如果在拦截器中获取到了相应的参数是没问题的,但是一旦preHandle方法返回true,即将HttpServletRequest向后传递,那么就会出现问题:
Failed to read HTTP message: org.springframework.http.converter.HttpMessageNotReadableException: I/O error while reading input message; nested exception is java.io.IOException: Stream closed
原因就是在拦截器已经读取了请求体中的内容,这时候请求的流中已经没有了数据,开始我只是以为是HttpRequestUtil中关闭流的问题,后面修改以后还是不行,报错信息是:
Failed to read HTTP message: org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public org.springframework.http.ResponseEntity
是因为请求体丢失,也就是说HttpServletRequest请求体中的内容一旦读取就不不存在了,所以直接读取是不行的。后面网上看到一种方案,就是使用一个自定义的包装类来实现,因此自定义一个包装类CustomRequestWrapper继承HttpServletRequestWrapper,代码如下:
publicclassCustomRequestWrapperextendsHttpServletRequestWrapper{
privatebyte[] requestBody;
publicCustomRequestWrapper(HttpServletRequest request){
super(request);
requestBody = HttpRequestUtil.getRequestBody(request).getBytes();
}
publicbyte[] getRequestBody() {
returnrequestBody;
}
@Override
publicServletInputStreamgetInputStream()throwsIOException{
ByteArrayInputStream byteArrayInputStream =newByteArrayInputStream(requestBody);
ServletInputStream servletInputStream =newServletInputStream() {
@Override
publicbooleanisFinished(){
returnfalse;
}
@Override
publicbooleanisReady(){
returnfalse;
}
@Override
publicvoidsetReadListener(ReadListener readListener){
}
@Override
publicintread()throwsIOException{
returnbyteArrayInputStream.read();
}
};
returnservletInputStream;
}
@Override
publicBufferedReadergetReader()throwsIOException{
returnnewBufferedReader(newInputStreamReader(getInputStream()));
}
}
这样成员变量requestBody保存了请求体的内容,根据其构造函数可以看出,其先会调用父类构造,然后将HttpServletRequest的请求体内容赋值给成员变量requestBody。这样似乎应该没问题了,毕竟赋值是调用父类构造之后进行的,只要在之后的过程中将自定义的CustomRequestWrapper向后进行传递就行了,这么说好像没问题,但是实际上在拦截中没办法实现这点,这时候就需要引入一个Filter,因为Filter先执行这样能够保证在过滤的时候将HttpServletRequest替换成我们自定义的CustomRequestWrapper向后进行传递。定义一个Filter,代码如下:
@Slf4j
@Component
@WebFilter(urlPatterns = {"/user/*"},filterName ="customFilter")
publicclassCustomFilterimplementsFilter{
@Override
publicvoidinit(FilterConfig filterConfig)throwsServletException{
log.info(">>>> customFilter init <<<<");
}
@Override
publicvoiddoFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)throwsIOException, ServletException{
log.info(">>>> customFilter doFilter start <<<<");
CustomRequestWrapper requestWapper =null;
if(servletRequestinstanceofHttpServletRequest) {
requestWapper =newCustomRequestWrapper((HttpServletRequest) servletRequest);
}
if(requestWapper !=null) {
filterChain.doFilter(requestWapper,servletResponse);
}else{
filterChain.doFilter(servletRequest,servletResponse);
}
}
@Override
publicvoiddestroy(){
log.info(">>>> customFilter destroy <<<<");
}
}
上面的代码中在自定义Filter执行doFilter时判断ServletRequest是不是一个HttpServletRequest实例,是的话,则创建一个自定义的CustomRequestWrapper对象,并将其向后传递,这样之后的代码中我们获取到的HttpServletRequest其实都是一个CustomRequestWrapper对象。
这样应该就没什么问题了,接下来debug模式重启项目测试一下,我们现在过滤器看看创建的CustomRequestWrapper和在拦截器中的HttpServletRequest对象是不是一个,见图-1和图-2。
图-1.png
图-2.png
也就是说在自定义的过滤器之后,其实传递的都是CustomRequestWrapper对象。这里需要说明一点,就是自定义的CustomRequestWrapper中必须要重写getInputStream和getReader这两个方法(这两个方法都是返回的请求体),不然依然无法获取到请求体的内容。当然我觉得最好参考这个代码实现。
另外这里还遇到了一个关于Filter的小问题,根据自定义的代码可以看出来我配置的过滤路径是/user/*,但是实际在启动日志中却并不是这样的,如下:
图-3.png
可见我配置的过滤路径并没有生效,依然是过滤/*所有请求,虽然不影响功能的实现,但是我还是觉得还是根据具体需求来比较好。网上找资料说需要在启动类使用@ServletComponentScan注解,指定basePackages即自定义Filter的包名或者使用basePackageClasses指定具体的Filter类即可,具体原因尚不清楚。
二、不同环境下服务启动
前面介绍了对接后的变更,这里还有个令人难受的问题,那就是生产环境和测试环境不同的问题,测试环境需要使用数据库,生产没有数据库。如果直接将现在代码部署到生产环境,服务是无法启动的,因为在服务启动过程会涉及到创建数据库链接,但是没有数据源。开始的想法是能不能在测试环境配置数据源,而在生产环境不配置,但是因为spring boot是自动配置,那么可以禁用自动配置,即在测试环境使用自定义数据源配置,而在生产环境不指定。先按照生产环境代码优先的原则,先排除掉所有和数据库相关的自动配置,比如DataSourceAutoConfiguration和HibernateJpaAutoConfiguration,代码如下:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
@EnableDiscoveryClient
@ServletComponentScan(basePackageClasses = {CustomFilter.class})
publicclassNacosApplication{
publicstaticvoidmain(String[] args) {
SpringApplication.run(NacosApplication.class, args);
}
}
但是启动过程依然报错,因为创建RequestInterceptor时需要依赖UserRepository,但是因为排除了HibernateJpaAutoConfiguration,所以无法创建UserRepository,当时想着要不然直接使用JdbcTemplate算了,虽然需要自己写sql,这样就不会涉及到JPA的内容了。后来想起其实拦截器这部分代码只在测试环境使用,那问题就容易解决了,只要自定义拦截器和注册拦截器的配置类只在测试环境下创建就可以了,修改自定义拦截器和其注册配置类,代码如下:
@Slf4j
@Configuration
@Profile("dev")
publicclassInterceptorConfigimplementsWebMvcConfigurer{
@Autowired
privateRequestInterceptor requestInterceptor;
@Override
publicvoidaddInterceptors(InterceptorRegistry registry){
log.info(">>>> registry interceptor start <<<<");
registry.addInterceptor(requestInterceptor).addPathPatterns("/user/**");
}
}
// 拦截器
@Slf4j
@Component
@Profile("dev")
publicclassRequestInterceptorimplementsHandlerInterceptor{
@Autowired
privateUserRepository userRepository;
@Override
publicbooleanpreHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throwsException{
log.info(">>>> requestInterceptor preHandle method start <<<<");
CustomRequestWrapper requestWrapper =newCustomRequestWrapper(request);
String jsonRequest =newString(requestWrapper.getRequestBody(), Charset.forName("UTF-8"));
String requestParam ="";
if(StringUtils.isNotBlank(jsonRequest)) {
Map requestMap =newGson().fromJson(jsonRequest,Map.class);
requestParam = requestMap !=null? requestMap.get("username") :"";
}else{
requestParam = request.getParameter("username");
}
if(StringUtils.isNotBlank(requestParam)) {
User user = userRepository.findByUsername(requestParam);
if(user !=null) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(newGson().toJson(user));
returnfalse;
}
}
returntrue;
}
}
当修改spring.profiles.active=prod时启动服务,服务终于可以正常启动,日志如下:
图-4.png
可见启动日志中没有任何和数据库相关的内容,测试一下接口也是正常的。
但是改回spring.profiles.active=dev的时候就无法启动了,因为在启动类上我们排除了
DataSourceAutoConfiguration和HibernateJpaAutoConfiguration,所以必须修改启动类上的注解,即将:
(exclude={DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})去掉。但是生产环境确实不能有这两个自动配置项,所以改为在生产环境配置文件,在
application-prod.properties添加以下配置,排除两个自动配置项:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
然后切换到dev环境启动服务,启动日志如下:
图-5.png
从上图可以看出启动过程中输出了HikariPool和Hibernate相关日志,且调用接口也返回了测试数据,说明整个服务根据配置文件切换环境的功能完成了。
其实在解决这个问题的过程中也有同事建议我使用一个内存型的数据库,但是自己对这方面了解的比较少,因此没有按照他的思路去做,不知道可不可行。另外其实就功能实现上来讲我觉得使用JdbcTemplate而不使用JPA应该也是可行的,但是我觉得尽量不动原来的代码比较好,所以还是按照原有方案解决了。
当然,实际工作中解决方案可能不止一种,感兴趣的话可以尝试下不同的解决方案,这样也可以加深对相关知识点的了解。