# base-app **Repository Path**: redoing/base-app ## Basic Information - **Project Name**: base-app - **Description**: 记录Spring Boot 学习过程中遇到的各种通用处理方法 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2022-03-22 - **Last Updated**: 2022-09-07 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # base-app:基础应用 ### 参考文档 ## 问题描述 在学习Spring Boot过程中,经常遇到一些通用的步骤和处理方法。每次新建一个Spring Boot工程时都要重新配置一遍,因此萌生了编写一个base-app基础应用的想法,其目的有以下: * 记录Spring Boot 学习过程中各种配置的方法 * 加深已经学习过的常用的配置的印象 * 形成一个完整的学习体系和应用快照 ## 需求 本着记录的想法,该应用需要涉及到学习的各个流程,文档随开发进行更新。 - 拥有完整的开发记录 - 开发过程中详细的配置细节和注释 ## 概要设计 根据需求,该项目采用Spring Boot + myBatis-Plus+MySQL的设计,使用Git作为版本管理,项目托管在[ gitee base-app](https://gitee.com/zzhangleiz/base-app.git) ```sh mkdir base-app cd base-app git init touch README.md git add README.md git commit -m "first commit" git remote add origin https://gitee.com/zzhangleiz/base-app.git git push -u origin "master" ``` ## 详细设计 ### MyBatis-Plus 使用 #### pom.xml 配置 - 添加MyBatis-Pluas依赖 ```xml com.baomidou mybatis-plus-boot-starter 3.5.1 ``` #### application.yml 配置 - 配置MySQL链接和驱动 ```yml spring: application: name: BASE_APP datasource: driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root url: jdbc:mysql://localhost:3306/os_development ``` - 注意,使用MyBatis/MyBatis-Plus时需要将dao或者mapper包注册到启动类上 ```java @MapperScan(value = {"com.example.baseapp.business.mapper"}) @ComponentScan(value = {"com.example.baseapp.dao"}) //或者使用以下,并在对呀mapper类上加上@Mapper注解 @MapperScan(basePackages = {"com.biz1", "com.group2"}, annotationClass = Mapper.class) ``` ### 使用druid数据库连接池 druid[官方文档](https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter) #### pom.xml 配置 - 添加pom.xml依赖 ```xml com.alibaba druid-spring-boot-starter 1.2.8 com.alibaba druid 1.2.8 ``` #### application.yml 配置 - 使用starter,配置druid数据库连接池 ```yml druid: type: com.alibaba.druid.pool.DruidDataSource initialSize: 5 minIdle: 5 maxActive: 20 maxWait: 600000 timeBetweenEvictionRunsMillis: 600000 minEvictableIdleTimeMillis: 300000 validation-query: testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: true # 配置监控统计拦截器filters,去掉h后监控界面sql无法统计,‘wall’用于防火墙 filters: stat,wall,slf4j maxPoolPreparedStatementPerConnectionSize: 20 useGlobalDataSourceStat: true connectionProperties: druid.stat.mergeSql=true,druid.stat.slowSqlMillis=500 stat-view-servlet: # 需要账号密码才能访问控制台,默认为root login-username: admin login-password: admin # 是否启用StatViewServlet默认值true enabled: true # 访问路径为/druid时,跳转到StatViewServlet url-pattern: /druid/* # 是否能够重置数据 reset-enable: false # IP白名单 allow: 127.0.0.1 # IP黑名单(共同存在时,deny优先于allow) deny: ``` #### 使用druid依赖需要创建配置 ```java @Configuration public class DruidConfig { @ConfigurationProperties(prefix = "spring.datasource") @Bean public DataSource druid() { return new DruidDataSource(); } //配置Druid的监控 //1、配置一个管理后台的Servlet @Bean public ServletRegistrationBean statViewServlet() { ServletRegistrationBean bean = new ServletRegistrationBean(new StatViewServlet(), "/druid/*"); Map initParams = new HashMap<>(); initParams.put("loginUsername", "admin"); initParams.put("loginPassword", "123456"); initParams.put("allow", "");//默认就是允许所有访问 initParams.put("deny", "192.168.15.21"); bean.setInitParameters(initParams); return bean; } //2、配置一个web监控的filter @Bean public FilterRegistrationBean webStatFilter() { FilterRegistrationBean bean = new FilterRegistrationBean(); bean.setFilter(new WebStatFilter()); Map initParams = new HashMap<>(); initParams.put("exclusions", "*.js,*.css,/druid/*"); bean.setInitParameters(initParams); bean.setUrlPatterns(Arrays.asList("/*")); return bean; } } ``` 配置完成后访问http://ip:port/druid/spring.html查看是否成功! ### 日志使用 使用logback作为日志输出,具体配置如下: #### application.yml 配置 - 使用 logback-spring.xml ```yml logging: config: classpath:logback-spring.xml ``` - 调整日志级别为info ```yml logging: level: root: info #调整具体包日志级别 com: example: debug ``` #### logback-spring.xml 文件 - logback-spring.xml文件内容 ```xml logback debug ${CONSOLE_LOG_PATTERN} UTF-8 ${log.path}/web_debug.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n UTF-8 ${log.path}/web-debug-%d{yyyy-MM-dd}.%i.log 100MB 15 debug ACCEPT DENY ${log.path}/web_info.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n UTF-8 ${log.path}/web-info-%d{yyyy-MM-dd}.%i.log 100MB 15 info ACCEPT DENY ${log.path}/web_warn.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n UTF-8 ${log.path}/web-warn-%d{yyyy-MM-dd}.%i.log 100MB 15 warn ACCEPT DENY ${log.path}/web_error.log %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n UTF-8 ${log.path}/web-error-%d{yyyy-MM-dd}.%i.log 100MB 15 ERROR ACCEPT DENY ``` ### 统一返回格式 该部分参考[ java日知录-SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!](https://javadaily.cn/post/2022012758/2fed6f3dba49/) #### 定义返回标准格式 一个标准的返回格式应该包含3部分: 1. status 状态值:由后端统一定义各种返回结果的状态码 2. message 描述:本次接口调用的结果描述 3. data 数据:本次返回的数据 4. timestamp: 接口调用时间等 ```json { "status":"000000", "message":"操作成功", "data":"hello world" } ``` #### 定义返回对象 ```java package com.example.baseapp.common; import com.example.baseapp.common.enums.ResponseCode; import lombok.Data; import lombok.extern.slf4j.Slf4j; /** * Result class * * @author zhangl * @date 2022/3/23 11:42 */ @Slf4j @Data public class Result { private String status; private String message; private T responseData; private long timestamp; public Result() { this.timestamp = System.currentTimeMillis(); } public static Result success(T data) { Result result = new Result<>(); result.setResponseData(data); result.setStatus(ResponseCode.RC000000.getCode()); result.setMessage(ResponseCode.RC000000.getMessage()); log.info("Result :[{}]", result); return result; } public static Result fail(String code, String message) { Result result = new Result<>(); result.setStatus(code); result.setMessage(message); log.info("Result :[{}]", result); return result; } } ``` #### 定义返回状态码 ```java package com.example.baseapp.common.enums; /** * ResponeCode enum * * @author zhangl * @date 2022/3/23 11:47 */ public enum ResponseCode { /**操作成功**/ RC000000("000000", "操作成功"), /**操作失败**/ RC999999("999999", "操作失败"), /**服务限流**/ RC200("200", "服务开启限流保护,请稍后再试!"), /**服务降级**/ RC201("201", "服务开启降级保护,请稍后再试!"), /**热点参数限流**/ RC202("202", "热点参数限流,请稍后再试!"), /**系统规则不满足**/ RC203("203", "系统规则不满足要求,请稍后再试!"), /**授权规则不通过**/ RC204("204", "授权规则不通过,请稍后再试!"), /**access_denied**/ RC403("403", "无访问权限,请联系管理员授予权限"), /**access_denied**/ RC401("401", "匿名用户访问无权限资源时的异常"), /**服务异常**/ RC500("500", "系统异常,请稍后重试"), INVALID_TOKEN("2001", "访问令牌不合法"), ACCESS_DENIED("2003", "没有权限访问该资源"), CLIENT_AUTHENTICATION_FAILED("1001", "客户端认证失败"), USERNAME_OR_PASSWORD_ERROR("1002", "用户名或密码错误"), UNSUPPORTED_GRANT_TYPE("1003", "不支持的认证模式"); private final String code; private final String message; ResponseCode(String code, String message) { this.code = code; this.message = message; } public String getCode() { return code; } public String getMessage() { return message; } } ``` #### 一般实现方法 ##### 测试 ```java @GetMapping("hello") public Result hello(){ return Result.success("HELLO WORLD !"); } ``` 返回结果: ```json { "status": "000000", "message": "操作成功", "responseData": "HELLO WORLD !", "timestamp": 1648021474934 } ``` #### 高级实现方式 借助SpringBoot提供的`ResponseBodyAdvice`实现高级的实现机制 > ResponseBodyAdvice的作用:拦截Controller方法的返回值,统一处理返回值/响应体,一般用来统一返回格式,加解密,签名等等。 ##### 编写`ResponseBodyAdvice`实现类 ```java /** * ResponseAdvice class * * @author zhangl * @date 2022/3/23 15:52 */ @RestControllerAdvice public class ResponseAdvice implements ResponseBodyAdvice { @Resource private ObjectMapper objectMapper; @Override public boolean supports(MethodParameter returnType, Class> converterType) { return true; } @SneakyThrows @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body instanceof String){ return objectMapper.writeValueAsString(Result.success(body)); } return Result.success(body); } } ``` > `@RestControllerAdvice`是`@RestController`注解的增强,可以实现三个方面的功能: > > 1. 全局异常处理 > 2. 全局数据绑定 > 3. 全局数据预处理 ##### 测试 ```java @GetMapping("anotherHello") public String antherHello(){ return "Another HELLO WORLD !"; } ``` 返回结果: ```json { "status": "000000", "message": "操作成功", "responseData": "Another HELLO WORLD !", "timestamp": 1648022453114 } ``` 如果此时再请求开始实现的**一般实现方法**会出现以下结果: ```json { "status": "000000", "message": "操作成功", "responseData": { "status": "000000", "message": "操作成功", "responseData": "HELLO WORLD !", "timestamp": 1648022524482 }, "timestamp": 1648022524482 } ``` 显然这不是我们想要的结果。因此需要进行处理,修改`ResponseAdvice`类,添加如下代码: ```java @SneakyThrows @Override public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) { if(o instanceof String){ return objectMapper.writeValueAsString(Result.success(o)); } //关键代码,如果返回的类型是 Result ,则直接返回 if(o instanceof Result){ return o; } return Result.success(o); } } ``` 返回结果: ```json { "status": "000000", "message": "操作成功", "responseData": "HELLO WORLD !", "timestamp": 1648023783116 } ``` ### 异常拦截 #### 编写RestExceptionHandler类用于拦截异常 ```java /** * RestExceptionHandler class * * @author zhangl * @date 2022/3/23 16:07 */ @RestControllerAdvice public class RestExceptionHandler { /** * 默认处理全局异常 * @param e Exception * @return Result */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Result exception(Exception e){ return Result.fail(ResponseCode.RC500.getCode(),e.getMessage()); } } ``` > 1. `@RestControllerAdvice`,RestController的增强类,可用于实现全局异常处理器 > 2. `@ExceptionHandler`,统一处理某一类异常,从而减少代码重复率和复杂度,比如要获取自定义异常可以`@ExceptionHandler(BusinessException.class)` > 3. `@ResponseStatus`指定客户端收到的http状态码 #### 测试 ```java /** * 测试全局异常是否正常 */ @GetMapping("exception") public void exception(){ throw new RuntimeException("自定义异常"); } ``` 返回结果: ```json { "status": "500", "message": "自定义异常", "responseData": null, "timestamp": 1648023741882 } ``` ### 自定义注解校验 #### 0、引入Spring Validation ```xml org.springframework.boot spring-boot-starter-validation ``` Spring Validation 提供的注解基本上够用,但是面对复杂的定义,我们还是需要自己定义相关注解来实现自动校验 #### 1、创建自定义注解 ```java /** * UniqueUser interface * 校验用户是否存在,或唯一 * @author zhangl * @date 2022/3/23 17:06 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target({FIELD,METHOD,PARAMETER,TYPE}) @Constraint(validatedBy = UserValidation.UniqueUserValidator.class) public @interface UniqueUser { String message() default "用户已存在"; Class[] groups() default {}; Class[] payload() default {}; } ``` #### 2、自定义校验逻辑 ```java @Slf4j public class UserValidation implements ConstraintValidator { protected Predicate predicate = c -> true; @Resource protected UserService userService; @Override public boolean isValid(User user, ConstraintValidatorContext constraintValidatorContext) { log.warn("开始校验.... :{}",user); return userService == null || predicate.test(user); } public static class UniqueUserValidator extends UserValidation { @Override public void initialize(UniqueUser uniqueUser) { log.info("UniqueUser initialize ..."); predicate = c-> { log.info("UniqueUserValidator ... {}",c); boolean flag = !userService.existUser(c); log.debug("result : {}",flag); return flag; }; } } } ``` #### 3、在需要校验的类中使用注解 ```java @PostMapping("user") public User createUser(@UniqueUser @Valid @RequestBody UserReq userReq) { Date date = new Date(); User user = new User().setName(userReq.getName()) .setEmail(userReq.getEmail()) .setTel(userReq.getTel()); userMapper.insert(user); return user; } ``` > ==注意==:因为在自定义校验逻辑中限制了 `ConstraintValidator` ,因此可以使用 `constraintValidatorContext`自定义设置返回消息 ```java @Override public boolean isValid(MenuReq menu, ConstraintValidatorContext constraintValidatorContext) { log.debug("isValid"); if (menuService.getMenuByMenuId(menu.getMenuParentId()) == null){ //禁用默认值 constraintValidatorContext.disableDefaultConstraintViolation(); //重新赋值 constraintValidatorContext.buildConstraintViolationWithTemplate("上级菜单不存在") .addPropertyNode("menuParentId").addConstraintViolation(); predicate = c-> false; } return super.isValid(menu, constraintValidatorContext); } ``` #### 4、测试结果 ```json { "status": "500", "message": "createUser.userReq:用户已存在", "responseData": null, "timestamp": 1648094270605 } ``` ### 使用Filter实现请求和返回参数的打印 #### 编写LogFilter - 实现代理类RequestWrapper ```java class RequestWrapper extends HttpServletRequestWrapper { private final String body; public RequestWrapper(HttpServletRequest request) { super(request); StringBuilder stringBuilder = new StringBuilder(); BufferedReader bufferedReader = null; InputStream inputStream = null; try { inputStream = request.getInputStream(); if (inputStream != null) { bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); char[] charBuffer = new char[128]; int bytesRead = -1; while ((bytesRead = bufferedReader.read(charBuffer)) > 0) { stringBuilder.append(charBuffer, 0, bytesRead); } } } catch (IOException e) { log.warn("[{}]", e); } finally { try { if (inputStream != null) { inputStream.close(); } if (bufferedReader != null) { bufferedReader.close(); } } catch (IOException e) { log.warn("[{}]", e); } } body = stringBuilder.toString(); } @Override public ServletInputStream getInputStream() throws IOException { final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes()); 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(this.getInputStream())); } public String getBody() { return this.body; } } ``` - 实现代理类ResponseWrapper ```java class ResponseWrapper extends HttpServletResponseWrapper { private ByteArrayOutputStream buffer; private ServletOutputStream out; public ResponseWrapper(HttpServletResponse httpServletResponse) { super(httpServletResponse); buffer = new ByteArrayOutputStream(); out = new WrapperOutputStream(buffer); } @Override public ServletOutputStream getOutputStream() throws IOException { return out; } @Override public void flushBuffer() throws IOException { if (out != null) { out.flush(); } } public byte[] getContent() throws IOException { flushBuffer(); return buffer.toByteArray(); } class WrapperOutputStream extends ServletOutputStream { private ByteArrayOutputStream bos; public WrapperOutputStream(ByteArrayOutputStream bos) { this.bos = bos; } @Override public void write(int b) throws IOException { bos.write(b); } @Override public boolean isReady() { return false; } @Override public void setWriteListener(WriteListener arg0) { } } } ``` - 编写LogFilter实现Filter接口 详细代码如下: ```java @Slf4j @Configuration public class LogFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; ResponseWrapper wrapperResponse = new ResponseWrapper(httpServletResponse); RequestWrapper requestWrapper = new RequestWrapper(httpServletRequest); long before = System.currentTimeMillis(); log.info("[{}] ip: [{}{}] \nRequest Args--:]",httpServletRequest.getMethod(), httpServletRequest.getRemoteAddr(), httpServletRequest.getRequestURL().toString(), JSON.toJSON(requestWrapper.getBody())); filterChain.doFilter(requestWrapper, wrapperResponse); byte[] content = wrapperResponse.getContent(); log.info("Response Args-->: {}", JSON.toJSON(new String(content))); log.info("Time-Consuming->: {} ms", System.currentTimeMillis() - before); //把返回值输出到客户端 ServletOutputStream out = httpServletResponse.getOutputStream(); out.write(content); out.flush(); } } ``` ### 使用AOP切面实现 ```java /** * WebLogAspect class * 使用切面实现 * @author zhangl * @date 2022/3/24 20:58 */ @Slf4j @Aspect @Component public class WebLogAspect { /** * 以 controller 包下定义的所有请求为切入点 */ @Pointcut("execution(public * com.example.baseapp.business.controller.*.*(..))") public void webLog(){} /** * 在切点之前织入 * @param joinPoint * */ @Before("webLog()") public void doBefore(JoinPoint joinPoint){ // 开始打印请求日志 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); // 打印请求 url log.info("URL : {}", request.getRequestURL().toString()); // 打印 Http method log.info("HTTP Method : {}", request.getMethod()); // 打印调用 controller 的全路径以及执行方法 log.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName()); // 打印请求的 IP log.info("IP : {}", request.getRemoteAddr()); // 打印请求入参 log.info("Request Args : {}", joinPoint.getArgs()); } /** * 在切点之后织入 * @throws Throwable */ @After("webLog()") public void doAfter() throws Throwable { } /** * 环绕 * @param proceedingJoinPoint * @return * @throws Throwable */ @Around("webLog()") public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { long startTime = System.currentTimeMillis(); Object result = proceedingJoinPoint.proceed(); // 打印出参 log.info("Response Args : {}", JSON.toJSON(result)); // 执行耗时 log.info("Time-Consuming : {} ms", System.currentTimeMillis() - startTime); return result; } } ```