天天看點

SpringBoot項目基礎設施搭建

作者:Java熱點
SpringBoot項目基礎設施搭建

前言

準确點說,這不是《從零打造項目》系列的第一篇文章,模版代碼生成的那個項目講解算是第一篇,當時就打算做一套項目腳手架,為後續進行項目練習做準備。因時間及個人經驗問題,一直拖到現在才繼續實施該計劃,希望這次能順利完成。

每個項目中都會有一些共用的代碼,我們稱之為項目的基礎設施,随拿随用。本文主要介紹 SpringBoot 項目中的一些基礎設施,後續還會詳細介紹 SpringBoot 分别結合 Mybatis、MybatisPlus、JPA 這三種 ORM 架構進行項目搭建,加深大家對項目的掌握能力。

因内容篇幅過長,本來這些基礎設施代碼應該分布在未來的三篇文章中,被提取出來,專門寫一篇文章來介紹。

SpringBoot項目基礎代碼

引入依賴

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.6.3</version>
</parent>

<properties>
  <java.version>1.8</java.version>
  <fastjson.version>1.2.73</fastjson.version>
  <hutool.version>5.5.1</hutool.version>
  <mysql.version>8.0.19</mysql.version>
  <mybatis.version>2.1.4</mybatis.version>
  <mapper.version>4.1.5</mapper.version>
  <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
  <org.projectlombok.version>1.18.20</org.projectlombok.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>${fastjson.version}</version>
  </dependency>
  <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>${hutool.version}</version>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${org.projectlombok.version}</version>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-commons</artifactId>
    <version>2.4.6</version>
  </dependency>
  <dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-ui</artifactId>
    <version>1.6.9</version>
  </dependency>
  <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.18</version>
  </dependency>

  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>
  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>
複制代碼           

有些依賴不一定是最新版本,而且你看到這篇文章時,可能已經釋出了新版本,到時候可以先模仿着将項目跑起來後,再根據自己的需求來更新各項依賴,有問題咱再解決問題。

日志請求切面

項目進入聯調階段,服務層的接口需要和協定層進行互動,協定層需要将入參[json字元串]組裝成服務層所需的 json 字元串,組裝的過程中很容易出錯。入參出錯導緻接口調試失敗問題在聯調中出現很多次,是以就想寫一個請求日志切面把入參資訊列印一下,同時協定層調用服務層接口名稱對不上也出現了幾次,通過請求日志切面就可以知道上層是否有沒有發起調用,友善前後端甩鍋還能拿出證據。

首先定義一個請求日志類,記錄一些關鍵資訊。

@Data
@EqualsAndHashCode(callSuper = false)
public class RequestLog {

  // 請求ip
  private String ip;
  // 通路url
  private String url;
  // 請求類型
  private String httpMethod;
  // 請求方法名(絕對路徑)
  private String classMethod;
  // 請求方法描述
  private String methodDesc;
  // 請求參數
  private Object requestParams;
  // 傳回結果
  private Object result;
  // 操作時間
  private Long operateTime;
  // 消耗時間
  private Long timeCost;
  // 錯誤資訊
  private JSONObject errorMessage;
}
複制代碼           

然後根據 @Aspect 實作日志切面記錄

@Component
@Aspect
@Slf4j
public class RequestLogAspect {

  @Pointcut("execution(* com.msdn.orm.hresh.controller..*(..))")
  public void requestServer() {
  }

  @Around("requestServer()")
  public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    long start = System.currentTimeMillis();
    //擷取目前請求對象
    RequestLog requestLog = getRequestLog();

    Object result = proceedingJoinPoint.proceed();
    Signature signature = proceedingJoinPoint.getSignature();
    // 請求方法名(絕對路徑)
    requestLog.setClassMethod(String.format("%s.%s", signature.getDeclaringTypeName(),
        signature.getName()));
    // 請求參數
    requestLog.setRequestParams(getRequestParamsByProceedingJoinPoint(proceedingJoinPoint));
    // 傳回結果
    requestLog.setResult(result);
    // 如果傳回結果不為null,則從傳回結果中剔除傳回資料,檢視條目數、傳回狀态和傳回資訊等
    if (!ObjectUtils.isEmpty(result)) {
      JSONObject jsonObject = JSONUtil.parseObj(result);
      Object data = jsonObject.get("data");
      if (!ObjectUtils.isEmpty(data) && data.toString().length() > 200) {
        // 減少日志記錄量,比如大量查詢結果,沒必要記錄
        jsonObject.remove("data");
        requestLog.setResult(jsonObject);
      }
    }

    // 擷取請求方法的描述注解資訊
    MethodSignature methodSignature = (MethodSignature) signature;
    Method method = methodSignature.getMethod();
    if (method.isAnnotationPresent(Operation.class)) {
      Operation methodAnnotation = method.getAnnotation(Operation.class);
      requestLog.setMethodDesc(methodAnnotation.description());
    }
    // 消耗時間
    requestLog.setTimeCost(System.currentTimeMillis() - start);

    log.info("Request Info      : {}", JSONUtil.toJsonStr(requestLog));
    return result;
  }

  @AfterThrowing(pointcut = "requestServer()", throwing = "e")
  public void doAfterThrow(JoinPoint joinPoint, RuntimeException e) {
    try {
      RequestLog requestLog = getRequestLog();

      Signature signature = joinPoint.getSignature();
      // 請求方法名(絕對路徑)
      requestLog.setClassMethod(String.format("%s.%s", signature.getDeclaringTypeName(),
          signature.getName()));
      // 請求參數
      requestLog.setRequestParams(getRequestParamsByJoinPoint(joinPoint));
      StackTraceElement[] stackTrace = e.getStackTrace();
      // 将異常資訊轉換成json
      JSONObject jsonObject = new JSONObject();
      if (!ObjectUtils.isEmpty(stackTrace)) {
        StackTraceElement stackTraceElement = stackTrace[0];
        jsonObject = JSONUtil.parseObj(JSONUtil.toJsonStr(stackTraceElement));
        // 轉換成json
        jsonObject.set("errorContent", e.getMessage());
        jsonObject.set("createTime", DateUtil.date());
        jsonObject.setDateFormat(DatePattern.NORM_DATETIME_PATTERN);
        jsonObject.set("messageId", IdUtil.fastSimpleUUID());
        // 擷取IP位址
        jsonObject.set("serverIp", NetUtil.getLocalhostStr());
      }
      requestLog.setErrorMessage(jsonObject);
      log.error("Error Request Info      : {}", JSONUtil.toJsonStr(requestLog));
    } catch (Exception exception) {
      log.error(exception.getMessage());
    }
  }

  private RequestLog getRequestLog() {
    //擷取目前請求對象
    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
        .getRequestAttributes();
    // 記錄請求資訊(通過Logstash傳入Elasticsearch)
    RequestLog requestLog = new RequestLog();
    if (!ObjectUtils.isEmpty(attributes) && !ObjectUtils.isEmpty(attributes.getRequest())) {
      HttpServletRequest request = attributes.getRequest();
      // 請求ip
      requestLog.setIp(request.getRemoteAddr());
      // 通路url
      requestLog.setUrl(request.getRequestURL().toString());
      // 請求類型
      requestLog.setHttpMethod(request.getMethod());
    }
    return requestLog;
  }

  /**
   * 根據方法和傳入的參數擷取請求參數
   *
   * @param proceedingJoinPoint 入參
   * @return 傳回
   */
  private Map<String, Object> getRequestParamsByProceedingJoinPoint(
      ProceedingJoinPoint proceedingJoinPoint) {
    //參數名
    String[] paramNames = ((MethodSignature) proceedingJoinPoint.getSignature())
        .getParameterNames();
    //參數值
    Object[] paramValues = proceedingJoinPoint.getArgs();

    return buildRequestParam(paramNames, paramValues);
  }

  private Map<String, Object> getRequestParamsByJoinPoint(JoinPoint joinPoint) {
    try {
      //參數名
      String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
      //參數值
      Object[] paramValues = joinPoint.getArgs();

      return buildRequestParam(paramNames, paramValues);
    } catch (Exception e) {
      return new HashMap<>();
    }
  }

  private Map<String, Object> buildRequestParam(String[] paramNames, Object[] paramValues) {
    try {
      Map<String, Object> requestParams = new HashMap<>(paramNames.length);
      for (int i = 0; i < paramNames.length; i++) {
        Object value = paramValues[i];

        //如果是檔案對象
        if (value instanceof MultipartFile) {
          MultipartFile file = (MultipartFile) value;
          //擷取檔案名
          value = file.getOriginalFilename();
        }

        requestParams.put(paramNames[i], value);
      }

      return requestParams;
    } catch (Exception e) {
      return new HashMap<>(1);
    }
  }
}
複制代碼           

上述切面是在執行 Controller 方法時,列印出調用方IP、請求URL、HTTP 請求類型、調用的方法名、耗時等。

除了上述這種形式進行日志記錄,還可以自定義注解,

@Target({ElementType.PARAMETER, ElementType.METHOD})//作用于參數或方法上
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SystemLog {

  /**
         * 日志描述
         * @return
         */
  String description() default "";

}
複制代碼           

具體使用為:

@GetMapping(value = "/queryPage")
@Operation(description = "擷取使用者分頁清單")
@SystemLog(description = "擷取使用者分頁清單")
public Result<PageResult<UserVO>> queryPage(
  @RequestBody UserQueryPageDTO dto) {
  Page<UserVO> userVOPage = userService.queryPage(dto);
  return Result.ok(PageResult.ok(userVOPage));
}
複制代碼           

我們隻需要修改一下 RequestLogAspect 檔案中的 requestServer()方法

@Pointcut("@annotation(com.xxx.annotation.SystemLog)")
  public void requestServer() {
  }
複制代碼           

除了友善前後端排查問題,健壯的項目還會做日志分析,這裡介紹一種我了解的日志分析系統——ELK(ELasticsearch+Logstash+Kibana),在 RequestLogAspect 檔案中可以将日志資訊輸出到 ELK 上,本項目不做過多介紹。

除了日志分析,還有一種玩法,如果項目比較複雜,比如說分布式項目,微服務個數過多,一次請求往往需要涉及到多個服務,這樣一來,調用鍊路就會很複雜,一旦出現故障,如何快速定位問題需要考慮。一種解決方案就是在日志記錄時增加一個 traceId 字段,一條調用鍊路上的 traceId 是相同。

全局異常

在日常項目開發中,異常是常見的,雖然 SpringBoot 對于異常有自己的處理方案,但是對于開發人員不夠友好。我們想要友好地抛出異常,針對運作時異常,想要一套全局異常捕獲手段。是以如何處理好異常資訊,對我們後續開發至關重要。

1、定義基礎接口類

public interface IError {
    /**
     * 錯誤碼
     */
    String getResultCode();

    /**
     * 錯誤描述
     */
    String getResultMsg();
}
複制代碼           

2、異常枚舉類

public enum ExceptionEnum implements IError {
    // 資料操作狀态碼和提示資訊定義
    SUCCESS("200", "操作成功"),
    VALIDATE_FAILED("400", "參數檢驗失敗"),
    NOT_FOUND("404", "參數檢驗失敗"),
    UNAUTHORIZED("401", "暫未登入或token已經過期"),
    FORBIDDEN("403", "沒有相關權限"),
    REQUEST_TIME_OUT("408", "請求時間逾時"),
    INTERNAL_SERVER_ERROR("500", "伺服器内部錯誤!"),
    SERVER_BUSY("503", "伺服器正忙,請稍後再試!");
    /**
     * 錯誤碼
     */
    private String resultCode;

    /**
     * 錯誤描述
     */
    private String resultMsg;

    private ExceptionEnum(String resultCode, String resultMsg) {
        this.resultCode = resultCode;
        this.resultMsg = resultMsg;
    }


    @Override
    public String getResultCode() {
        return resultCode;
    }

    @Override
    public String getResultMsg() {
        return resultMsg;
    }
}
複制代碼           

3、自定義業務異常類

public class BusinessException extends RuntimeException {

  /**
   * 錯誤碼
   */
  private String errorCode;

  /**
   * 錯誤描述
   */
  private String errorMsg;

  public BusinessException() {
    super();
  }

  public BusinessException(IError error) {
    super(error.getResultCode());
    this.errorCode = error.getResultCode();
    this.errorMsg = error.getResultMsg();
  }

  public BusinessException(IError error, Throwable cause) {
    super(error.getResultCode(), cause);
    this.errorCode = error.getResultCode();
    this.errorMsg = error.getResultMsg();
  }

  public BusinessException(String message) {
    super(message);
  }

  public BusinessException(String errorCode, String errorMsg) {
    super(errorCode);
    this.errorCode = errorCode;
    this.errorMsg = errorMsg;
  }

  public BusinessException(String errorCode, String errorMsg, Throwable cause) {
    super(errorCode, cause);
    this.errorCode = errorCode;
    this.errorMsg = errorMsg;
  }

  public BusinessException(Throwable cause) {
    super(cause);
  }

  public BusinessException(String message, Throwable cause) {
    super(message, cause);
  }

  public static void validateFailed(String message) {
    throw new BusinessException(ExceptionEnum.VALIDATE_FAILED.getResultCode(), message);
  }

  public static void fail(String message) {
    throw new BusinessException(message);
  }

  public static void fail(IError error) {
    throw new BusinessException(error);
  }

  public static void fail(String errorCode, String errorMsg) {
    throw new BusinessException(errorCode, errorMsg);
  }
}
複制代碼           

4、全局異常處理類

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

  /**
   * 處理自定義的api異常
   *
   * @param e
   * @return
   */
  @ResponseBody
  @ExceptionHandler(value = BusinessException.class)
  public Result handle(BusinessException e) {
    if (Objects.nonNull(e.getErrorCode())) {
      log.error("發生業務異常!原因是:{}", e.getErrorMsg());
      return Result.failed(e.getErrorCode(), e.getErrorMsg());
    }
    return Result.failed(e.getMessage());
  }

  /**
   * 處理參數驗證失敗異常 基于json格式的資料傳遞,這種傳遞才會抛出MethodArgumentNotValidException異常
   *
   * @param e
   * @return
   */
  @ResponseBody
  @ExceptionHandler(value = MethodArgumentNotValidException.class)
  public Result handleValidException(MethodArgumentNotValidException e) {
    BindingResult bindingResult = e.getBindingResult();
    String message = null;
    if (bindingResult.hasErrors()) {
      FieldError fieldError = bindingResult.getFieldError();
      if (Objects.nonNull(fieldError)) {
        message = fieldError.getField() + fieldError.getDefaultMessage();
      }
    }
    return Result.validateFailed(message);
  }

  /**
   * 使用@Validated 來校驗 JavaBean的參數,比如@NotNull、@NotBlank等等; post 請求資料傳遞有兩種方式,一種是基于form-data格式的資料傳遞,這種傳遞才會抛出BindException異常
   *
   * @param e
   * @return
   */
  @ResponseBody
  @ExceptionHandler(value = BindException.class)
  public Result handleValidException(BindException e) {
    BindingResult bindingResult = e.getBindingResult();
    String message = null;
    if (bindingResult.hasErrors()) {
      FieldError fieldError = bindingResult.getFieldError();
      if (fieldError != null) {
        message = fieldError.getField() + fieldError.getDefaultMessage();
      }
    }
    return Result.validateFailed(message);
  }
}
複制代碼           

統一傳回格式

目前比較流行的是基于 json 格式的資料互動。但是 json 隻是消息的格式,其中的内容還需要我們自行設計。不管是 HTTP 接口還是 RPC 接口保持傳回值格式統一很重要,這将大大降低 client 的開發成本。

定義傳回值四要素

  • boolean success ;是否成功。
  • T data ;成功時具體傳回值,失敗時為 null 。
  • String code ;成功時傳回 200 ,失敗時傳回具體錯誤碼。
  • String message ;成功時傳回 null ,失敗時傳回具體錯誤消息。

傳回對象中會處理分頁結果,普通的查詢結果,異常等資訊。

@Data
@NoArgsConstructor
public class Result<T> implements Serializable {

  private T data;
  private String code;
  private String message;
  private boolean success;

  protected Result(String code, String message, T data) {
    this.code = code;
    this.message = message;
    this.data = data;
    this.success = true;
  }

  protected Result(String code, String message, T data, boolean success) {
    this(code, message, data);
    this.success = success;
  }

  public static <T> Result<T> ok() {
    return ok((T) null);
  }

  /**
   * 成功傳回結果
   *
   * @param data 擷取的資料
   * @return
   */
  public static <T> Result<T> ok(T data) {
    return new Result<>(ExceptionEnum.SUCCESS.getResultCode(),
        ExceptionEnum.SUCCESS.getResultMsg(), data);
  }

  /**
   * 成功傳回list結果
   *
   * @param list 擷取的資料
   * @return
   */
  public static <T> Result<List<T>> ok(List<T> list) {
    Result<List<T>> listResult = new Result<>(ExceptionEnum.SUCCESS.getResultCode(),
        ExceptionEnum.SUCCESS.getResultMsg(), list);
    return listResult;
  }

  /**
   * 成功傳回結果
   *
   * @param data    擷取的資料
   * @param message 提示資訊
   */
  public static <T> Result<T> ok(T data, String message) {
    return new Result<>(ExceptionEnum.SUCCESS.getResultCode(), message, data);
  }

  /**
   * 失敗傳回結果
   *
   * @param error 錯誤碼
   */
  public static <T> Result<T> failed(IError error) {
    return new Result<>(error.getResultCode(), error.getResultMsg(), null, false);
  }

  /**
   * 失敗傳回結果
   *
   * @param error   錯誤碼
   * @param message 錯誤資訊
   */
  public static <T> Result<T> failed(IError error, String message) {
    return new Result<>(error.getResultCode(), message, null, false);
  }

  /**
   * 失敗傳回結果
   *
   * @param errorCode 錯誤碼
   * @param message   錯誤資訊
   */
  public static <T> Result<T> failed(String errorCode, String message) {
    return new Result<>(errorCode, message, null, false);
  }

  /**
   * 失敗傳回結果
   *
   * @param message 提示資訊
   */
  public static <T> Result<T> failed(String message) {
    return new Result<>(ExceptionEnum.INTERNAL_SERVER_ERROR.getResultCode(), message, null, false);
  }

  /**
   * 失敗傳回結果
   */
  public static <T> Result<T> failed() {
    return failed(ExceptionEnum.INTERNAL_SERVER_ERROR);
  }

  /**
   * 參數驗證失敗傳回結果
   */
  public static <T> Result<T> validateFailed() {
    return failed(ExceptionEnum.VALIDATE_FAILED);
  }

  /**
   * 參數驗證失敗傳回結果
   *
   * @param message 提示資訊
   */
  public static <T> Result<T> validateFailed(String message) {
    return new Result<>(ExceptionEnum.VALIDATE_FAILED.getResultCode(), message, null, false);
  }

  /**
   * 未登入傳回結果
   */
  public static <T> Result<T> unauthorized(T data) {
    return new Result<>(ExceptionEnum.UNAUTHORIZED.getResultCode(),
        ExceptionEnum.UNAUTHORIZED.getResultMsg(), data, false);
  }

  /**
   * 未授權傳回結果
   */
  public static <T> Result<T> forbidden(T data) {
    return new Result<>(ExceptionEnum.FORBIDDEN.getResultCode(),
        ExceptionEnum.FORBIDDEN.getResultMsg(), data, false);
  }

  @Override
  public String toString() {
    return toJSONString(this);
  }
}
複制代碼           

對象類型轉換

在項目中,尤其是在服務層,經常要将服務中的 Dto 實體對象轉換為 Entity 對象,以及将 Entity 對象轉換為 VO 對象傳回給前端展示。現在市面上有很多這樣的工具包,比如 Spring 架構中就自帶了 BeanUtils,使我們進行這樣的資料操作十分簡單快捷,但當資料量級特别大時,存在性能問題。是以我們要選擇一款優秀的工具——Mapstruct。

關于 Mapstruct 的介紹以及其他對象轉換工具,可以參考這兩篇文章:Apache的BeanUtils、Spring的BeanUtils、Mapstruct、BeanCopier對象拷貝 和 MapStruct 才是王者

定義如下對象類型轉換檔案:

@Mapper(componentModel = "spring")
public interface UserStruct {

    @Mapping(target = "jobVOS",source = "jobs")
    UserVO modelToVO(User record);

    @Mapping(target = "jobVOS",source = "jobs")
    List<UserVO> modelToVO(List<User> records);

    User voToModel(UserVO record);

    List<User> voToModel(List<UserVO> records);

    UserDTO modelToDTO(User record);

    List<UserDTO> modelToDTO(List<User> records);

    User dtoToModel(UserDTO record);

    List<User> dtoToModel(List<UserDTO> records);
}
複制代碼           

如果對象中的屬性名不同,可以使用 @Mapping 注解進行聲明,自動生成的 UserStructImpl.class 如下所示,這裡隻展示部分代碼。

@Component
public class UserStructImpl implements UserStruct {

  @Override
  public UserVO modelToVO(User record) {
    if ( record == null ) {
      return null;
    }

    UserVO userVO = new UserVO();

    userVO.setJobVOS( jobListToJobVOList( record.getJobs() ) );
    userVO.setName( record.getName() );
    userVO.setAge( record.getAge() );
    userVO.setAddress( record.getAddress() );

    return userVO;
  }

  protected JobVO jobToJobVO(Job job) {
    if ( job == null ) {
      return null;
    }

    JobVO jobVO = new JobVO();

    jobVO.setName( job.getName() );
    jobVO.setAddress( job.getAddress() );

    return jobVO;
  }

  protected List<JobVO> jobListToJobVOList(List<Job> list) {
    if ( list == null ) {
      return null;
    }

    List<JobVO> list1 = new ArrayList<JobVO>( list.size() );
    for ( Job job : list ) {
      list1.add( jobToJobVO( job ) );
    }

    return list1;
  }

  //.......
}
複制代碼           

分組校驗和自定義校驗

@Validation是一套幫助我們繼續對傳輸的參數進行資料校驗的注解,通過配置 Validation 可以很輕松的完成對資料的限制。

@Validated作用在類、方法和參數上

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
    Class<?>[] value() default {};
}
複制代碼           

在項目中我們可能會遇到這樣的場景:新增資料時某些字段需要進行判空校驗,而修改資料時又需要校驗另外一些字段,而且都是用同一個對象來封裝這些字段,為了便于管理及代碼的優雅,我們決定引入分組校驗。

建立分組,區分新增和編輯以及其它情況下的參數校驗。

public interface ValidateGroup {

  /**
   * 新增
   */
  interface Add extends Default {

  }

  /**
   * 删除
   */
  interface Delete {

  }

  /**
   * 編輯
   */
  interface Edit extends Default {

  }
}
複制代碼           

除了分組校驗,validation 還允許我們自定義校驗器。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Constraint(validatedBy = EnumValidatorClass.class)
public @interface EnumValidator {

  String[] value() default {};

  boolean required() default true;

  // 校驗枚舉值不存在時的報錯資訊
  String message() default "enum is not found";

  //将validator進行分類,不同的類group中會執行不同的validator操作
  Class<?>[] groups() default {};

  //主要是針對bean,很少使用
  Class<? extends Payload>[] payload() default {};
}
複制代碼           

其中 EnumValidatorClass 類主要是為了校驗 EnumValidator 注解的,代碼如下:

public class EnumValidatorClass implements ConstraintValidator<EnumValidator, Integer> {

  private String[] values;

  @Override
  public void initialize(EnumValidator enumValidator) {
    this.values = enumValidator.value();
  }

  @Override
  public boolean isValid(Integer value, ConstraintValidatorContext constraintValidatorContext) {
    boolean isValid = false;
    if (value == null) {
      //當狀态為空時使用預設值
      return true;
    }
    for (int i = 0; i < values.length; i++) {
      if (values[i].equals(String.valueOf(value))) {
        isValid = true;
        break;
      }
    }
    return isValid;
  }
}
複制代碼           

後續項目實踐過程中會示範具體使用。

Liquibase

Liquibase 是一個用于跟蹤、管理和應用資料庫變化的開源的資料庫重構工具。它将所有資料庫的變化(包括結構和資料)都儲存在 changelog 檔案中,便于版本控制,它的目标是提供一種資料庫類型無關的解決方案,通過執行 schema 類型的檔案來達到遷移。

目标:

Liquibase 實施端到端CI / CD要求将所有代碼(包括資料庫代碼)檢入版本控制系統,并作為軟體釋出過程的一部分進行部署。

1、引入依賴

<dependency>
  <groupId>org.liquibase</groupId>
  <artifactId>liquibase-core</artifactId>
  <version>4.16.1</version>
</dependency>
複制代碼           

2、application.yml 配置

spring:
  liquibase:
    enabled: true
    change-log: classpath:liquibase/master.xml
    # 記錄版本日志表
    database-change-log-table: databasechangelog
    # 記錄版本改變lock表
    database-change-log-lock-table: databasechangeloglock
複制代碼           

3、resource 目錄下建立 master.xml 和 changelog 目錄

SpringBoot項目基礎設施搭建
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">

  <includeAll path="src/main/resources/liquibase/changelog"/>

</databaseChangeLog>
複制代碼           

4、運作項目,資料庫中會生成如下兩張表:

  • DATABASECHANGELOG 表
  • DATABASECHANGELOGLOCK表

因為 yaml 檔案中的配置,實際生成的表名為小寫格式。

接下來該研究如何使用 liquibase 了,如果項目所連接配接的資料庫中目前沒有一個表,那麼你可以在網上找一下 changeset 的書寫格式,然後模仿着來建表。如果資料庫中有表,可以先執行 liquibase:generateChangeLog 指令,生成一份現有表的建表語句,檔案輸出路徑既可以在 yaml 檔案中添加,然後在 pom 檔案中讀取 yaml 檔案;也可以直接在 pom 檔案中添加。

#輸出檔案路徑配置
outputChangeLogFile: src/main/resources/liquibase/out/out.xml
複制代碼           

pom.xml

<plugin>
  <groupId>org.liquibase</groupId>
  <artifactId>liquibase-maven-plugin</artifactId>
  <version>4.16.1</version>
  <configuration>
    <!--properties檔案路徑,該檔案記錄了資料庫連接配接資訊等-->
    <propertyFile>src/main/resources/application.yml</propertyFile>
    <propertyFileWillOverride>true</propertyFileWillOverride>
    <!--生成檔案的路徑-->
    <!--          <outputChangeLogFile>src/main/resources/liquibase/out/out.xml</outputChangeLogFile>-
  </configuration>
</plugin>
複制代碼           

如果之後想要添加新表,則隻需要在 liquibase/changelog 目錄下建立好對應的 xml 檔案,比如這個:

<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
  xmlns:pro="http://www.liquibase.org/xml/ns/pro"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
  <changeSet author="hresh" id="1664204549485-7">
    <createTable remarks="使用者" tableName="user">
      <column name="id" type="VARCHAR(36)">
        <constraints nullable="false" primaryKey="true"/>
      </column>
      <column name="name" type="VARCHAR(20)">
        <constraints unique="true"/>
      </column>
      <column name="age" type="INT"/>
      <column name="address" type="VARCHAR(100)"/>
      <column name="created_date" type="timestamp"/>
      <column name="last_modified_date" type="timestamp"/>
      <column defaultValueBoolean="false" name="del_flag" type="BIT(1)">
        <constraints nullable="false"/>
      </column>
      <column name="create_user_code" type="VARCHAR(36)"/>
      <column name="create_user_name" type="VARCHAR(50)"/>
      <column name="last_modified_code" type="VARCHAR(36)"/>
      <column name="last_modified_name" type="VARCHAR(50)"/>
      <column defaultValueNumeric="1" name="version" type="INT">
        <constraints nullable="false"/>
      </column>
    </createTable>
  </changeSet>
</databaseChangeLog>
複制代碼           

目前項目 resource 目錄結構如下:

SpringBoot項目基礎設施搭建

隻需要運作該項目,就會處理 user.xml 中的 changeSet,并在資料庫中生成 user 表,并且在 databasechangelog 中插入一條記錄,重複運作項目時,會判斷 changeSetId 避免重複插入。

為了更好的使用 liquibase,比如說通過指令行來生成一個 changelog 模版,最好能記錄下建立時間,然後我們隻需要修改裡面的内容即可。

為了滿足該需求,則需要自定義自定義 Maven 插件。

自定義Maven插件

建立一個 maven 項目 liquibase-changelog-generate,本項目具備生成 xml 和 yaml 兩種格式的 changelog,個人覺得 yaml 格式的 changelog 可讀性更高。

1、定義一個接口,提前準備好公用代碼,主要是判斷 changelog id 是否有非法字元,并且生成 changelog name。

public interface LiquibaseChangeLog {

  default String getChangeLogFileName(String sourceFolderPath) {
    System.out.println("> Please enter the id of this change:");
    Scanner scanner = new Scanner(System.in);
    String changeId = scanner.nextLine();
    if (StrUtil.isBlank(changeId)) {
      return null;
    }

    String changeIdPattern = "^[a-z][a-z0-9_]*#34;;
    Pattern pattern = Pattern.compile(changeIdPattern);
    Matcher matcher = pattern.matcher(changeId);
    if (!matcher.find()) {
      System.out.println("Change id should match " + changeIdPattern);
      return null;
    }

    if (isExistedChangeId(changeId, sourceFolderPath)) {
      System.out.println("Duplicate change id :" + changeId);
      return null;
    }

    Date now = new Date();
    String timestamp = DateUtil.format(now, "yyyyMMdd_HHmmss_SSS");
    return timestamp + "__" + changeId;
  }

  default boolean isExistedChangeId(String changeId, String sourceFolderPath) {
    File file = new File(sourceFolderPath);
    File[] files = file.listFiles();
    if (null == files) {
      return false;
    }

    for (File f : files) {
      if (f.isFile()) {
        if (f.getName().contains(changeId)) {
          return true;
        }
      }
    }
    return false;
  }
}
複制代碼           

2、每個 changelog 檔案中的 changeSet 都有一個 author 屬性,用來标注是誰建立的 changelog,目前我的做法是執行終端指令來擷取 git 的 userName,如果有更好的實作,望不吝賜教。

public class GitUtil {

  public static String getGitUserName() {
    try {
      String cmd = "git config user.name";
      Process p = Runtime.getRuntime().exec(cmd);
      InputStream is = p.getInputStream();
      BufferedReader reader = new BufferedReader(new InputStreamReader(is));
      String line = reader.readLine();
      p.waitFor();
      is.close();
      reader.close();
      p.destroy();
      return line;
    } catch (IOException | InterruptedException e) {
      e.printStackTrace();
    }
    return "hresh";
  }
}
複制代碼           

3、生成 xml 格式的 changelog

@Mojo(name = "generateModelChangeXml", defaultPhase = LifecyclePhase.PACKAGE)
public class LiquibaseChangeLogXml extends AbstractMojo implements LiquibaseChangeLog {

  // 配置的是本maven插件的配置,在pom使用configration标簽進行配置 property就是名字,
  // 在配置裡面的标簽名字。在調用該插件的時候會看到
  @Parameter(property = "sourceFolderPath")
  private String sourceFolderPath;

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    System.out.println("Create a new empty model changelog in liquibase yaml file.");
    String userName = GitUtil.getGitUserName();

    String changeLogFileName = getChangeLogFileName(sourceFolderPath);
    if (StrUtil.isNotBlank(changeLogFileName)) {
      generateXmlChangeLog(changeLogFileName, userName);
    }
  }

  private void generateXmlChangeLog(String changeLogFileName, String userName) {
    String changeLogFileFullName = changeLogFileName + ".xml";
    File file = new File(sourceFolderPath, changeLogFileFullName);
    String content = "<?xml version=\"1.1\" encoding=\"UTF-8\" standalone=\"no\"?>\n"
        + "<databaseChangeLog xmlns=\"http://www.liquibase.org/xml/ns/dbchangelog\"\n"
        + "  xmlns:ext=\"http://www.liquibase.org/xml/ns/dbchangelog-ext\"\n"
        + "  xmlns:pro=\"http://www.liquibase.org/xml/ns/pro\"\n"
        + "  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n"
        + "  xsi:schemaLocation=\"http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd\">\n"
        + "  <changeSet author=\" " + userName + "\" id=\"" + changeLogFileName + "\">\n"
        + "  </changeSet>\n"
        + "</databaseChangeLog>";
    try {
      FileWriter fw = new FileWriter(file.getAbsoluteFile());
      BufferedWriter bw = new BufferedWriter(fw);
      bw.write(content);
      bw.close();
      fw.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

}
複制代碼           

4、生成 yaml 格式的 changelog

@Mojo(name = "generateModelChangeYaml", defaultPhase = LifecyclePhase.PACKAGE)
public class LiquibaseChangeLogYaml extends AbstractMojo implements LiquibaseChangeLog {

  // 配置的是本maven插件的配置,在pom使用configration标簽進行配置 property就是名字,
  // 在配置裡面的标簽名字。在調用該插件的時候會看到
  @Parameter(property = "sourceFolderPath")
  private String sourceFolderPath;

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    System.out.println("Create a new empty model changelog in liquibase yaml file.");
    String userName = GitUtil.getGitUserName();

    String changeLogFileName = getChangeLogFileName(sourceFolderPath);
    if (StrUtil.isNotBlank(changeLogFileName)) {
      generateYamlChangeLog(changeLogFileName, userName);
    }
  }

  private void generateYamlChangeLog(String changeLogFileName, String userName) {
    String changeLogFileFullName = changeLogFileName + ".yml";
    File file = new File(sourceFolderPath, changeLogFileFullName);
    String content = "databaseChangeLog:\n"
        + "  - changeSet:\n"
        + "      id: " + changeLogFileName + "\n"
        + "      author: " + userName + "\n"
        + "      changes:";
    try {
      FileWriter fw = new FileWriter(file.getAbsoluteFile());
      BufferedWriter bw = new BufferedWriter(fw);
      bw.write(content);
      bw.close();
      fw.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

}
複制代碼           

5、執行 mvn install 指令,然後會在 maven 的 repository 檔案中生成對應的 jar 包。

6、在 mybatis-springboot 引入 liquibase-changelog-generate

<plugin>
  <groupId>com.msdn.hresh</groupId>
  <artifactId>liquibase-changelog-generate</artifactId>
  <version>1.0-SNAPSHOT</version>
  <configuration>
    <sourceFolderPath>src/main/resources/liquibase/changelog/
    </sourceFolderPath><!-- 目前應用根目錄 -->
  </configuration>
</plugin>
複制代碼           

7、點選如下任意一個指令

SpringBoot項目基礎設施搭建

然後在控制台輸入名稱:job_create_table,效果為:

SpringBoot項目基礎設施搭建

内容如下:

<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
  xmlns:pro="http://www.liquibase.org/xml/ns/pro"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
  <changeSet author="hresh" id="20220927_212841_214__job_create_table">
  </changeSet>
</databaseChangeLog>
複制代碼           

plugin-生成資料庫修改文檔

輕按兩下liquibase plugin面闆中的liquibase:dbDoc選項,會生成資料庫修改文檔,預設會生成到target目錄中,如下圖所示

SpringBoot項目基礎設施搭建

通路index.html會展示如下頁面,簡直應有盡有

SpringBoot項目基礎設施搭建

關于 liquibase 的更多有意思的使用,可以花時間再去挖掘一下,這裡就不過多介紹了。

一鍵式生成模版代碼

基于 orm-generate 項目可以實作項目模闆代碼,內建了三種 ORM 方式:Mybatis、Mybatis-Plus 和 Spring JPA,JPA 是剛內建進來的,該項目去年就已經釋出過一版,也成功實作了想要的功能,關于功能介紹可以參考我之前的這篇文章。

運作 orm-generate 項目,在 swagger 上調用 /build 接口,調用參數如下:

{
    "database": "mysql_db",
    "flat": true,
    "type": "mybatis",
    "group": "hresh",
    "host": "127.0.0.1",
    "module": "orm",
    "password": "root",
    "port": 3306,
    "table": [
        "user",
        "job"
    ],
    "username": "root",
    "tableStartIndex":"0"
}
複制代碼           

先将代碼下載下傳下來,解壓出來目錄如下:

SpringBoot項目基礎設施搭建

代碼檔案直接移到項目中就行了,稍微修改一下引用就好了。

總結

上述基礎代碼是根據個人經驗總結出來的,可能不夠完美,甚至還缺少一些更有價值的基礎代碼,望大家多多指教。

在實際項目開發中,SpringBoot 基礎代碼和模版生成代碼完全可以作為兩個獨立的項目,供其他業務項目使用,以上代碼僅供參考,應用時可以按需修改。