基于springboot 2.0的项目种子

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

做这个种子的心路历程

最近在做一个大型的J2EE项目后端语言选择了Java理所当然的选择了SpringBoot使用SpringBoot来做restful风格的api开发很是方便Spring下面有很多子项目通过Springboot集成也很舒服。程序员都知道沟通很重要实际项目中往往是各自为战尽管使用的是相同的框架、工具编写的代码却千差万别为了统一基础代码风格编写了这个项目种子。

除此之外在开发一个Web后端api项目时通常都会经历搭建项目、选择依赖管理工具、引入基础包依赖、配置框架等为了加快项目的开发进度早点下班还需要封装一些常用的类和工具如标准的响应结构体封装、统一异常处理切面、接口签名认证、初始化运行方法、轮询方法、api版本控制封装、异步方法配置等。

每次开始一个类型的新项目以上这些步骤又要重复一遍虽然能够将老项目拿过来删删减减达到目的但还是很费时费力还容易出问题。所以可以利用面向对象的思想抽取这类Web后端api项目的共同之处封装成一个项目种子。以后再开发类似的项目就能直接在这个项目种子上迭代减少重复劳动。

如果你有类似的需求可以克隆下来试试。欢迎star或fork如果在使用中发现问题或者有什么建议欢迎提 issue 或 pr 一起完善。

使用方法

  1. 克隆本项目到本地
  2. 使用IDEA打开选择pom.xml文件使用maven构建本项目
  3. 下载项目需要的依赖包
  4. 修改application-dev.yml中的pgsql、kafka配置
  5. 运行Application.javaApplication.java中的main函数
  6. 访问 http://localhost:8080

环境依赖

  • jdk: openjdk1.8
  • kafka: 2.12-2.5.1
  • pgsql: 15

特征

  • 支持包管理工具maven和gradle
  • springboot版本为2.3.7.RELEASE
  • 统一HTTP Response响应JSON结构封装
  • 基于 @ControllerAdvice 的AOP异常拦截处理
  • 基于 ApplicationRunner 的初始化
  • 基于 HandlerInterceptor 的Mvc拦截器配置
  • PostgreSQL关系型数据库支持
  • 基于 slf4j/logback 的日志切面
  • 基于 @Scheduled 的定时任务
  • 基于 @Async 的异步任务处理
  • 文件分片上传下载示例
  • websocket消息推送示例
  • 多数据源示例
  • docker构建脚本示例

统一HTTP Response响应JSON结构封装

基于@RestControllerAdvice的返回值拦截封装。

返回参数示例

{
  "code": "0000000000",
  "msg": "操作成功",
  "data": {
    
  }
}
/**
 * 返回结果类
 */
@Data
public final class Res<T> {
  private static final String SUCCESS_CODE = "0000000000";
  private String code = SUCCESS_CODE;

  private String msg = "请求成功";

  private T data;


  private Res() {
  }

  private Res(T data) {
    this.data = data;
  }

  private Res(String code, String msg) {
    this(code, msg, null);
  }

  private Res(String code, String msg, T data) {
    this.code = code;
    this.msg = msg;
    this.data = data;
  }

  public static Res<Object> success() {
    return new Res<>();
  }

  public static <T> Res<T> success(T data) {
    return new Res<>(data);
  }

  public static <T> Res<T> success(T data, String msg) {
    return new Res<>(SUCCESS_CODE, msg, data);
  }

  public static Res<Object> error(String code, String msg) {
    return new Res<>(code, msg);
  }

  public static Res<Object> error(IErrorCode error) {
    return new Res<>(error.getCode(), error.getMsg());
  }

  public static <T> Res<T> of(String code, String msg, T data) {
    return new Res<>(code, msg, data);
  }

  public static Res<Object> of(BaseErrorCode baseErrorCode) {
    return new Res<>(baseErrorCode.getCode(), baseErrorCode.getMsg());
  }

  @JsonIgnore
  public boolean isSuccess() {
    return SUCCESS_CODE.equals(code);
  }
}

基于 @ControllerAdvice 的AOP异常拦截处理

可以参考CustomExceptionsHandler.java的异常捕获实现将自定义异常拦截添加到CustomExceptionsHandler.java末尾。

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler implements ThrowsAdvice {

  @ExceptionHandler(ConstraintViolationException.class)
  @ResponseBody
  public ResponseEntity<Object> constraintViolationException(ConstraintViolationException e) {
    log.error("ConstraintViolationException Params valid exception={}\n{}", SessionHelper.getRequest().getRequestURI(), e.getMessage());
    final Res<Object> res = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), e.getMessage());
    return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
  }

  /**
   * 参数校验异常
   *
   * @param e
   * @return
   */
  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseBody
  public ResponseEntity<Object> methodArgumentNotValidException(MethodArgumentNotValidException e) {
    log.error("Params valid exception={}\n{}", SessionHelper.getRequest().getRequestURI(), e.getMessage());
    return bindingResult(e.getBindingResult());
  }

  @ExceptionHandler(BindException.class)
  @ResponseBody
  public ResponseEntity<Object> bindException(BindException e) {
    log.error("Params valid exception={}\n{}", SessionHelper.getRequest().getRequestURI(), e.getMessage());
    return bindingResult(e.getBindingResult());
  }

  private ResponseEntity<Object> bindingResult(BindingResult bindingResult) {
    final String notEmpty = "不能为空";
    List<FieldError> errors = bindingResult.getFieldErrors();
    StringBuilder messageBuilder = new StringBuilder();
    String message;
    for (int i = 0; i < errors.size(); i++) {
      FieldError error = errors.get(i);
      message = Strings.isNotBlank(error.getDefaultMessage()) ? error.getDefaultMessage() : notEmpty;
      if (notEmpty.equals(message)) {
        messageBuilder.append(error.getField());
      }
      messageBuilder.append(message);
      if (i < errors.size() - 1) {
        messageBuilder.append(";");
      }
    }
    final Res<Object> res = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), messageBuilder.toString());
    return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
  }

  /**
   * 请求方法不支持
   *
   * @param e
   * @return
   */
  @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  @ResponseBody
  public ResponseEntity<Object> methodNotSupportHandle(HttpRequestMethodNotSupportedException e) {
    log.error("HttpRequestMethodNotSupportedException exception={}", e.getMessage(), e);
    final Res<Object> res = Res.error(BaseErrorCode.HTTP_REQUEST_METHOD_NOT_SUPPORTED_ERROR.getCode(), e.getMessage());
    return new ResponseEntity<>(res, HttpStatus.METHOD_NOT_ALLOWED);
  }

  /**
   * 参数校验异常
   */
  @ExceptionHandler(MissingServletRequestParameterException.class)
  public ResponseEntity<Object> missingServletRequestParameterException(MissingServletRequestParameterException e) {
    Res<Object> result = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), BaseErrorCode.INVALID_PARAM_ERROR.getMsg() + e.getMessage());
    log.error("Params valid exception={}", e.getMessage(), e);
    return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
  }

  @ExceptionHandler(MaxUploadSizeExceededException.class)
  public ResponseEntity<Object> maxUploadSizeExceededException(MaxUploadSizeExceededException e) {

    if (e.getCause().getCause() instanceof SizeLimitExceededException) {
      final SizeLimitExceededException slee = (SizeLimitExceededException) e.getCause().getCause();

      final String message = BaseErrorCode.FILE_SIZE_ERROR.getMsg() + "限制大小"
        + slee.getPermittedSize() / 1024 / 1024 + "MB" + "实际大小" + slee.getActualSize() / 1024 / 1024
        + "MB";
      Res<Object> result = Res.error(BaseErrorCode.FILE_SIZE_ERROR.getCode(), message);
      log.error("file size exceeded exception={}", e.getMessage(), e);
      return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    } else {
      Res<Object> result = Res.error(BaseErrorCode.FILE_SIZE_ERROR.getCode(), BaseErrorCode.FILE_SIZE_ERROR.getMsg() + e.getMessage());
      log.error("file size exceeded exception={}", e.getMessage(), e);
      return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    }
  }

  @ExceptionHandler(TypeMismatchException.class)
  @ResponseBody
  public ResponseEntity<Object> typeMismatchHandle(TypeMismatchException e) {
    log.error("param type mismatch exception={}", e.getMessage(), e);
    final Res<Object> res = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), BaseErrorCode.INVALID_PARAM_ERROR.getMsg() + e.getPropertyName() + "类型应该为" + e.getRequiredType());
    return new ResponseEntity<>(res, HttpStatus.BAD_REQUEST);
  }

  @ExceptionHandler({HttpMessageNotReadableException.class})
  public ResponseEntity<Object> httpMessageNotReadableHandle(HttpMessageNotReadableException e) {
    if (e.getCause() instanceof InvalidFormatException) {
      InvalidFormatException ife = (InvalidFormatException) e.getCause();
      Joiner joiner = Joiner.on(" ").skipNulls();
      String message = BaseErrorCode.INVALID_PARAM_ERROR.getMsg();
      if (null != ife) {
        message = joiner.join(message, "字段", ife.getValue(), "正确类型:", ife.getTargetType());
      }

      Res<Object> result = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), message);
      log.error("param type mismatch exception={}", e.getMessage(), e);
      return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    } else {
      Res<Object> result = Res.error(BaseErrorCode.INVALID_PARAM_ERROR.getCode(), BaseErrorCode.INVALID_PARAM_ERROR.getMsg() + e.getMessage());
      log.error("param type mismatch exception={}", e.getMessage(), e);
      return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    }
  }

  @ExceptionHandler(NoHandlerFoundException.class)
  @ResponseBody
  public ResponseEntity<Object> noHandlerFoundException(NoHandlerFoundException e) {
    log.error("api not exist exception={}", e.getMessage(), e);
    final Res<Object> res = Res.error(BaseErrorCode.API_NOT_EXIST_ERROR.getCode(), BaseErrorCode.API_NOT_EXIST_ERROR.getMsg() + " 请求地址" + e.getRequestURL());
    return new ResponseEntity<>(res, HttpStatus.NOT_FOUND);
  }

  @ExceptionHandler(MultipartException.class)
  public ResponseEntity<Object> multipartException(MultipartException e) {
    Res<Object> result = Res.error(BaseErrorCode.HTTP_REQUEST_FAILED.getCode(), BaseErrorCode.HTTP_REQUEST_FAILED.getMsg() + e.getMessage());
    log.error("upload file or form data exception={}", e.getMessage(), e);
    return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
  }

  @ExceptionHandler(FeignException.class)
  @ResponseBody
  public ResponseEntity<Object> feignException(FeignException e) {
    final String content = e.contentUTF8();
    Object data = null;
    Res<Object> res = null;
    if (!StringUtils.isEmpty(content)) {
      res = JSON.toBean(content, new TypeReference<Res<Object>>() {
      });
      if (res.isSuccess()) {
        data = res.getData();
      }
    }
    if (res == null || res.isSuccess()) {
      res = Res.error(BaseErrorCode.CALL_FAILED.of());
      res.setData(data);
    }
    log.error("Remote call exception={}\n{}", e.request().url(), res);
    return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
  }

  /**
   * SQL 异常
   */
  @ExceptionHandler(SQLException.class)
  @ResponseBody
  public ResponseEntity<Object> SQLException(SQLException e) {
    log.error("sql  exception={}", e.getMessage(), e);
    final Res<Object> res = Res.of(BaseErrorCode.SQL_EXCEPTION);
    return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
  }

  /**
   * 业务异常
   */
  @ExceptionHandler(BaseException.class)
  @ResponseBody
  public ResponseEntity<Object> baseException(BaseException e) {
    log.error("internal server exception={}", e.getMessage(), e);
    final Res<Object> res = Res.error(e.getCode(), e.getMessage());
    return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
  }

  /**
   * 全局异常
   */
  @ExceptionHandler(Exception.class)
  @ResponseBody
  public ResponseEntity<Object> globalHandle(Exception e) {
    log.error("exception={}", e.getMessage(), e);
    final Res<Object> res = Res.of(BaseErrorCode.SYS_INTERNAL_ERROR);
    return new ResponseEntity<>(res, HttpStatus.INTERNAL_SERVER_ERROR);
  }

}

基于 ApplicationRunner 的初始化

在 run 函数中可初始化数据库清楚缓存等。


@Component
public class StartupRunnerConfig implements ApplicationRunner {
  @Resource
  private
  InitService service;

  @Override
  public void run(ApplicationArguments args) throws Exception {
    service.init();
  }
}

基于 HandlerInterceptor 的Mvc拦截器配置

preHandle 函数中返回 true 表示验证通过请求会向下传递返回false请求会被打回处理header中的用户信息。

Header用户信息示例

Header: username:admin&usercode:admin

public class BaseHeaderInterceptor extends HandlerInterceptorAdapter {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String user = request.getHeader(GlobalConstant.USER);
    if (StringUtils.hasText(user)) {
      ThreadLocalUtils.put(GlobalConstant.USER, user);
      //user 解析
      handleUserInfo(user);
    }
    return true;
  }

  @Override
  public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    super.postHandle(request, response, handler, modelAndView);
    ThreadLocalUtils.remove(GlobalConstant.USER);
    UserHandler.remove();
  }

  private void handleUserInfo(String authorization) throws Exception {
    // 编码转换
//        authorization = EscapeUtils.unescape(EscapeUtils.unescape(authorization));
    authorization = URLDecoder.decode(authorization, "UTF-8");

    String[] array = authorization.split("&");

    User user = UserHandler.getUser();
    if (null == user) {
      user = new User();
    }
    for (String line : array) {
      String[] keyValue = line.split(":");
      if (keyValue.length < 2) {
        continue;
      }
      if ("usercode".equalsIgnoreCase(keyValue[0])) {
        user.setUserCode(keyValue[1]);
      }

      if ("username".equalsIgnoreCase(keyValue[0])) {
        user.setUserName(keyValue[1]);
      }
    }

    //处理其它用户信息
    UserHandler.setUser(user);
  }
}

PostgreSQL关系型数据库支持

支持 PostgreSQL、MySQL 数据库相应的模板连接文件已经配置好修改连接地址用户名密码即可使用这些数据库都支持 Mybatis 管理。

使用不同数据库只需更改application.yml中的

spring:
  profiles:
    active: dev,h2

基于 slf4j/logback 的日志切面

有关于 RequestMapping 的日志切面可记录当前调用函数起止时间。

/**
 * 异常拦截切面
 */
@Aspect // 声明切面
@Component // 让此切面成为Spring容器管理的bean
@Slf4j
public class RequestAspect {

    public static final String GET = "@annotation(org.springframework.web.bind.annotation.GetMapping)";
    public static final String POST = "||@annotation(org.springframework.web.bind.annotation.PostMapping)";
    public static final String PUT = "||@annotation(org.springframework.web.bind.annotation.PutMapping)";
    public static final String PATCH = "||@annotation(org.springframework.web.bind.annotation.PatchMapping)";
    public static final String DELETE = "||@annotation(org.springframework.web.bind.annotation.DeleteMapping)";
    public static final String REQUEST = "||@annotation(org.springframework.web.bind.annotation.RequestMapping)";
    @Value("${request.aspect.excluded.urls:${springdoc.swagger-ui.path},${springdoc.api-docs.path}/**,/${api-prefix}/files/**}")
    private List<String> excludedUrls;

    @Pointcut(GET + POST + PUT + PATCH + DELETE + REQUEST) // 声明切点
    private void request() {
    }

    /**
     * 核心业务逻辑调用异常退出后执行此advice处理错误信息。
     *
     * @param proceedingJoinPoint 代理对象
     */
    @Around("request()") // 声明一个建言传入定义的切点
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (ObjectUtils.isEmpty(attributes)) {
            return proceedingJoinPoint.proceed();
        }
        HttpServletRequest request = attributes.getRequest();

        String requestURI = request.getRequestURI();

        boolean excluded = CollectionUtils.isEmpty(excludedUrls) || excludedUrls.stream().anyMatch(pattern -> new AntPathMatcher().match(pattern, requestURI));

        if (!excluded) {
            log.info("REQUEST {} : {}", requestURI, JSON.toString(proceedingJoinPoint.getArgs()));
        }
        try {
            Object proceed = proceedingJoinPoint.proceed();
            if (!excludedUrls.contains(requestURI)) {
                log.info("RESPONSE : {}", proceed);
            }
            return proceed;
        } catch (Throwable e) {
            log.error("REQUEST {} : {}", requestURI, JSON.toString(proceedingJoinPoint.getArgs()), e);
            throw e;
        }
    }

}

基于 @Scheduled 的定时任务

/**
 * kafka 定时检测消费组是否在线下线的重新拉起
 */
@Component
@EnableConfigurationProperties(KafkaTopicProperties.class)
@AutoConfigureAfter(KafkaInitialConfiguration.class)
@Slf4j
public class KafkaConsumerRestartTask {

  public static final int CONNECTIONS_MAX_IDLE_MS_CONFIG = 10000;
  public static final int REQUEST_TIMEOUT_MS_CONFIG = 5000;

  @Resource
  private KafkaAdmin kafkaAdmin;
  @Resource
  private KafkaTopicProperties topicProperties;
  @Resource
  private KafkaListenerEndpointRegistry endpointRegistry;
  List<String> topics;

  /**
   * 计划任务每隔5分钟执行一次
   */
  @Scheduled(cron = "${kyyee.config.kafka.container.restart-corn:0 0/5 * * * ?}")
  public void consumerRestart() {
    Instant start = Instant.now();
    doRestart();
    log.info("the task used:{}s", ChronoUnit.SECONDS.between(start, Instant.now()));
  }

  public void doRestart() {
    if (CollectionUtils.isEmpty(this.topics)) {
      this.topics = topicProperties.getTopics().stream().map(KafkaTopicProperties.Topic::getName).collect(Collectors.toList());
      if (CollectionUtils.isEmpty(this.topics)) {
        return;
      }
    }
    // kafka服务端配置信息
    Map<String, Object> properties = new HashMap<>(kafkaAdmin.getConfigurationProperties());
    properties.put(AdminClientConfig.CONNECTIONS_MAX_IDLE_MS_CONFIG, CONNECTIONS_MAX_IDLE_MS_CONFIG);
    properties.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, REQUEST_TIMEOUT_MS_CONFIG);

    // 创建KafkaAdminClient
    try (AdminClient client = KafkaAdminClient.create(properties)) {

      // 获取在线消费者列表
      List<String> groups = Collections.singletonList(String.valueOf(properties.get("spring.kafka.consumer.group-id")));
      // 获取在线消费者列表订阅的topic集合
      Set<String> assignedTopics = client.describeConsumerGroups(groups).all().get().values()
        .stream().flatMap(consumerGroupDescription -> consumerGroupDescription.members().stream())
        .flatMap(memberDescription -> memberDescription.assignment().topicPartitions().stream().map(TopicPartition::topic))
        .collect(Collectors.toSet());

      //kafka 集群当前的所有topic
      Set<String> allClusterTopics = client.listTopics().names().get();

      // 过滤获得未订阅的topic集合消费者离线
      List<String> unassignedTopics = this.topics.stream().filter(e -> !assignedTopics.contains(e) && allClusterTopics.contains(e)).collect(Collectors.toList());

      if (unassignedTopics.isEmpty()) {
        log.info("unassigned topics is empty.");
        return;
      }
      log.info("unassigned topics:{}", unassignedTopics);

      //获取监听了未订阅topic的kafka监听器
      List<MessageListenerContainer> needRestartContainers = new LinkedList<>();
      Collection<MessageListenerContainer> allListenerContainers = endpointRegistry.getAllListenerContainers();
      for (MessageListenerContainer listenerContainer : allListenerContainers) {
        ContainerProperties containerProperties = listenerContainer.getContainerProperties();
        for (String topic : unassignedTopics) {
          boolean topicCheck = Optional.ofNullable(containerProperties.getTopics()).map(Arrays::asList).map(list -> list.contains(topic)).orElse(false);
          boolean topicPatternCheck = Optional.ofNullable(containerProperties.getTopicPattern()).map(pattern -> pattern.matcher(topic).find()).orElse(false);
          if (topicCheck || topicPatternCheck) {
            needRestartContainers.add(listenerContainer);
          }
        }
      }
      if (needRestartContainers.isEmpty()) {
        log.info("need restart containers is empty.");
        return;
      }
      //依次重启kafka监听器
      for (MessageListenerContainer toRestartContainer : needRestartContainers) {
        AbstractMessageListenerContainer container = (AbstractMessageListenerContainer) toRestartContainer;
        log.info("kafka consumer restart, container:{}", container.getContainerProperties());
        container.stop(false);
        container.start();
      }
    } catch (Exception e) {
      log.error("kafka consumer restart failed, message:{}", e.getMessage());
    }
  }

}

基于 @Async 的异步任务处理

在普通方法上添加@Async该方法将变成异步方法可与 websocket 结合实现消息推送。

文件分片上传下载示例

websocket消息推送示例

多数据源示例

docker构建脚本示例

总结

该项目抽取了几个基于 springboot 开发的项目的一些公共代码只是一个项目框架。这个项目的特性多是 spring 及 mybatis 的特性。与
spring 耦合度很高springboot 3.0.0 宣称在性能上相比 springboot 2.0.0 有很大提升后续我会将该种子项目的 springboot 版本更新到
3.0.0。

希望它对你有所帮助。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: Spring