在完成 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 之中了。