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

正文

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

目录:

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

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注解将这个随机生成的端口号注入到测试类中。

1
2
3
4
5
6
7
8
9
10
11
@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注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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并不新鲜,还是很常见的,而且并不一定是内存数据库的情况下才用,通常测完回滚是必须的

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

1
2
3
4
5
6
7
@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注解测试类即可,回滚是这个注解在测试模式中的默认行为,因此这里除了这个注解之外不需要其他任何内容。但是请记住,它只能在客户端正常工作,如果应用程序在服务器端执行事务,是不会有回滚效果的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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才可用。

1
2
3
4
5
6
@Bean
@ConditionalOnProperty("multipleBeans.enabled")
@ConditionalOnBean({MyBean1.class, MyBean2.class})
public MyBean myBean() {
return new MyBean();
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 配置文件中使用带有项目前缀和@分隔符的特殊占位符。

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

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

1
2
3
4
5
6
@SpringBootApplication
public class TipsApp {

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
@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。

1
$ mvn package spring-boot:build-info

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

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

1
2
3
4
5
6
7
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
<configuration>
<failOnNoGitDirectory>false</failOnNoGitDirectory>
</configuration>
</plugin>

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

1
2
3
4
5
6
7
8
9
10
11
12
13
@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 目录中的某个位置,然后你可以在非开发构建期间轻松地过滤掉这样的文件。

1
2
3
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中激活执行的特性是很重要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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状态码。

1
2
3
4
5
6
7
8
9
@ControllerAdvice
public class TipNotFoundHandler {

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

}
}

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

1
2
3
4
5
6
7
8
9
@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。

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

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

1
2
$ 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 配置文件的默认位置。属性源的优先级由列表中文件的顺序决定,排在最后的最重要。这个特性允许您定义不同级别的配置,从常规设置开始,到最具体的应用程序设置。因此,假设我们有一个全局配置文件,其中的内容如下。

1
2
property1=Global property1
property2=Global property2

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

1
property1=App specific property1

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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文件内添加一些新的参数,或者重写默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 集成。欲了解更多详情,请参阅以下文章

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

Tip 13. 生成随机HTTP端口

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

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

1
server.port=0

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

1
server.port=${random.int(8000,8100)}
再谈转行
立夏随笔