在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洞見