SpringBoot中的一些使用的技巧

偶然有机会看到一位波兰老哥写的文章,感觉不错,简单实用,因此翻译转载到这里,同时我也会略微增加一些我的个人评论和补充。顺便提一句这个波兰老哥写的东西都还挺不错的,有兴趣可以逛逛他的博客。原文链接见文章底部。

正文

在本文中,我将向你展示一些有助于高效构建Spring Boot应用程序的提示和技巧。我希望你能在Spring Boot开发中找到一些有助于提高生产力的技巧和方法。当然,这是我个人最喜欢的功能列表,你也可以自己尝试在别处寻找一些其他的技巧,例如Spring “How-to” 教学引导网站

我已经在 Twitter 上以图形形式发布了以上这些 Spring Boot 使用技巧。你可以使用 #springboottip 标签发布一些相关的推文,我是 Spring Boot 的超级粉丝,所以如果你有什么建议或者想展示你最喜欢的功能,请在Twitter上给我发消息(@piotr_minkowski),我一定会转发你的推文:slightly_smiling_face:

image

Source Code

如果你想自己尝试运行一下,你可以随时查看我的源代码。为此你需要克隆我的GitHub仓库,然后执行命令 mvn clean package spring-boot: run 来构建并运行示例应用程序。这个应用程序例子使用了嵌入式数据库H2并且暴露了一些REST API,它演示了本文中描述的所有特性。如果你有任何建议,欢迎创建pull request!

Tip 1. 在测试中使用随机HTTP端口

让我们从一些Spring Boot测试技巧开始。在Spring Boot测试中不应该使用静态端口,为了对指定的测试设置该选项,你需要设置 @SpringBootTest 注解中的 webEnvironment 字段,将它的指定为 RANDOM_PORT 而不是默认的 DEFINED_PORT 。配置完后你可以使用 @LocalServerPort 注解将这个随机生成的端口号注入到测试类中。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AppTest {

   @LocalServerPort
   private int port;

   @Test
   void test() {
      Assertions.assertTrue(port > 0);
   }
}

Tip 2. 使用@DataJpaTest来测试JPA接口

Tony: 这个注解很惊艳,相见恨晚,很多时候就是想测下JPA发的SQL有没有问题

对于集成测试,通常情况下你可能会使用 @SpringBootTest 来注释测试类,这样做的问题在于它启动了整个应用程序上下文,这会增加运行测试所需的总时间。更好的选择是:你可以使用 @DataJpaTest 来启动JPA组件和带有 @Repository 注解的bean。默认情况下它会在日志中记录SQL查询语句,因此一个好主意是使用 showSql 字段禁用这个特性。此外,如果你希望将带有 @Service@Component 注解的bean包含到测试中,可以使用 @Import 注解。

@DataJpaTest(showSql = false)
@Import(TipService.class)
public class TipsControllerTest {

    @Autowired
    private TipService tipService;

    @Test
    void testFindAll() {
        List<Tip> tips = tipService.findAll();
        Assertions.assertEquals(3, tips.size());
    }

}

注意:如果你的应用程序中有多个集成测试,那么在更改测试注解时要小心。由于这种更改会修改应用程序的全局上下文,因此这可能会导致在各个测试之间无法重用上下文。你可以在Philip Riecks的文章中了解更多。

Tip 3. 在每次测试执行后回滚事务

Tony: 这个Tip并不新鲜,还是很常见的,而且并不一定是内存数据库的情况下才用,通常测完回滚是必须的

从一个使用嵌入式内存数据库的样例展开来说,通常你应该回滚每个测试期间执行的所有更改,测试期间的变化不应影响其他测试的结果。但是,不要尝试手动回滚这些更改!例如不应编写删除语句来处理在测试期间新添加的实体,如下所示。

@Test
@Order(1)
public void testAdd() {
    Tip tip = tipRepository.save(new Tip(null, "Tip1", "Desc1"));
    Assertions.assertNotNull(tip);
    tipRepository.deleteById(tip.getId());
}

Spring Boot 为这种情况提供了非常方便的解决方案,你只需要用 @Transactional 注解测试类即可,回滚是这个注解在测试模式中的默认行为,因此这里除了这个注解之外不需要其他任何内容。但是请记住,它只能在客户端正常工作,如果应用程序在服务器端执行事务,是不会有回滚效果的。

@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Transactional
public class TipsRepositoryTest {

    @Autowired
    private TipRepository tipRepository;

    @Test
    @Order(1)
    public void testAdd() {
        Tip tip = tipRepository.save(new Tip(null, "Tip1", "Desc1"));
        Assertions.assertNotNull(tip);
    }

    @Test
    @Order(2)
    public void testFindAll() {
        Iterable<Tip> tips = tipRepository.findAll();
        Assertions.assertEquals(0, ((List<Tip>) tips).size());
    }
}

在某些情况下,你可能不会选择在测试中使用嵌入式内存数据库。比如你有一个复杂的数据结构,你可能希望检查已提交的数据,而不是在测试失败时进行调试。因此你需要使用外部数据库,并在每次测试后提交数据。在这种情况下,每次开始测试时你都应该进行清理。

Tip 4. 使用OR条件来组合多个Spring Condition

Tony: 用到的场景不多,因为定义Condition通常是自动配置包的作者要干的事情,业务使用时可能的场景是用 @ConditionalOnProperty 配合配置文件来使得某些Bean可配。不过还是很实用的,万一真用到了呢。

如果想要通过在 Spring bean 上添加 @Conditional 注解来定义复合型的生效条件,要怎么做呢?默认情况下Spring Boot将所有定义的条件以逻辑”与“的形式组合在一起,在下面可见的示例代码中,只有在 MyBean1MyBean2 同时存在并且定义了 multipleBeans.enabled 属性的情况下,目标bean才可用。

@Bean
@ConditionalOnProperty("multipleBeans.enabled")
@ConditionalOnBean({MyBean1.class, MyBean2.class})
public MyBean myBean() {
   return new MyBean();
}

为了定义多个以“或”逻辑组合的条件,你需要创建一个继承自 AnyNestedCondition 的子类,并将所有条件放在里面。然后你应该配合 @Conditional 来使用这个类,如下所示。

public class MyBeansOrPropertyCondition extends AnyNestedCondition {

    public MyBeansOrPropertyCondition() {
        super(ConfigurationPhase.REGISTER_BEAN);
    }

    @ConditionalOnBean(MyBean1.class)
    static class MyBean1ExistsCondition {}

    @ConditionalOnBean(MyBean2.class)
    static class MyBean2ExistsCondition {}

    @ConditionalOnProperty("multipleBeans.enabled")
    static class MultipleBeansPropertyExists {}

}

@Bean
@Conditional(MyBeansOrPropertyCondition.class)
public MyBean myBean() {
   return new MyBean();
}

Tip 5. 在应用中注入Maven数据

Tony: 不常见,但是确实是长了姿势,也许某些骚操作可以用到

有两种方法允许将Maven中的数据信息注入到应用程序中。其一,可以在 application.properties 配置文件中使用带有项目前缀和@分隔符的特殊占位符。

maven.app=@project.artifactId@:@project.version@

然后你需要使用 @Value 注解来将属性注入到应用程序当中。

@SpringBootApplication
public class TipsApp {

   @Value("${maven.app}")
   private String name;
}

除此之外,你也可以按照如下所示的方式使用 BuildProperties 这个Bean。它会读取存储在名为 build-info.properties 的配置文件中的数据。

@SpringBootApplication
public class TipsApp {

   @Autowired
   private BuildProperties buildProperties;

   @PostConstruct
   void init() {
      log.info("Maven properties: {}, {}", 
	     buildProperties.getArtifact(), 
	     buildProperties.getVersion());
   }
}

为了生成 build-info.properties 文件,你可以执行 Spring Boot Maven Plugin 提供的名为 build-info 的maven goal。

$ mvn package spring-boot:build-info

Tip 6. 在应用中注入Git数据

有时,你可能希望访问 Spring Boot 应用程序项目中的Git数据。为了做到这一点,首先需要在Maven pom中引入 git-commit-id-plugin 。在构建过程中,它会生成 git.properties 文件。

<plugin>
   <groupId>pl.project13.maven</groupId>
   <artifactId>git-commit-id-plugin</artifactId>
   <configuration>
      <failOnNoGitDirectory>false</failOnNoGitDirectory>
   </configuration>
</plugin>

最后,你可以通过使用 GitProperties 这个Bean来注入 git.properties 文件中的内容。

@SpringBootApplication
public class TipsApp {

   @Autowired
   private GitProperties gitProperties;

   @PostConstruct
   void init() {
      log.info("Git properties: {}, {}", 
	     gitProperties.getCommitId(), 
	     gitProperties.getCommitTime());
   }
}

Tip 7. 插入非生产用的初始数据

Tony: data.sql的生效逻辑参见 org.springframework.boot.autoconfigure.jdbc.DataSourceInitializer 。如果在启动时执行一些命令,除了注册启动完成事件的监听器,也可以利用 ApplicationRunner 和 CommandLineRunner 接口

有时,为了做一个demo的效果,你需要在应用程序启动时插入一些数据。在开发过程中,你也可以使用这样的初始数据集来手动测试应用程序。为了实现这一目的,你只需要将 data.sql 文件放在类路径上。通常,你会把它放在 src/main/resources 目录中的某个位置,然后你可以在非开发构建期间轻松地过滤掉这样的文件。

insert into tip(title, description) values ('Test1', 'Desc1');
insert into tip(title, description) values ('Test2', 'Desc2');
insert into tip(title, description) values ('Test3', 'Desc3');

但是,如果你需要生成一个大型数据集,或者你只是不确定是否能使用sql文件的解决方案,那么可以选择通过编程方式插入数据。在这种情况下,只在特定的profile中激活执行的特性是很重要的。

@Profile("demo")
@Component
public class ApplicationStartupListener implements 
      ApplicationListener<ApplicationReadyEvent> {

   @Autowired
   private TipRepository repository;

   public void onApplicationEvent(final ApplicationReadyEvent event) {
      repository.save(new Tip("Test1", "Desc1"));
      repository.save(new Tip("Test2", "Desc2"));
      repository.save(new Tip("Test3", "Desc3"));
   }
}

Tip 8. 使用Configuration properties代替@Value注解

Tony: 倒不一定要用构造函数注入,重点是使用 @ConfigurationProperties 来整合同一系列的配置项,这样更加优雅简洁

如果你有多个前缀相同的属性(例如 app) ,则不应使用 @Value 来注入这些内容。更好的方式是使用 @ConfigurationProperties 配合进行构造函数注入,同时你可以搭配使用 Lombok 的 @AllArgsConstructor@Getter

@ConstructorBinding
@ConfigurationProperties("app")
@AllArgsConstructor
@Getter
@ToString
public class TipsAppProperties {
    private final String name;
    private final String version;
}

@SpringBootApplication
public class TipsApp {

    @Autowired
    private TipsAppProperties properties;
	
}

Tip 9. Spring MVC异常处理

Tony: 推荐的第二种方法一般就是图个方便,不过还是挺巧妙的。在 REST API 的场景下,可以考虑继承 ResponseEntityExceptionHandler 并覆盖其中的方法,它已经配置处理了一些常见的异常

Spring MVC 异常处理对于确保不向客户端发送服务内部异常信息来说非常重要。目前,在处理异常时有两种推荐的方法。在第一个例子中,你将使用一个带有 @ControllerAdvice@ExceptionHandler 注解的全局异常处理器。显然,一个好的实践是捕捉处理应用程序抛出的所有业务异常,并为它们分配对应的HTTP状态码。默认情况下,Spring MVC 为未处理的异常返回HTTP 500状态码。

@ControllerAdvice
public class TipNotFoundHandler {

    @ResponseStatus(HttpStatus.NO_CONTENT)
    @ExceptionHandler(NoSuchElementException.class)
    public void handleNotFound() {

    }
}

你也可以在 Controller 方法中内部处理所有异常。在这种情况下,你只需要抛出 ResponseStatusException 并指定HTTP状态码。

@GetMapping("/{id}")
public Tip findById(@PathVariable("id") Long id) {
   try {
      return repository.findById(id).orElseThrow();
   } catch (NoSuchElementException e) {
      log.error("Not found", e);
      throw new ResponseStatusException(HttpStatus.NO_CONTENT);
   }
}

Tip 10. 忽略不存在的配置文件

通常,如果配置文件不存在,应用程序不应该无法启动,尤其是在你可以为属性设置默认值的情况下。由于 Spring 应用程序的默认行为是在缺少配置文件的情况下无法启动,因此需要对其进行更改。在启动时将 spring.config.on-not-found 属性设置为ignore。

$ java -jar target/spring-boot-tips.jar \
   --spring.config.additional-location=classpath:/add.properties \
   --spring.config.on-not-found=ignore

此外还有一个方便的解决方案来避免启动失败:在指定配置文件的位置中使用optional关键字,如下所示。

$ java -jar target/spring-boot-tips.jar \
   --spring.config.additional-location=optional:classpath:/add.properties

Tip 11. 多层级配置

Tony: 这个Tip指的是手动指定配置文件位置的情况。默认情况下Spring Boot本身就会读取多个位置的配置文件,默认的外部配置文件位置和优先级可以参考官方文档

可以使用 Spring.config.location 属性更改 Spring 配置文件的默认位置。属性源的优先级由列表中文件的顺序决定,排在最后的最重要。这个特性允许您定义不同级别的配置,从常规设置开始,到最具体的应用程序设置。因此,假设我们有一个全局配置文件,其中的内容如下。

property1=Global property1
property2=Global property2

同时,我们还有一个特定应用程序的配置文件,如下所示。它包含与全局配置文件中的属性名相同的属性。

property1=App specific property1

下面是一个用来验证这个特性的 JUnit 测试。

@SpringBootTest(properties = {
    "spring.config.location=classpath:/global.properties,classpath:/app.properties"
})
public class TipsAppTest {

    @Value("${property1}")
    private String property1;
    @Value("${property2}")
    private String property2;

    @Test
    void testProperties() {
        Assertions.assertEquals("App specific property1", property1);
        Assertions.assertEquals("Global property2", property2);
    }
}

Tip 12. 在Kubernetes上部署Spring Boot

通过使用 Dekarate 项目,你不需要手动创建任何 Kubernetes YAML manifest文件。首先,你需要引入 io.dekorate: kubernetes-spring-starter 依赖项。然后,使用 @KubernetesApplication 之类的注解来向生成的 YAML manifest文件内添加一些新的参数,或者重写默认值。

@SpringBootApplication
@KubernetesApplication(replicas = 2,
    envVars = { 
       @Env(name = "propertyEnv", value = "Hello from env!"),
       @Env(name = "propertyFromMap", value = "property1", configmap = "sample-configmap") 
    },
    expose = true,
    ports = @Port(name = "http", containerPort = 8080),
    labels = @Label(key = "version", value = "v1"))
@JvmOptions(server = true, xmx = 256, gc = GarbageCollector.SerialGC)
public class TipsApp {

    public static void main(String[] args) {
        SpringApplication.run(TipsApp.class, args);
    }

}

之后,在 Maven build 命令中将 dekorate.builddekorate.deploy 参数设置为 true。它将自动生成 manifest 并将 Spring Boot 应用程序部署到 Kubernetes 上。如果你使用 Skaffold 来在 Kubernetes 上部署应用程序,那么你可以轻松地将其与 Dekorate 集成。欲了解更多详情,请参阅以下文章

$ mvn clean install -Ddekorate.build =true -Ddekorate.deploy=true

Tip 13. 生成随机HTTP端口

Tony: random占位符的秘密在 RandomValuePropertySource 中

最后,我们将继续学习本文描述的最后一个 Spring Boot 小技巧。也许你知道这个特性,但我必须在这里提一下。如果你将 server.port 属性设置为0,Spring Boot 将为 web 应用程序分配一个随机的未被占用的端口。

server.port=0

你也可以在自定义的预设范围内设置随机端口,例如8000 - 8100。但是这样不能保证生成的端口是一个可以用的未被占用的端口。

server.port=${random.int(8000,8100)}

原文:Spring Boot Tips, Tricks and Techniques | Tony's toy blog