在ThoughtWorks,我从零开始搭建了很多软件项目,其中包含了基础代码框架和持续集成基础设施等,这些内容在敏捷开发中一般被称为“第0个迭代”要作的事情。可是,当项目运行了一段时间以后再来反观,我总会发现一些不足的地方,要么测试分类没有分好,要么基本的编码架子没有考虑周全。php
另外,我在工做中也会接触到不少既有项目,公司内部和外部的都有,多数项目的编码实践我都是不满意的。好比,我曾经新加入一个项目的时候,前先后后请教了3位同事才把该项目在本地运行起来;又好比在另外一项目中,我发现前端请求对应的Java类命名规范不统一,有被后缀为Request的,也有被后缀为Command的。html
再者,工做了这么多年以后,我愈来愈发现基础知识以及系统性学习的重要性。诚然,技术框架的发展使得咱们能够快速地实现业务功能,可是当软件出了问题以后有时却须要将各方面的知识融会贯通并在大脑里综合反应才能找到解决思路。前端
基于以上,我但愿整理出一套公共性的项目模板出来,旨在尽可能多地包含平常开发之所需,减小开发者的重复性工做以及提供一些最佳实践。对于后端开发而言,我选择了当前被行业大量使用的Spring Boot,基于此整理出了一套公共的、基础性的实践方式,在结合了本身的经验以及其余项目的优秀实践以后,总结出本文以飨开发者。java
本文以一个简单的电商订单系统为例,源代码请访问:mysql
所使用的技术栈主要包括:Spring Boot、Gradle、MySQL、Junit 五、Rest Assured、Docker等。程序员
一份好的README能够给人以项目全景概览,可使新人快速上手项目,能够下降沟通成本。同时,README应该简明扼要,条理清晰,建议包含如下方面:github
须要注意的是,README中的信息可能随着项目的演进而改变(好比引入了新的技术栈或者加入了新的领域模型),所以也是须要持续更新的。虽然咱们知道,软件文档的一个痛点即是没法与项目实际进展保持同步,可是就README这点信息来说,仍是建议开发者们不要吝啬那一点点敲键盘的时间。redis
此外,除了保持README的持续更新,一些重要的架构决定能够经过示例代码的形式记录在代码库中,新开发者能够经过直接阅读这些示例代码快速了解项目的通用实践方式以及架构选择,请参考ThoughtWorks的技术雷达。spring
为了不诸如前文中所提到的“请教了3位同事才本地构建成功”的尴尬,为了减小“懒惰”的程序员们的手动操做,也为了为全部开发者提供一种一致的开发体验,咱们但愿用一个命令就能够完成全部的事情。这里,对于不一样的场景我总结出了如下命令:
idea.sh
,生成IntelliJ工程文件并自动打开IntelliJrun.sh
,本地启动项目,自动启动本地数据库,监听调试端口5005local-build.sh
,只有本地构建成功才能提交代码以上3个命令基本上能够完成平常开发之所需,此时,对于新人的开发流程大体为:
idea.sh
,自动打开IntelliJ;run.sh
,进行本地调试或必要的手动测试(本步骤不是必需);local-build.sh
,完成本地构建;local-build.sh
成功,提交代码。事实上,这些命令脚本的内容很是简单,好比run.sh
文件内容为:
#!/usr/bin/env bash
./gradlew clean bootRun
复制代码
然而,这种显式化的命令却能够减小新人的恐惧感,由于他们只须要知道运行这3个命令就能够搞开发了。另外,一个小小的细节:本地构建的local-build.sh
命令原本能够重命名为更简单的build.sh
,可是当咱们在命令行中使用Tab键自动补全的时候,会发现自动补全到了build
目录,而不是build.sh
命令,并不方便,所以命名为了local-build.sh
。细节虽小,可是却体现了一个宗旨,即咱们但愿给开发者一种极简的开发体验,我把这些看似微不足道的东西称做是对程序员的“人文关怀”。
Maven所提倡的目录结构当前已经成为事实上的行业标准,Gradle在默认状况下也采用了Maven的目录结构,这对于多数项目来讲已经足够了。此外,除了Java代码,项目中还存在其余类型的文件,好比Gradle插件的配置、工具脚本和部署配置等。不管如何,项目目录结构的原则是简单而有条理,不要随意地增长多余的文件夹,而且也须要及时重构。
在示例项目中,顶层只有2个文件夹,一个是用于放置Java源代码和项目配置的src
文件夹,另外一个是用于放置全部Gradle配置的gradle
文件夹,此外,为了方便开发人员使用,将上文提到的3个经常使用脚本直接放到根目录下:
└── order-backend
├── gradle // 文件夹,用于放置全部Gradle配置
├── src // 文件夹,Java源代码
├── idea.sh //生成IntelliJ工程
├── local-build.sh // 提交以前的本地构建
└── run.sh // 本地运行
复制代码
对于gradle
而言,咱们刻意地将Gradle插件脚本与插件配置放到了一块儿,好比Checkstyle:
├── gradle
│ ├── checkstyle
│ │ ├── checkstyle.gradle
│ │ └── checkstyle.xml
复制代码
事实上,在默认状况下Checkstyle插件会从项目根目录下的config
目录查找checkstyle.xml
配置文件,可是这一方面增长了多余的文件夹,另外一方面与该插件相关的设施分散在了不一样的地方,违背了广义上的内聚原则。
早年的Java分包方式一般是基于技术的,好比与domain包平级的有controller包、service包和infrastructure包等。这种方式当前并不被行业所推崇,而是应该首先基于业务分包。好比,在订单示例项目中,有两个重要的领域对象Order
和Product
(在DDD中称为聚合根),全部的业务都围绕它们展开,所以分别建立order包和product包,再分别在包下建立与之相关的各个子包。此时的order包以下:
├── order
│ ├── OrderApplicationService.java
│ ├── OrderController.java
│ ├── OrderNotFoundException.java
│ ├── OrderRepository.java
│ ├── OrderService.java
│ └── model
│ ├── Order.java
│ ├── OrderFactory.java
│ ├── OrderId.java
│ ├── OrderItem.java
│ └── OrderStatus.java
复制代码
能够看到,在order包下咱们直接放置了OrderController
和OrderRepository
等类,而没有必要再为这些类划分单独的子包。而对于领域模型Order来说,因为包含了多个对象,所以基于内聚性原则将它们归到model包中。可是这并非一个必须,若是业务足够简单,咱们甚至能够将全部类直接放到业务包下,product包即是如此:
└── product
├── Product.java
├── ProductApplicationService.java
├── ProductController.java
├── ProductId.java
└── ProductRepository.java
复制代码
在编码实践中,咱们老是基于一个业务用例来实现代码,在技术分包场景下,咱们须要在分散的各包中来回切换,增长了代码导航的成本;另外,代码提交的变动内容也是散落的,在查看代码提交历史时,没法直观的看出该次提交是关于什么业务功能的。在业务分包下,咱们只须要在单个统一的包下修改代码,减小了代码导航成本;另一个好处是,若是哪天咱们须要将某个业务迁移到另外的项目(好比识别出了独立的微服务),那么直接总体移动业务包便可。
固然,基于业务分包并不意味着全部的代码都必须囿于业务包下,这里的逻辑是:优先进行业务分包,而后对于一些不隶属于任何业务的代码能够单独分包,好比一些util类、公共配置等。好比咱们依然能够建立一个common包,下面放置了Spring公共配置、异常处理框架和日志等子包:
└── common
├── configuration
├── exception
├── loggin
└── utils
复制代码
在当前的微服务和先后端分离的开发模式下,后端项目仅提供纯粹的业务API,而不包含UI逻辑,所以后端项目不会再包含诸如WebDriver的重量级端到端测试。同时,后端项目做为向外提供业务功能的独立运行单元,在API级别也应该有相应的测试。
此外,程序中有些框架性代码,要么是诸如Controller之类的技术性框架代码,要么是基于某种架构风格的代码(好比DDD实践中的ApplicationService),这些代码一方面并不包含业务逻辑,一方面是很薄的一个抽象层(即实现相对简单),用单元测试来覆盖显得没有必要,所以笔者的观点是能够不为此编写单独的单元测试。再者,程序中有些重要的组件性代码,好比访问数据库的Repository或者分布式锁,使用单元测试实际上“测不到点上”,而使用API测试又显得在分类逻辑上不合理,为此咱们能够专门建立一种测试类型谓之组件测试。
基于以上,咱们能够对自动化测试作个分类:
Gradle在默认状况下只提供src/test/java
目录用于测试,对于以上3种类型的测试,咱们须要将它们分开以便于管理(也是职责分离的体现)。为此,能够经过Gradle提供的SourceSets对测试代码进行分类:
sourceSets {
componentTest {
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
}
apiTest {
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
}
}
复制代码
到此,3种类型的测试能够分别编写在如下目录:
src/test/java
src/componentTest/java
src/apiTest/java
须要注意的是,这里的API测试更多强调的是对业务功能的测试,有些项目中可能还会存在契约测试和安全测试等,虽然从技术上讲都是对API的访问,可是这些测试都是单独的关注点,所以建议分开对待。
值得一提的是,因为组件测试和API测试须要启动程序,也即须要准备好本地数据库,咱们采用了Gradle的docker-compose
插件(或者jib插件),该插件会在运行测试以前自动运行Docker容器(好比MySQL):
apply plugin: 'docker-compose'
dockerCompose {
useComposeFiles = ['docker/mysql/docker-compose.yml']
}
bootRun.dependsOn composeUp
componentTest.dependsOn composeUp
apiTest.dependsOn composeUp
复制代码
更多的测试分类配置细节,好比JaCoCo测试覆盖率配置等,请参考本文的示例项目代码。对Gradle不熟悉的读者能够参考笔者的Gradle学习系列文章。
在日志处理中,除了完成基本配置外,还有2个须要考虑的点:
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
//request id in header may come from Gateway, eg. Nginx
String headerRequestId = request.getHeader(HEADER_X_REQUEST_ID);
MDC.put(REQUEST_ID, isNullOrEmpty(headerRequestId) ? newUuid() : headerRequestId);
try {
filterChain.doFilter(request, response);
} finally {
clearMdc();
}
}
复制代码
<appender name="REDIS" class="com.cwbase.logback.RedisAppender">
<tags>ecommerce-order-backend-${ACTIVE_PROFILE}</tags>
<host>elk.yourdomain.com</host>
<port>6379</port>
<password>whatever</password>
<key>ecommerce-ordder-log</key>
<mdc>true</mdc>
<type>redis</type>
</appender>
复制代码
固然,统一日志的方案还有不少,好比Splunk和Graylog等。
在设计异常处理的框架时,须要考虑如下几点:
异常处理一般有两种形式,一种是层级式的,即每种具体的异常都对应了一个异常类,这些类最终继承自某个父异常;另外一种是单一式的,即整个程序中只有一个异常类,再以一个字段来区分不一样的异常场景。层级式异常的好处是可以显式化异常含义,可是若是层级设计很差可能致使整个程序中充斥着大量的异常类;单一式的好处是简单,而其缺点在于表意性不够。
本文的示例项目使用了层级式异常,全部异常都继承自一个AppException:
public abstract class AppException extends RuntimeException {
private final ErrorCode code;
private final Map<String, Object> data = newHashMap();
}
复制代码
这里,ErrorCode
枚举中包含了异常的惟一标识、HTTP状态码以及错误信息;而data
字段表示各个异常的上下文信息。
在示例系统中,在没有找到订单时抛出异常:
public class OrderNotFoundException extends AppException {
public OrderNotFoundException(OrderId orderId) {
super(ErrorCode.ORDER_NOT_FOUND, ImmutableMap.of("orderId", orderId.toString()));
}
}
复制代码
在返回异常给客户端时,经过一个ErrorDetail类来统一异常格式:
public final class ErrorDetail {
private final ErrorCode code;
private final int status;
private final String message;
private final String path;
private final Instant timestamp;
private final Map<String, Object> data = newHashMap();
}
复制代码
最终返回客户端的数据为:
{
requestId: "d008ef46bb4f4cf19c9081ad50df33bd",
error: {
code: "ORDER_NOT_FOUND",
status: 404,
message: "没有找到订单",
path: "/order",
timestamp: 1555031270087,
data: {
orderId: "123456789"
}
}
}
复制代码
能够看到,ORDER_NOT_FOUND
与data
中的数据结构是一一对应的,也即对于客户端来说,若是发现了ORDER_NOT_FOUND
,那么即可肯定data
中必定存在orderId
字段,进而完成精确的结构化解析。
除了即时完成客户端的请求外,系统中一般会有一些定时性的例行任务,好比按期地向用户发送邮件或者运行数据报表等;另外,有时从设计上咱们会对请求进行异步化处理。此时,咱们须要搭建后台任务相关基础设施。Spring原生提供了任务处理(TaskExecutor)和任务计划(TaskSchedulor)机制;而在分布式场景下,还须要引入分布式锁来解决并发冲突,为此咱们引入一个轻量级的分布式锁框架ShedLock。
启用Spring任务配置以下:
@Configuration
@EnableAsync
@EnableScheduling
public class SchedulingConfiguration implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(newScheduledThreadPool(10));
}
@Bean(destroyMethod = "shutdown")
@Primary
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(10);
executor.setTaskDecorator(new LogbackMdcTaskDecorator());
executor.initialize();
return executor;
}
}
复制代码
而后配置Shedlock:
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
public class DistributedLockConfiguration {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(dataSource);
}
@Bean
public DistributedLockExecutor distributedLockExecutor(LockProvider lockProvider) {
return new DistributedLockExecutor(lockProvider);
}
}
复制代码
实现后台任务处理:
@Scheduled(cron = "0 0/1 * * * ?")
@SchedulerLock(name = "scheduledTask", lockAtMostFor = THIRTY_MIN, lockAtLeastFor = ONE_MIN)
public void run() {
logger.info("Run scheduled task.");
}
复制代码
为了支持代码直接调用分布式锁,基于Shedlock的LockProvider建立DistributedLockExecutor:
public class DistributedLockExecutor {
private final LockProvider lockProvider;
public DistributedLockExecutor(LockProvider lockProvider) {
this.lockProvider = lockProvider;
}
public <T> T executeWithLock(Supplier<T> supplier, LockConfiguration configuration) {
Optional<SimpleLock> lock = lockProvider.lock(configuration);
if (!lock.isPresent()) {
throw new LockAlreadyOccupiedException(configuration.getName());
}
try {
return supplier.get();
} finally {
lock.get().unlock();
}
}
}
复制代码
使用时在代码中直接调用:
public String doBusiness() {
return distributedLockExecutor.executeWithLock(() -> "Hello World.",
new LockConfiguration("key", Instant.now().plusSeconds(60)));
}
复制代码
本文的示例项目使用了基于JDBC的分布式锁,事实上任何提供原子操做的机制均可用于分布式锁,Shedlock还提供基于Redis、ZooKeeper和Hazelcast等的分布式锁实现机制。
除了Checkstyle统一代码格式以外,项目中有些通用的公共的编码实践方式也须要在整个开发团队中进行统一,包括但不限于如下方面:
静态代码检查主要包含如下Gradle插件,具体配置请参考本文示例代码:
健康检查主要用于如下场景:
此时,能够实现一个简单的API接口,该接口不授权限管控,能够公开访问。若是该接口返回HTTP的200状态码,即可初步认为程序运行正常。此外,咱们还能够在该API中加入一些额外的信息,好比提交版本号、构建时间、部署时间等。
启动本文的示例项目:
./run.sh
复制代码
而后访问健康检查API:http://localhost:8080/about,结果以下:
{
requestId: "698c8d29add54e24a3d435e2c749ea00",
buildNumber: "unknown",
buildTime: "unknown",
deployTime: "2019-04-11T13:05:46.901+08:00[Asia/Shanghai]",
gitRevision: "unknown",
gitBranch: "unknown",
environment: "[local]"
}
复制代码
以上接口在示例项目中用了一个简单的Controller实现,事实上Spring Boot的Acuator框架也可以提供类似的功能。
软件文档的难点不在于写,而在于维护。多少次,当我对照着项目文档一步一步往下走时,总得不到正确的结果,问了同事以后获得回复“哦,那个已通过时了”。本文示例项目所采用的Swagger在必定程度上下降了API维护的成本,由于Swagger能自动识别代码中的方法参数、返回对象和URL等信息,而后自动地实时地建立出API文档。
配置Swagger以下:
@Configuration
@EnableSwagger2
@Profile(value = {"local", "dev"})
public class SwaggerConfiguration {
@Bean
public Docket api() {
return new Docket(SWAGGER_2)
.select()
.apis(basePackage("com.ecommerce.order"))
.paths(any())
.build();
}
}
复制代码
启动本地项目,访问http://localhost:8080/swagger-ui.html:
在传统的开发模式中,数据库由专门的运维团队或者DBA来维护,要对数据库进行修改须要向DBA申请,告之迁移内容,最后由DBA负责数据库变动实施。在持续交付和DevOps运动中,这些工做逐步提早到开发过程,固然并非说不须要DBA了,而是这些工做能够由开发者和运维人员一同完成。另外,在微服务场景下,数据库被包含在单个服务的边界以内,所以基于内聚性原则(咦,这好像是本文第三次提到内聚原则了,可见其在软件开发中的重要性),数据库的变动最好也与项目代码一道维护在代码库中。
本文的示例项目采用了Flyway做为数据库迁移工具,加入了Flyway依赖后,在src/main/sources/db/migration
目录下建立迁移脚本文件便可:
resources/
├── db
│ └── migration
│ ├── V1__init.sql
│ └── V2__create_product_table.sql
复制代码
迁移脚本的命名须要遵循必定的规则以保证脚本执行顺序,另外迁移文件生效以后不要任意修改,由于Flyway会检查文件的checksum,若是checksum不一致将致使迁移失败。
在软件的开发流程中,咱们须要将软件部署到多个环境,通过多轮验证后才能最终上线。在不一样的阶段中,软件的运行态多是不同的,好比本地开发时可能将所依赖的第三方系统stub掉;持续集成构建时可能使用的是测试用的内存数据库等等。为此,本文的示例项目推荐采用如下环境:
在先后端分离的系统中,前端单独部署,有时连域名都和后端不一样,此时须要进行跨域处理。传统的作法能够经过JSONP,但这是一种比较“trick”的作法,当前更通用的实践是采用CORS机制,在Spring Boot项目中,启用CORS配置以下:
@Configuration
public class CorsConfiguration {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**");
}
};
}
}
复制代码
对于使用Spring Security的项目,须要保证CORS工做于Spring Security的过滤器以前,为此Spring Security专门提供了相应配置:
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// by default uses a Bean by the name of corsConfigurationSource
.cors().and()
...
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
复制代码
这里列出一些比较常见的第三方库,开发者们能够根据项目所需引入:
本文经过一个示例项目谈及到了项目之初开发者搭建后端工程的诸多方面,其中的绝大多数实践均在笔者的项目中真实落地。读完本文以后你可能会发现,文中的不少内容都是很基础很简单的。没错,的确没有什么难的东西,可是要系统性地搭建好后端项目的基础框架却不见得是每一个开发团队都已经作到的事情,而这偏偏是本文的目的。最后,须要提醒的是,本文提到的实践方式只是一个参考,一方面依然存在考虑不周的地方,另外一方面示例项目中用到的技术工具还存在其余替代方案,请根据本身项目的实际状况进行取舍。
文/ThoughtWorks滕云 更多精彩洞见,请关注微信公众号:ThoughtWorks洞见