gossip 服務(二)拆分


在完成 gossip 重構,將元件的耦合程度調整到適當程度之後,接下來就是拆分服務,打算怎麼做呢?具體而言,希望將〈gossip 服務(一)重構〉中的成果,拆分為 Email 服務、Account 服務、Message 服務:

gossip 服務(二)拆分

你也許會覺這很難,其實若元件之間職責清楚,耦合度低,這部份嚴格來說不難,就是要耐心與細心去定義出服務之間如何溝通,就範例練習來說,就是一次處理一個服務(當然,團隊合作時,就是各自去實作),如果在拆分服務時感到元件之間因為耦合而互有拉扯,請停下來繼續重構單一應用程式,別想著要拆分服務!

你會看到圖中有兩個資料庫伺服器,為什麼?個別服務會責責自己該塊業務上必要的資料儲存,也因此,可以依各自需求,選用適合的資料儲存方式!那原本資料查詢上會有 JOIN 之類的操作怎麼辦?若表格之間有緊密相關的操作,那這些表格可能屬於同一個服務,若表格數量眾多,而且有著複雜的操作關係,那資料庫表格之間也得做重構,否則的話,可能令單一服務過於巨大,承載了過多的職責。

gossip 在資料表格上只有兩個,而且並沒有 JOIN 之類的複雜操作(還好當初設計範例時就單純化 XD),可以直接分開在兩個資料庫伺服器儲存,不過,為了簡單一些,實際上這邊的成果只會用兩個資料庫檔案來代表。

而在拆分之後,gossip、Mail 服務、Account 服務、Message 服務,都是從組態伺服器讀取各自的組態,因此在 Git 伺服器上的組態也拆分為 gossip、emailsvi、acctsvi 與 messagesvi,而組態伺服器 configsvrspring.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 中的 AccountEmailServiceGmailService 複製過去,設定好 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"
        }
    }
}

回應中包含 nameemailpassword 等特性,客戶端可以直接轉換為 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());
    }
}

acctsviOptional<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 之中了。