在完成 gossip 重構,將元件的耦合程度調整到適當程度之後,接下來就是拆分服務,打算怎麼做呢?具體而言,希望將〈gossip 服務(一)重構〉中的成果,拆分為 Email 服務、Account 服務、Message 服務:
你也許會覺這很難,其實若元件之間職責清楚,耦合度低,這部份嚴格來說不難,就是要耐心與細心去定義出服務之間如何溝通,就範例練習來說,就是一次處理一個服務(當然,團隊合作時,就是各自去實作),如果在拆分服務時感到元件之間因為耦合而互有拉扯,請停下來繼續重構單一應用程式,別想著要拆分服務!
你會看到圖中有兩個資料庫伺服器,為什麼?個別服務會責責自己該塊業務上必要的資料儲存,也因此,可以依各自需求,選用適合的資料儲存方式!那原本資料查詢上會有 JOIN 之類的操作怎麼辦?若表格之間有緊密相關的操作,那這些表格可能屬於同一個服務,若表格數量眾多,而且有著複雜的操作關係,那資料庫表格之間也得做重構,否則的話,可能令單一服務過於巨大,承載了過多的職責。
gossip 在資料表格上只有兩個,而且並沒有 JOIN 之類的複雜操作(還好當初設計範例時就單純化 XD),可以直接分開在兩個資料庫伺服器儲存,不過,為了簡單一些,實際上這邊的成果只會用兩個資料庫檔案來代表。
而在拆分之後,gossip、Mail 服務、Account 服務、Message 服務,都是從組態伺服器讀取各自的組態,因此在 Git 伺服器上的組態也拆分為 gossip、emailsvi、acctsvi 與 messagesvi,而組態伺服器 configsvr 的 spring.cloud.config.server.git.searchPath,必須從不同的路徑,讀取各自不同的組態:
spring.cloud.config.server.git.uri=https://github.com/JustinSDK/cloud-config-demo
spring.cloud.config.server.git.searchPaths=gossip-services/gossip,gossip-services/emailsvi,gossip-services/msgsvi,gossip-services/acctsvi
服務的拆分,基本上有些相同的動作,若先從 Mail 服務開始處理,可以建立一個 emailsvi 專案,將 gossip 中的 Account、EmailService、GmailService 複製過去,設定好 build.gradle,而 bootstrap.properties 的內容主要是組態伺服器上的組態讀取 emailsvi 設定:
server.port=8081
spring.application.name=emailsvi
spring.profiles.active=default
spring.cloud.config.uri=http://localhost:8888
在 Mail 服務中,Account 實際上不被當成是個儲存實體,因此 Account 中的 id 等程式碼可以去除,基本上這已經是個獨立的服務了,因此 Account 等程式碼需要怎麼修改,都跟原本的 gossip 無關,重點在於這個服務提供了什麼樣的 REST 介面。
EmailService 定義的方法不傳回值,那麼 REST 介面上該怎麼定義呢?自定義個 JSON 格式來表示請求處理成功?別忘了,可以善用 HTTP 回應狀態碼,在這邊採用 204 來表示請求處理完畢,然而沒有狀態碼之外的回應內容:
package cc.openhome.controller;
...
@RestController
public class MailController {
@Autowired
private EmailService emailService;
@PostMapping("validationLink")
@ResponseStatus(code = HttpStatus.NO_CONTENT)
public void validationLink(@RequestBody Account acct) {
emailService.validationLink(acct);
}
@PostMapping("failedRegistration/{acctName}/{acctEmail}")
@ResponseStatus(code = HttpStatus.NO_CONTENT)
public void failedRegistration(@PathVariable("acctName") String acctName, @PathVariable("acctEmail") String acctEmail) {
emailService.failedRegistration(acctName, acctEmail);
}
@PostMapping("passwordResetLink")
@ResponseStatus(code = HttpStatus.NO_CONTENT)
public void passwordResetLink(@RequestBody Account acct) {
emailService.passwordResetLink(acct);
}
}
在程式面上,@RequestBody 表示可以接受 JSON 作為請求本體,Spring 會負責轉換為 Account 實例,在抽取出 Mail 服務之後,對服務進行測試(像是透過 Postman 或者寫個 RestTemplate 等),確定它可以接受請求並做出正確回應。
確定 Mail 服務可以獨立地運作之後,就可以來調整 gossip,將原本的 GmailService 刪除,實作 EMailServiceRest,對 EmailService 的委託,全部轉發給 emailsvi:
package cc.openhome.model;
...略
@Service
public class EMailServiceRest implements EmailService {
@Autowired
private RestTemplate restTemplate;
@Override
public void validationLink(Account acct) {
RequestEntity<Account> request = RequestEntity
.post(URI.create("http://localhost:8081/validationLink/"))
.contentType(MediaType.APPLICATION_JSON)
.body(acct);
restTemplate.exchange(request, String.class);
}
@Override
public void failedRegistration(String acctName, String acctEmail) {
RequestEntity<Void> request = RequestEntity
.post(URI.create(String.format("http://localhost:8081/failedRegistration/%s/%s", acctName, acctEmail)))
.build();
restTemplate.exchange(request, String.class);
}
@Override
public void passwordResetLink(Account acct) {
RequestEntity<Account> request = RequestEntity
.post(URI.create("http://localhost:8081/passwordResetLink/"))
.contentType(MediaType.APPLICATION_JSON)
.body(acct);
restTemplate.exchange(request, String.class);
}
}
這麼一來,gossip 中的 Email 元件就變成取用 Email 服務了,當然,過程中有些細節,例如,gossip 中 ]build.gradle 設定上的增減、建立 RestTemplate實例等,最後我會提供全部專案結果作為參考。
拆分 Account 服務的流程基本上類似,因為 Account 服務有自己的資料庫,與帳戶相關的表格,現在儲存至 acctsvi.mv.db,因此你會看到 acctsvi 在 Git 上的組態 是這麼寫的:
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:tcp://localhost/c:/workspace/acctsvi/acctsvi
spring.datasource.username={cipher}41a0d800c2a1dc55348ddf3c4cabccf53a6de921be7b761c7646faeefe1aadbe
spring.datasource.password={cipher}55ae5203e663abf372e4a4068e466eeb81f85d26a59fe6f0af7e3b3a817d872a
不過你可能會問,AccountService 有些方法傳回了 Optional<Account>,這怎麼處理呢?在這邊打算使用 HAL 的 JSON 格式,透過 Spring HATEOAS 的支援),而 Spring MVC 可以處理 Optional<Account>,只要直接用 Resource 來包裝 Optional<Account> 就可以了,例如:
...略
@RestController
public class AcctController {
...略
@GetMapping("accountByNameEmail")
public Resource<Optional<Account>> accountByNameEmail(@RequestParam("username") String username, @RequestParam String email) {
String uri = String.format("%s/accountByNameEmail?username=%s&email=%s", linkTo(AcctController.class), username, email);
return new Resource<>(accountService.accountByNameEmail(username, email), new Link(uri));
}
}
如果 Optional<Account> 中有值,那麼會像是以下的 JSON 回應:
{
"name": "caterpillar",
"email": "caterpillar@openhome.cc",
"password": "$2a$10$CEkPOmd.Uid2FpIOHA6Cme1G.mvhWfelv2hPu7cxZ/vq2drnXaVo.",
"_links": {
"self": {
"href": "http://192.168.8.100:8084/accountByNameEmail?username=caterpillar&email=caterpillar@openhome.cc"
}
}
}
回應中包含 name、email、password 等特性,客戶端可以直接轉換為 Account 實例,若 Optional<Account> 不含值的話,那回應會是:
{
"_links": {
"self": {
"href": "http://192.168.8.100:8084/accountByNameEmail?username=caterpillar&email=caterpillar@openhom"
}
}
}
客戶端無法取得相關資料來建立 Account 實例,因此若可以這樣撰寫客戶端:
package cc.openhome.model;
...略
@Service
public class AccountServiceRest implements AccountService {
@Autowired
private RestTemplate restTemplate;
...略
public Optional<Account> accountByNameEmail(String name, String email) {
RequestEntity<Void> request = RequestEntity
.get(URI.create(String.format("http://localhost:8084/accountByNameEmail?username=%s&email=%s", name, email)))
.build();
ResponseEntity<Resource<Account>> response =
restTemplate.exchange(request, new TypeReferences.ResourceType<Account>() {});
return Optional.ofNullable(response.getBody().getContent());
}
}
在 acctsvi 的 Optional<Account> 有值時,客戶端可以順利建立 Account,若無值的話會取得 null,因此可以使用 Optional.ofNullable 來銜接。
你可以試著再抽出 Message 服務,因為 gossip 本身的控制器,都是透過介面隔離了變化,在服務的抽取過程中,gossip 的控制器是不用修改的。
最後是 Spring Security,原本是透過 JDBC 連線資料庫來查詢,現在想改透過 Account 服務來取得使用者的細節,因此,Account 服務需要提供 accountByName 的介面,為此 AccountService 也要增加 accountByName 方法與相對應的實作,gossip 也必須做對應的變更,接著,修改 Spring Security 的設定:
@Bean
public WebSecurityConfigurerAdapter webSecurityConfig() {
return new WebSecurityConfigurerAdapter() {
...略
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(username -> {
Optional<Account> maybeAcct = accountService.accountByName(username);
if(maybeAcct.isPresent()) {
Account acct = maybeAcct.get();
return new User(
username,
acct.getPassword(),
Arrays.asList(new SimpleGrantedAuthority("ROLE_MEMBER"))
);
}
return null;
});
}
};
}
由於 gossip 本身已經不需要資料庫相關設定了,因此也不用 spring.cloud.config.uri 了;在抽取出服務之後,gossip 本身幾乎就只剩呈現層了,也就是剩一層介面,調用後端的服務,gossip 要怎麼變化,例如修改提供 REST API,作為前端 JavaScript、手機 App 呼叫、實現前後端分離,或者是自己也成為一個服務等,都可以自行演化了。
以上範例相對來說是比較簡單的,有時抽取出來的服務,可能還是需要與單一應用程式溝通,取得單一應用程式中的狀態或存取其資料庫,這時中間會需要個彼此溝通的介面,也許是膠合用的程式碼,或者是提供雙向的 API,有時在抽取服務的過程中,可能還會發生需求增加的情況,這時新增的需求可以考慮,不要直接加入單一應用程式,試著實作為服務,定義出與單一應用程式溝通的方式,避免單一應用程式更加臃腫。
基本上一個人練習這個專案,需要的技術之前基本上都談過了,需要的就是整體架構上該怎麼規劃,以及有範例可以參考,因此,我將完成的成果放在 GossipSvi/2nd 之中了。

