Skip to content

云原生 Java 微服务应用开发手册

Cover

云原生环境的 Java 应用开发与传统的非容器化方式有许多不同之处,尤其是在构建和运行环节,传统方式需要构建 Jar 包,而云原生环境需要构建镜像,传统方式应用直接运行在操作系统之上,而云原生环境应用在容器内运行,传统方式应用只能在单机上运行,而云原生环境下借助于 Kubernetes 这样的容器编排工具,应用可以自动地被调度到任意的单个或多个节点上运行,更具弹性和容错性,本文将分享一些在云原生环境下开发一个真实的 Java 微服务应用的经验。

微服务划分

服务名简介备注
parent父 POMMaven 包物料清单,所有微服务中引用的 Maven 包版本在此统一指定
client微服务调用客户端微服务内部 API SDK,可以被 common 包引用,但不能反过来
common微服务实现公共代码各微服务之间共享的代码,避免代码冗余
gateway网关各微服务外部 API 入口,包含认证、鉴权等通用功能
user用户帐号、角色、租户等用户相关功能
dataset数据集数据集,以及简单的数据流标注
annotation标注复杂的工作流标注
storage存储文件存储及元数据管理
admin管理后台内部管理平台
websocket消息WebSocket 消息推送

为了降低本地开发环境的资源消耗,本地微服务可以通过各微服务的 Kubernetes 集群外端口来调用,而不需要在本地启动所有依赖的微服务。注意,此种方式会将各微服务接口直接暴露给外部,没有网关认证和鉴权的保护,请勿在开发环境保存涉密数据。

代码结构

干净架构介绍

Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中提出了一种适用于复杂业务系统的软件架构方式。在干净架构出现之前,已经有一些其它架构,包括 Hexagonal Architecture、Onion Architecture、Screaming Architecture、DCI 和 BCE。这些架构在本质上都是类似的,都采用分层的方式来达到一个共同的目标,那就是分离关注。

Clean architecture

采用干净架构的系统,可以达成以下目标:

  1. 框架无关性。干净架构不依赖于具体的框架和库,而仅把它们当作工具,因此不会受限于任何具体的框架和库。
  2. 可测试性。业务规则可以在没有 UI、数据库、Web 服务器等外部依赖的情况下进行测试。
  3. UI 无关性。UI 改变可以在不改动系统其它部分的情况下完成,比如把 Web UI 替换成控制台 UI。
  4. 数据库无关性。可以很容易地切换数据库类型,比如从关系型数据库 MySQL 切换到文档型数据库 MongoDB,因为业务规则并没有绑定到某种特定的数据库类型。
  5. 外部代理无关性。业务规则对外部世界一无所知,因此外部代理的变动不会影响到业务代码。

干净架构原图里涉及的概念比较多,容易造成大家理解上出现偏差。再加上干净架构并没有规定具体实施细节,因此在实施的时候不同的人会有不同的选择,有些选择甚至是错误的。为了简化和规范干净架构的实施,这里对干净架构进行了一些简化,并增加了一些约束,更多内容可参考这篇博文 干净架构最佳实践

下面是精简后的干净架构图:

Clean architecture simplified

目录结构

以用户微服务为例,其项目目录结构如下:

txt
.
├── adapter // 适配层,包括对外提供用例层服务(API、CLI 等),以及用例层调用外部服务
│   ├── Application.java
│   ├── api // 以 API 方式调用用例
│   │   ├── config // 框架配置
│   │   ├── controller // 控制器
│   │   └── plugin // 框架插件,过滤器、包装器等
│   ├── cli // 以 CLI 方式调用用例
│   │   └── job // 后台任务
│   ├── dto // 对外输入和输出的对象
│   ├── gui // 以 GUI 方式调用用例
│   └── port // 外部依赖服务接口实现
│       │── dao // 数据访问接口实现,可以使用 MyBatis 或 JPA,切换时不会影响到用例层
│       │── email // 邮件发送接口实现
│       │── mobile // 短信发送接口实现
│       └── pay // 支付接口实现
│── entity // 业务实体
│  ├── UserBO.java // 用户实体
│  └── RoleBO.java // 角色实体
│── usecase // 用例层,亦即业务逻辑层
│  ├── UserUsecase.java // 用户模块用例
│  ├── exception // 业务异常定义
│  └── port // 外部依赖服务接口定义
│       │── dao // 数据访问接口
│       │── email // 邮件发送接口
│       │── mobile // 短信发送接口
│       └── pay // 支付接口
└── util // 工具类

为了减少代码冗余,将各微服务中的公用代码抽取到了 common 包中,其目录结构跟微服务类似。

txt
.
├── adapter // 适配层
│   ├── api
│   └── cli
└── util  // 工具类
    ├── encoder
    ├── generator
    ├── jwt
    └── pagination

为了方便微服务之间相互调用,将各微服务的内部 API 封装到了 client 包下,client 包可以被 common 包引用,但不能反过来 。

txt
.
├── async // 异步调用,适合 WebFlux 环境
│   └── user
├── dto // 数据传输对象
│   ├── ApiResult.java
│   └── user
├── exception // API 异常
│   ├── UsecaseCode.java
│   └── UsecaseException.java
├── feign // Feign 配置
│   ├── ApiResultDecoder.java
└── sync // 同步调用
    ├── ApiConfiguration.java
    ├── RequestContextInterceptor.java
    └── user

Maven 规范

  1. 各微服务的依赖包版本统一在 parent 包里指定,其它包引入依赖包时不要指定版本号;
  2. POM 文件里依赖包引入按底层(比如驱动)到上层(比如 ORM 框架)的顺序,同一个 Group 的包相邻放置,测试相关的依赖包放在后面;
  3. common 包里不要直接引入 Spring Boot Starter 依赖包(比如 com.baomidou.mybatis-plus-boot-starter),而是要引入底层包(比如 com.baomidou.mybatis-plus),以免其它包引入 common 包时执行了不需要的自动配置;

日志规范

  1. 使用 Spring 自带的 CommonsRequestLoggingFilter 来打印请求日志,并通过配置项 logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter 来控制是否打印请求日志(为 DEBUG 或更低时才打印);
  2. Mybatis-Plus 通过 logging.level.<mapper-package-path> 来控制是否打印 SQL 日志(为 DEBUG 或更低时才打印);
  3. 默认配置文件里日志级别应设为 INFO 或更高,各环境依各自需求来调整;

异常规范

  1. 使用 UsecaseException 来抛出业务异常,未被捕获的业务异常会在框架层面进行处理,返回 200 状态码,以及相应的业务错误码和描述,如果需要返回非 200 状态码可在控制器里显示构造并返回 ResponseEntity 对象;

数据库规范

  1. 每个微服务使用单独的数据库,不能直接访问其它微服务的数据库,只能通过接口调用;
  2. 迁移文件统一放在 ops/db/migration 目录下,文件命名遵循 Flyway 规范,在此规范上稍做调整为“服务版本号_更新编号__描述”,比如 V1.0_1__Add_new_table.sql
  3. 为了可控性和简单性,使用手动迁移而不是 Flyway 自动迁移;
  4. 每次提测时(包括 Bug 修复),如有升级库表结构,那么需要创建一个新的升级文件,更新编号依次增加;

参考资料:

  1. 开发 Java 应用使用 TiDB 的最佳实践
  2. TiDB 最佳实践

缓存规范

Redis 使用规范

引入 Spring Data Redis 依赖:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Boot 配置:

java
@Configuration
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Value("${spring.redis.password}")
    private String password;

    /**
     * 将对象以 JSON 格式保存到 Redis 里。不推荐使用默认的 JdkSerializationRedisSerializer,存在安全风险,
     * 可读性也不好。
     * @param objectMapperBuilder
     * @return
     */
    @Bean
    public GenericJackson2JsonRedisSerializer jsonRedisSerializer(
            Jackson2ObjectMapperBuilder objectMapperBuilder) {
        var objectMapper = objectMapperBuilder.build();

        // 配置 ObjectMapper,使得在序列化后的 JSON 对象里存放类型信息,以便反序列化时能获得正确类型的对象
        GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

    /**
     * 用来将对象转换为 Map,以便保存为 Redis Hash。
     * @param objectMapper
     * @return
     */
    @Bean
    public Jackson2HashMapper hashMapper(ObjectMapper objectMapper) {
        return new Jackson2HashMapper(objectMapper, false);
    }

    /**
     * 使用功能更强大和完善的 Lettuce,而非 Jedis
     * @return
     */
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        var serverConfig = new RedisStandaloneConfiguration(host, port);
        serverConfig.setPassword(password);

        var clientConfig = LettucePoolingClientConfiguration.builder()
                .readFrom(REPLICA_PREFERRED)
                .build();

        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory redisConnectionFactory,
            GenericJackson2JsonRedisSerializer jsonRedisSerializer) {
        var template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(RedisSerializer.string());
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashKeySerializer(RedisSerializer.string());
        template.setHashValueSerializer(jsonRedisSerializer);
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        return new StringRedisTemplate(redisConnectionFactory);
    }
}

如果是把 Redis 当作 Key-Value 数据库来使用,推荐采用类似数据库表访问的 DAO 模式,为每种类型的数据创建一个 DAO 对象,每个 Key 对应一个主键 ID。

首先,定义 DAO 接口。通过 DAO 接口可以屏蔽底层的实现细节,对于调用者来说不用关心数据存放在哪,是 MySQL 这样的关系数据库还是 Redis 这样的 Key-Value 数据库,方便后续替换底层存储引擎。

java
public interface CounterDAO {

    /**
     * 获取计数
     * @param key Key
     * @return 计数
     */
    Long get(String key);

    /**
     * 设置计数
     * @param key Key
     * @param value 计数
     */
    void set(String key, Long value);

    /**
     * 变化量,返回更新后的计数,并发安全
     * @param key Key
     * @param delta 变化量
     * @return 更新后的计数
     */
    Long inc(String key, Long delta);
}

其次,实现 DAO。这里把一些公共的约定,比如 Key 命名前缀,提取到了抽象基类 AbstractRedisDAO 里。Key 命名遵循 <app>:<service>:<table>:<id> 规则,比如 basicai:user:user:1

java
@Component
public class CounterDAOImpl extends AbstractRedisDAO<RedisTemplate<String, Object>>
        implements CounterDAO {

    public CounterDAOImpl(RedisTemplate<String, Object> template) {
        super(template, "user", "counter");
    }

    @Override
    public Long get(String key) {
        var value = (Integer) template.opsForValue().get(prefixedKey(key));
        if (value == null) {
            return 0L;
        }

        return Long.valueOf(value);
    }

    @Override
    public void set(String key, Long value) {
        template.opsForValue().set(prefixedKey(key), value);
    }

    @Override
    public Long inc(String key, Long delta) {
        return template.opsForValue().increment(prefixedKey(key), delta);
    }
}

这里没有使用功能更强大也更复杂的 Redis Repository 来保存对象到 Redis Hash 里,也不推荐在 Redis 里存放结构比较复杂和对可靠性要求比较高的数据。

参考资料:

  1. Spring Data Redis 官方文档
  2. Introduction to Spring Data Redis

Spring Cache 使用规范

引入 Spring Cache 依赖:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

Spring Boot 配置:

java
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    /**
     * 配置各个 Cache,包括 TTL、Prefix 等
     * @param jsonRedisSerializer
     * @return
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer(
            GenericJackson2JsonRedisSerializer jsonRedisSerializer
    ) {
        var defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(60))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(jsonRedisSerializer))
                .computePrefixWith(new CacheKeyPrefix() {
                    String SEPARATOR = ":";

                    @Override
                    public String compute(String cacheName) {
                        return "basicai" + SEPARATOR + "user" + SEPARATOR + cacheName + SEPARATOR;
                    }
                });

        return (builder) -> builder
                .withCacheConfiguration("user", defaultCacheConfig
                        .entryTtl(Duration.ofMinutes(12 * 60)));
    }
}

使用 Spring Cache 的注解来自动缓存方法结果。推荐明确指定 key,而不是依赖 Spring Cache 自动生成的,更具可读性。

java
@Component
public class UserDAOImpl extends AbstractDAO implements UserDAO {
    private final UserMapper userMapper;

    public UserDAOImpl(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @Override
    @CachePut(cacheNames = "user", key = "#userBO.getId()")
    public UserBO saveOrUpdate(UserBO userBO) {
        User user = User.fromBO(userBO);
        userMapper.saveOrUpdate(user);
        return user.toBO();
    }

    @Override
    @Cacheable(cacheNames = "user", key = "#id")
    public Optional<UserBO> findById(Long id) {
        return Optional.ofNullable(userMapper.selectById(id))
                .map(User::toBO);
    }
}

参考资料:

  1. Spring Cache 官方文档
  2. Spring Boot Cache with Redis

Job 规范

Job 用来执行一些后台任务,比如缩略图生成、消息处理等,Job 跟 API 服务一样,都是经由 Kubernetes 来调度运行,具体可参考官方文档 Jobs

应用入口

为了避免同一个服务针对不同运行方式分别打包,统一使用一个应用来启动 Web 服务和执行 Job,通过命令行参数来区分以哪种方式运行,同时通过 --job 参数来区分不同 Job。

java
@SpringBootApplication(scanBasePackages = "ai.basic.basicai.user.adapter")
@EnableFeignClients(basePackages = "ai.basic.basicai.client.sync")
public class Application implements ApplicationRunner, ApplicationListener<ContextClosedEvent> {
   private static Logger logger = LoggerFactory.getLogger(Application.class);

   @Autowired
   private ApplicationContext context;

   private Thread jobThread;

   public static void main(String[] args) {
      var app = new SpringApplication(Application.class);
      var arguments = new DefaultApplicationArguments(args);
      var jobValues = arguments.getOptionValues("job");
      // 未指定 job 参数时启动 Web 服务,否则不启动
      if (CollectionUtils.isEmpty(jobValues)) {
         app.setWebApplicationType(WebApplicationType.SERVLET);
      } else {
         app.setWebApplicationType(WebApplicationType.NONE);
      }
      app.run(args);
   }

   @Override
   public void onApplicationEvent(ContextClosedEvent event) {
      if (jobThread != null) {
         jobThread.interrupt();
         try {
            jobThread.join();
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
      }
   }

   @Override
   public void run(ApplicationArguments args) throws Exception {
      var jobValues = args.getOptionValues("job");
      // 未指定 job 参数时直接返回
      if (CollectionUtils.isEmpty(jobValues)) {
         return;
      }
      var jobName = jobValues.get(0);
      var job = (AbstractJob) context.getBean(jobName);
      if (ObjectUtil.isNotNull(job)) {
         jobThread = new Thread(job);
         jobThread.start();
         jobThread.join();
         var exitCode = SpringApplication.exit(context);
         System.exit(exitCode);
      } else {
         logger.error(String.format("Unknown job %s", jobName));
      }
   }
}

编写 Job

所有 Job 统一放在 cli.job 包下,继承于 AbstractJob 基类。

java
public abstract class AbstractJob implements Runnable {
    private static final Logger logger = LoggerFactory.getLogger(AbstractJob.class);

    private final String name;

    protected AbstractJob(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    @Override
    public void run() {
        logger.info("Start running job {}", name);

        try {
            work();
        } catch (InterruptedException e) {
            logger.info("Interrupt running job {} ", name);
        } catch (Exception e) {
            logger.info("Exception running job {}: {}", name, e.getMessage());
            e.printStackTrace();
        }

        logger.info("End running job {}", name);
    }

    /**
     * 真正要做的工作,由子类实现。对于长期运行的任务,注意在合适的时机检查当前线程是否被中断,以便结束运行。
     * @throws Exception
     */
    protected abstract void work() throws Exception;
}

短期运行 Job 示例:

java
public class ShortJob extends AbstractJob {
    private static Logger logger = LoggerFactory.getLogger(ShortJob.class);

    public ShortJob() {
        super(ShortJob.class.getSimpleName());
    }

    @Override
    protected void work() {
        logger.info("Do some work");
    }
}

长期运行 Job 示例:

java
public class LongJob extends AbstractJob {
    private static Logger logger = LoggerFactory.getLogger(LongJob.class);

    public LongJob() {
        super(LongJob.class.getSimpleName());
    }

    /**
     * 在循环间隙检查当前线程是否被中断,如果是则退出循环,单次循环处理时长不要超过 30s(Kubernetes 默认的容器优雅
     * 终止时间)。
     * @throws Exception
     */
    @Override
    protected void work() throws Exception {
        while (true) {
            if (Thread.currentThread().isInterrupted()) {
                break;
            }

            logger.info("Do some work");
            Thread.sleep(10 * 1000);
        }
    }
}

构建镜像

此外,在 Dockerfile 里最好使用 ENTRYPOINT 而不是 CMD 来指定启动命令,这样在执行 Job 时只需指定命令参数,否则需要指定完整的命令。

Dockerfile
FROM openjdk:11

RUN apt update && \
    apt install -y iputils-ping curl wget netcat

WORKDIR /app
COPY target/user-1.0-SNAPSHOT.jar ./
RUN mkdir -p config

EXPOSE 8080

ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "user-1.0-SNAPSHOT.jar"]

执行 Job

假设镜像名为 basicai/backend/user,那么可以按如下的命令来分别启动 Web 服务和执行 Job。

bash
# 启动 Web 服务
docker container run --rm basicai/backend/user
# 执行 Job
docker container run --rm basicai/backend/user java -jar app.jar --job=short

在 Kubernetes 里可通过下面的部署文件来执行有结束状态的短期 Job。对于每次部署时都需要执行的长期 Job(比如消息队列消费者),需要使用 Deployment 来部署,并且建议添加到 CI deployment 文件里,跟 API 服务一起部署。

yaml
# 有结束状态的短期 Job,需手动执行,执行之前需先删除现有同名 Job,Kubernetes Job 不允许更新
apiVersion: batch/v1
kind: Job
metadata:
  name: user-shortjob
spec:
  # 完成次数
  completions: 1
  # 并发数
  parallelism: 1
  # 失败重试次数
  backoffLimit: 6
  template:
    spec:
      # 提前准备好拉取镜像的凭证
      imagePullSecrets:
        - name: basicai-registry
      restartPolicy: Never
      containers:
        - name: user-shortjob
          image: user
          env:
            - name: JAVA_OPTS
              value: "-XX:InitialRAMPercentage=20.0 -XX:MinRAMPercentage=80.0 -XX:MaxRAMPercentage=80.0 -XshowSettings:vm"
            - name: APP_ARGS
              value: "--job=short"
          resources:
            requests:
              memory: 1Gi
              cpu: 100m
            limits:
              memory: 2Gi
              cpu: 1
          volumeMounts:
            - name: config
              mountPath: /app/config
      volumes:
        - name: config
          configMap:
            name: user

定时执行 Job

Linux 下通常使用 cron job 来执行定时任务,同样地 Kubernetes 也提供了 CronJob 资源类型来在集群里调度和执行定时任务,比如下面的示例中每小时执行一次任务。CronJob 的 jobTemplate.spec 类型是 JobSpec,因此也就自然支持普通 Job 的 parallelism、completions 等并发控制参数。

yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: user-cronjob
  namespace: basicai-backend
spec:
  # 执行频率
  schedule: "0 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          nodeSelector:
            dedicated: app
          # 提前准备好拉取镜像的凭证
          imagePullSecrets:
            - name: basicai-registry
          restartPolicy: OnFailure
          containers:
          - name: user-cronjob
            image: user
            env:
              - name: JAVA_OPTS
                value: "-XX:InitialRAMPercentage=20.0 -XX:MinRAMPercentage=80.0 -XX:MaxRAMPercentage=80.0 -XshowSettings:vm"
              - name: APP_ARGS
                value: "--job=cronjob --management.metrics.tags.application=basicai-user-cronjob --management.metrics.export.prometheus.pushgateway.enabled=true"
            resources:
              requests:
                memory: 1Gi
                cpu: 100m
              limits:
                memory: 2Gi
                cpu: 1
            volumeMounts:
              - name: config
                mountPath: /app/config
          volumes:
            - name: config
              configMap:
                name: user

单元测试规范

因为集成测试会依赖各种外部服务,执行起来比较困难也比较慢,并且通过功能强大的 API 测试工具(比如 Apifox)就能够基本达到集成测试的要求,因此这里不推荐编写集成测试,而是把精力集中到更易于执行的单元测试上。对于单元测试,也要重点针对业务逻辑复杂的用例层接口去做,不需要对只进行简单增删改查的 DAO 接口去做。

用例层单元测试

java
@ExtendWith(SpringExtension.class)
public class UserUsecaseTest {

    @TestConfiguration
    static class UserControllerTestConfiguration {

        @Bean
        public UserUsecase userUsecase() {
            return new UserUsecase();
        }
    }

    @Autowired
    private UserUsecase userUsecase;

    @MockBean
    private UserDAO userDAO;

    @MockBean
    private RoleDAO roleDAO;

    @MockBean
    private RandomGenerator randomGenerator;

    @MockBean
    private PasswordEncoder passwordEncoder;

    @MockBean
    private UserEmailPublisher emailPublisher;

    @MockBean
    private StorageService storageService;

    @BeforeEach
    public void setUp() {
        var jagger = UserBO.builder().id(1L)
                .username("jaggerwang")
                .email("jaggerwang@gmail.com")
                .nickname("Jagger Wang")
                .build();

        Mockito.when(userDAO.findById(jagger.getId(), UserBO.Status.ACTIVE))
                .thenReturn(Optional.of(jagger));
    }

    @Test
    public void whenValidId_thenUserShouldBeFound() {
        var id = (Long) 1L;
        var user = userUsecase.info(id);

        assertDoesNotThrow(() -> {
            user.get();
        });

        assertEquals(id, user.get().getId());
    }
}
  1. 使用了 @ExtendWith 注解而不是 @SpringBootTest,避免启动一个完整的应用上下文,从而加快单元测试执行速度;
  2. 使用 @TestConfiguration 注解来声明了一个只给本单元测试使用的配置类,里面可以定义本单元测试里需要自动注入的 Bean,比如被测试的用例对象;
  3. 对于被测试的用例对象依赖的其它 Bean 对象,可以通过 @MockBean 注解来声明;
  4. setUp 方法里使用 Mockito 来 Mock 被测试的用例对象方法里所调用的其它依赖对象的方法,注意这里不需要 Mock 依赖对象的所有方法;

控制器层单元测试

java
@ExtendWith(SpringExtension.class)
@WebMvcTest(UserController.class)
@ContextConfiguration(classes = TestApplication.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private UserUsecase userUsecase;

    @MockBean
    private TeamUsecase teamUsecase;

    @BeforeEach
    public void setUp() {
        var jagger = UserBO.builder().id(1L)
                .username("jaggerwang")
                .email("jaggerwang@gmail.com")
                .nickname("Jagger Wang")
                .build();

        Mockito.when(userUsecase.info(jagger.getId()))
                .thenReturn(Optional.of(jagger));

        Mockito.when(teamUsecase.findMemberByUserId(jagger.getId()))
                .thenReturn(List.of());
    }

    @Test
    public void whenValidId_thenUserShouldBeFound() throws Exception {
        var id = 1;

        mvc.perform(MockMvcRequestBuilders.get(String.format("/user/info/%d", id)))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.code", Matchers.is("OK")))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data.user.id", Matchers.is(id)));
    }
}
  1. 相比用例层单元测试,控制器层单元测试使用了 @ContextConfiguration 注解来指定了测试配置类,否则会自动向上搜索到主应用类,从而导致自动配置各种不需要的组件,还会引起报错;

单元测试编写好后,注意打开 CI/CD 流水线里 Maven 构建任务的测试开关,以便在每次构建时自动执行单元测试。

部署规范

配置规范

  1. src/main/resources 目录下的 application.yml 配置文件只存放环境无关的配置,并且按照严格模式来配置,比如关闭 debug、日志级别设为 info 或更严格;
  2. 环境相关的配置放到对应的环境配置目录 ops/<env> 下;
  3. Spring 默认会从 optional:classpath:/,optional:classpath:/config/,optional:file:./,optional:file:./config/ 目录里加载配置文件,可以通过 spring.config.location 参数来设置,或者通过 spring.config.additional-location 来添加额外的,更多细节可参考 External Application Properties
  4. 开发和测试环境为了调试方便可以使用 * 来暴露所有 Actuator 端点,生产环境请明确指定要暴露的端点,以免信息泄漏,Spring Boot 默认只会暴露 info 和 health 端点,更多细节可参考 Exposing Endpoints

资源使用规范

  1. 容器遵循单一职责原则,不要在单个容器里同时运行多种计算类型的任务,比如把前台 API 服务跟后台异步任务混合在一起。每种计算类型的任务应独立为单独的容器,一是可以避免任务之间相互影响,二是方便 Kubernetes 把不同计算类型的任务调度到对应的节点上并配置不同的升级策略,三是可以针对不同计算类型的任务配置重启策略或优雅终止方式。
  2. Java 8u191 及 Java 9 之后已经可以正确感知到容器资源限制,为了避免 JVM 堆内存设置跟容器限制不同步,请使用 InitialRAMPercentage(初始堆内存大小,默认为 25%)、MinRAMPercentage(容器内存小于 200MB 时的最大堆内存) 和 MaxRAMPercentage(容器内存大于 200MB 时的最大堆内存) 按百分比来设置堆内存用量;
  3. 为不同计算类型的任务设置合理的 Resource Limit,如果不清楚可以先按小的设,后续再按需扩容;

容器应用优雅终止规范

  1. Kubernetes 接收到停止 Pod 指令后,会将 Pod 标记为 Terminating 状态,并停止转发流量给该 Pod,然后通知容器运行时停止 Pod 下的各个容器;
  2. 容器运行时发送 TERM 信号(默认为 SIGTERM,可在 Dockerfile 里通过 STOPSIGNAL 指令修改)给容器内的所有进程,Kubernetes 等待该容器停止,等待时间默认为 30s(可通过 terminationGracePeriodSeconds 来修改);
  3. 容器内的应用如果需要优雅终止,那么需处理 SIGTERM 信号,等待现有任务完成、清理资源等;
  4. 如果等待容器停止超时,Kubernetes 将触发强制关闭过程,此时容器运行时会发送 SIGKILL 给容器内的所有进程,系统强制杀掉所有进程,然后容器终止;
  5. Kubernetes 执行其它 Pod 相关的资源清理操作;

参考资料:

  1. Pod 的生命周期
  2. Pod Termination

Spring 应用优雅关闭

  1. 为了避免应用关闭时强行终止当前请求的处理,可设置 server.shutdown=graceful,默认等待时间为 30s,该值可通过 spring.lifecycle.timeout-per-shutdown-phase 来修改;
  2. Spring 默认的 TaskExecutor 会强行终止正在执行的任务,可通过 setWaitForTasksToCompleteOnShutdown 方法来开启等待,为了避免无限等待,可通过 setAwaitTerminationSeconds 方法来设置等待时长(默认为 0);
  3. 对于长期运行的后台 Job,需要在合适的时机检查当前线程是否被中断,从而结束执行,否则会被强行终止;
  4. 无论是单个请求处理、TaskExecutor 终止等待时长,还是后台 Job 中断检查间隔时长,都不要超过 Kubernetes 的容器优雅终止等待时长(默认为 30s);

参考资料:

  1. Shutdown Spring Boot Applications Gracefully
  2. Graceful Shutdown of a Spring Boot Application
  3. Catching SIGTERM signal in Spring
  4. 中断线程