上周接手了一个微服务迁移的项目,即从原来的腾讯云迁移到阿里云的EDAS平台,这个项目非常的简单技术层面基本是没有什么可说的,接口也不多,而且都是使用请求阿里获取一些数据。但是随着和测试以及甲方的对接有了一些新的变化。
本来测试环境和生产是两套代码,区别在于测试环境是有数据库的,而生产环境没有数据库的(至于为啥生产环境不能加一个数据库....这个说来话长)。测试环境需要根据接口请求参数来判断是直接从数据库返回数据还是调用阿里接口获取数据后返回。原来的测试环境代码逻辑其实也简单,就是添加了一个拦截器获取请求参数,然后根据这个参数去查询数据库,数据库有结果直接返回,没有则需要调用阿里接口获取结果并返回。对接之后需求变了,首先测试环境和生产环境代码是一套,生产依然没有数据库,这就引起了一个很尴尬的问题(下面会详细说),还有就是本来测试环境的请求参数是一个额外添加的RequestParam
,现在没有这个参数了,需要根据接口的具体请求参数来进行判断,关键部分接口的参数是在RequestBody
.....本着尽量不改动原有代码的情况下,我决定继续使用原来的拦截器,接下来就是遇到了几个问题。
一、RequestBody丢失问题
第一个问题就是在拦截器我需要获取用户请求参数,这里可能会有一个疑问,拦截器不是能够拿到HttpServletRequest
吗,直接获取就行了呀,这么想确实没问题,但是如果是POST请求,且请求参数在RequestBody
中就会出现问题,看下面拦截器的preHandle
方法代码:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info(">>>> requestInterceptor preHandle method start <<<<");
String jsonRequest = HttpRequestUtil.getRequestBody(request);
String requestParam = "";
if (StringUtils.isNotBlank(jsonRequest)) {
Map<String,String> requestMap = new Gson().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(new Gson().toJson(user));
return false;
}
}
return true;
}
上面的代码中使用一个工具类读取HttpServletRequest
的请求体,代码如下:
@Slf4j
public class HttpRequestUtil {
public static String getRequestBody(HttpServletRequest request) {
StringBuffer stringBuffer = new StringBuffer();
try (ServletInputStream servletInputStream = request.getInputStream()){
String line = null;
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(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();
}
return stringBuffer.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
,代码如下:
public class CustomRequestWrapper extends HttpServletRequestWrapper {
private byte[] requestBody;
public CustomRequestWrapper(HttpServletRequest request) {
super(request);
requestBody = HttpRequestUtil.getRequestBody(request).getBytes();
}
public byte[] getRequestBody() {
return requestBody;
}
@Override
public ServletInputStream getInputStream() throws IOException {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody);
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
return servletInputStream;
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}
这样成员变量requestBody
保存了请求体的内容,根据其构造函数可以看出,其先会调用父类构造,然后将HttpServletRequest
的请求体内容赋值给成员变量requestBody
。这样似乎应该没问题了,毕竟赋值是调用父类构造之后进行的,只要在之后的过程中将自定义的CustomRequestWrapper
向后进行传递就行了,这么说好像没问题,但是实际上在拦截中没办法实现这点,这时候就需要引入一个Filter
,因为Filter
先执行这样能够保证在过滤的时候将HttpServletRequest
替换成我们自定义的CustomRequestWrapper
向后进行传递。定义一个Filter
,代码如下:
@Slf4j
@Component
@WebFilter(urlPatterns = {"/user/*"},filterName = "customFilter")
public class CustomFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info(">>>> customFilter init <<<<");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.info(">>>> customFilter doFilter start <<<<");
CustomRequestWrapper requestWapper = null;
if (servletRequest instanceof HttpServletRequest) {
requestWapper = new CustomRequestWrapper((HttpServletRequest) servletRequest);
}
if (requestWapper != null) {
filterChain.doFilter(requestWapper,servletResponse);
} else {
filterChain.doFilter(servletRequest,servletResponse);
}
}
@Override
public void destroy() {
log.info(">>>> customFilter destroy <<<<");
}
}
上面的代码中在自定义Filter
执行doFilter
时判断ServletRequest
是不是一个HttpServletRequest
实例,是的话,则创建一个自定义的CustomRequestWrapper
对象,并将其向后传递,这样之后的代码中我们获取到的HttpServletRequest
其实都是一个CustomRequestWrapper
对象。
这样应该就没什么问题了,接下来debug模式重启项目测试一下,我们现在过滤器看看创建的CustomRequestWrapper
和在拦截器中的HttpServletRequest
对象是不是一个,见图-1和图-2
也就是说在自定义的过滤器之后,其实传递的都是
CustomRequestWrapper
对象。这里需要说明一点,就是自定义的CustomRequestWrapper
中必须要重写getInputStream
和getReader
这两个方法(这两个方法都是返回的请求体),不然依然无法获取到请求体的内容。当然我觉得最好参考这个代码实现。
另外这里还遇到了一个关于Filter
的小问题,根据自定义的代码可以看出来我配置的过滤路径是/user/*
,但是实际在启动日志中却并不是这样的,如下:
可见我配置的过滤路径并没有生效,依然是过滤
/*
所有请求,虽然不影响功能的实现,但是我还是觉得还是根据具体需求来比较好。网上找资料说需要在启动类使用@ServletComponentScan
注解,指定basePackages
即自定义Filter
的包名或者使用basePackageClasses
指定具体的Filter
类即可,具体原因尚不清楚。
二、不同环境下服务启动
前面介绍了对接后的变更,这里还有个令人难受的问题,那就是生产环境和测试环境不同的问题,测试环境需要使用数据库,生产没有数据库。如果直接将现在代码部署到生产环境,服务是无法启动的,因为在服务启动过程会涉及到创建数据库链接,但是没有数据源。开始的想法是能不能在测试环境配置数据源,而在生产环境不配置,但是因为spring boot
是自动配置,那么可以禁用自动配置,即在测试环境使用自定义数据源配置,而在生产环境不指定。先按照生产环境代码优先的原则,先排除掉所有和数据库相关的自动配置,比如DataSourceAutoConfiguration
和HibernateJpaAutoConfiguration
,代码如下:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
@EnableDiscoveryClient
@ServletComponentScan(basePackageClasses = {CustomFilter.class})
public class NacosApplication {
public static void main(String[] args) {
SpringApplication.run(NacosApplication.class, args);
}
}
但是启动过程依然报错,因为创建RequestInterceptor
时需要依赖UserRepository
,但是因为排除了HibernateJpaAutoConfiguration
,所以无法创建UserRepository
,当时想着要不然直接使用JdbcTemplate
算了,虽然需自己写sql,这样就不会涉及到JPA的内容了。后来想起其实拦截器这部分代码只在测试环境使用,那问题就容易解决了,只要自定义拦截器和注册拦截器的配置类只在测试环境下创建就可以了,修改自定义拦截器和其注册配置类,代码如下:
@Slf4j
@Configuration
@Profile("dev")
public class InterceptorConfig implements WebMvcConfigurer {
@Autowired
private RequestInterceptor requestInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
log.info(">>>> registry interceptor start <<<<");
registry.addInterceptor(requestInterceptor).addPathPatterns("/user/**");
}
}
// 拦截器
@Slf4j
@Component
@Profile("dev")
public class RequestInterceptor implements HandlerInterceptor {
@Autowired
private UserRepository userRepository;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info(">>>> requestInterceptor preHandle method start <<<<");
CustomRequestWrapper requestWrapper = new CustomRequestWrapper(request);
String jsonRequest = new String(requestWrapper.getRequestBody(), Charset.forName("UTF-8"));
String requestParam = "";
if (StringUtils.isNotBlank(jsonRequest)) {
Map<String,String> requestMap = new Gson().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(new Gson().toJson(user));
return false;
}
}
return true;
}
}
当修改spring.profiles.active=prod
时启动服务,服务终于可以正常启动,日志如下:
可见启动日志中没有任何和数据库相关的内容,测试一下接口也是正常的。
但是改回
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环境启动服务,启动日志如下:
从上图可以看出启动过程中输出了
HikariPool
和Hibernate
相关日志,且调用接口也返回了测试数据,说明整个服务根据配置文件切换环境的功能完成了。
其实在解决这个问题的过程中也有同事建议我使用一个内存型的数据库,但是自己对这方面了解的比较少,因此没有按照他的思路去做,不知道可不可行。另外其实就功能实现上来讲我觉得使用JdbcTemplate
而不使用JPA应该也是可行的,但是我觉得尽量不动原来的代码比较好,所以还是按照原有方案解决了。
当然,实际工作中解决方案可能不止一种,感兴趣的话可以尝试下不同的解决方案,这样也可以加深对相关知识点的了解。
最后:自己在微信开了一个个人号:
超超学堂
,都是自己之前写过的一些文章,另外关注还有Java免费自学资料,欢迎大家关注。