使用 Dubbo 對遺留單體系統進行微服務改造

使用 Dubbo 對遺留單體系統進行微服務改造

來自專欄我是程序員

Credit: Justin Kenneth Rowley. You can find the original photo at flickr.

The microservices style of architecture highlights rising abstractions in the developer world because of containerization and the emphasis on low coupling, offering a high level of operational isolation. Developers can think of a container as a self-contained process and the PaaS as the common deployment target, using the microservices architecture as the common style. Decoupling the architecture allows the same for teams, cutting down on coordination cost among silos. Its attractiveness to both developers and DevOps has made this the de facto standard for new development in many organizations.

在 2016 年 11 月份的《技術雷達》中,ThoughtWorks 給予了微服務很高的評價。同時,也有越來越多的組織將實施微服務作為架構演進的一個必選方向。只不過在擁有眾多遺留系統的組織內,將曾經的單體系統拆分為微服務並不是一件容易的事情。本文將從對遺留系統進行微服務改造的原則要求出發,探討如何使用 Dubbo 框架實現單體系統向微服務的遷移。

一、原則要求

想要對標準三層架構的單體系統進行微服務改造——簡言之——就是將曾經單一進程內服務之間的本地調用改造為跨進程的分散式調用。這雖然不是微服務改造的全部內容,但卻直接決定了改造前後的系統能否保持相同的業務能力,以及改造成本的多少。

1.1 適合的框架

在微服務領域,雖然技術棧眾多,但無非 RPC 與 RESTful 兩個流派,這其中最具影響力的代表當屬 Dubbo 與 Spring Cloud 了 。他們擁有相似的能力,卻有著截然不同的實現方式——本文並不是想要對微服務框架的選型過程進行深入剖析,也不想對這兩種框架的孰優孰劣進行全面比較——本章所提到的全部這些原則要求都是超越具體實現的,其之於任何微服務框架都應該是適用的。讀者朋友們大可以把本文中的 Dubbo 全部替換為 Spring Cloud,而並不會對最終結果造成任何影響,唯一需要改變的僅僅是實現的細節過程而已。因此,無論最後抉擇如何,都是無所謂對錯的,關鍵在於:要選擇符合組織當下現狀的最適合的那一個。

1.2 方便的將服務暴露為遠程介面

單體系統,服務之間的調用是在同一個進程內完成的;而微服務,是將獨立的業務模塊拆分到不同的應用系統中,每個應用系統可以作為獨立的進程來部署和運行。因此進行微服務改造,就需要將進程內方法調用改造為進程間通信。進程間通信的實現方式有很多種,但顯然基於網路調用的方式是最通用且易於實現的。那麼能否方便的將本地服務暴露為網路服務,就決定了暴露過程能否被快速實施,同時暴露的過程越簡單則暴露後的介面與之前存在不一致性的風險也就越低。

1.3 方便的生成遠程服務調用代理

當服務被暴露為遠程介面以後,進程內的本地實現將不復存在。簡化調用方的使用——為遠程服務生成相應的本地代理,將底層網路交互細節進行深層次的封裝——就顯得十分必要。另外遠程服務代理在使用與功能上不應該與原有本地實現有任何差別。

1.4 保持原有介面不變或向後兼容

在微服務改造過程中,要確保介面不變或向後兼容,這樣才不至於對調用方產生巨大影響。在實際操作過程中,我們有可能僅僅可以掌控被改造的系統,而無法訪問或修改調用方系統。倘若介面發生重大變化,調用方系統的維護人員會難以接受:這會對他們的工作產生不可預估的風險和衝擊,還會因為適配新介面而產生額外的工作量。

1.5 保持原有的依賴注入關係不變

基於 Spring 開發的遺留系統,服務之間通常是以依賴注入的方式彼此關聯的。進行微服務改造後,原本注入的服務實現變成了本地代理,為了盡量減少代碼變更,最好能夠自動將注入的實現類切換為本地代理。

1.6 保持原有代碼的作用或副作用效果不變

這一點看上去有些複雜,但卻是必不可少的。改造後的系統跟原有系統保持相同的業務能力,當且僅當改造後的代碼與原有代碼保持相同的作用甚至是副作用。這裡要額外提及的是副作用。我們在改造過程中可以很好的關注一般作用效果,卻往往會忽視副作用的影響。舉個例子,Java 內部進行方法調用的時候參數是以引用的方式傳遞的,這意味著在方法體中可以修改參數里的值,並將修改後的結果「返回」給被調用方。看下面的例子會更容易理解:

public void innerMethod(Map map) { map.put("key", "new");}

public void outerMethod() {

Map map = new HashMap<>();

map.put("key", "old");

System.out.println(map); // {key=old}

this.innerMethod(map);

System.out.println(map); // {key=new}

}

這段代碼在同一個進程中運行是沒有問題的,因為兩個方法共享同一片內存空間,innerMethodmap 的修改可以直接反映到 outerMethod 方法中。但是在微服務場景下事實就並非如此了,此時 innerMethodouterMethod 運行在兩個獨立的進程中,進程間的內存相互隔離,innerMethod修改的內容必須要主動回傳才能被 outerMethod 接收到,僅僅修改參數里的值是無法達到回傳數據的目的的。

此處副作用的概念是指在方法體中對傳入參數的內容進行了修改,並由此對外部上下文產生了可察覺的影響。顯然副作用是不友好且應該被避免的,但由於是遺留系統,我們不能保證其中不會存在諸如此類寫法的代碼,所以我們還是需要在微服務改造過程中,對副作用的影響效果進行保持,以獲得更好的兼容性。

1.7 盡量少改動(最好不改動)遺留系統的內部代碼

多數情況下,並非所有遺留系統的代碼都是可以被平滑改造的:比如,上面提到的方法具有副作用的情況,以及傳入和傳出參數為不可序列化對象(未實現 Serializable 介面)的情況等。我們雖然不能百分之百保證不對遺留系統的代碼進行修改,但至少應該保證這些改動被控制在最小範圍內,盡量採取變通的方式——例如添加而不是修改代碼——這種僅添加的改造方式至少可以保證代碼是向後兼容的。

1.8 良好的容錯能力

不同於進程內調用,跨進程的網路通信可靠性不高,可能由於各種原因而失敗。因此在進行微服務改造的時候,遠程方法調用需要更多考慮容錯能力。當遠程方法調用失敗的時候,可以進行重試、恢復或者降級,否則不加處理的失敗會沿著調用鏈向上傳播(冒泡),從而導致整個系統的級聯失敗。

1.9 改造結果可插拔

針對遺留系統的微服務改造不可能保證一次性成功,需要不斷嘗試和改進,這就要求在一段時間內原有代碼與改造後的代碼並存,且可以通過一些簡單的配置讓系統在原有模式和微服務模式之間進行無縫切換。優先嘗試微服務模式,一旦出現問題可以快速切換回原有模式(手動或自動),循序漸進,直到微服務模式變得穩定。

1.10 更多

當然微服務改造的要求遠不止上面提到的這些點,還應該包括諸如:配置管理、服務註冊與發現、負載均衡、網關、限流降級、擴縮容、監控和分散式事務等,然而這些需求大部分是要在微服務系統已經升級改造完畢,複雜度不斷增加,流量上升到一定程度之後才會遇到和需要的,因此並不是本文關注的重點。但這並不意味著這些內容就不重要,沒有他們微服務系統同樣也是無法正常、平穩、高速運行的。

二、模擬一個單體系統

2.1 系統概述

我們需要構建一個具有三層架構的單體系統來模擬遺留系統,這是一個簡單的 Spring Boot 應用,項目名叫做 hello-dubbo。本文涉及到的所有源代碼均可以到 Github 上查看和下載。

首先,系統存在一個模型 User 和對該模型進行管理的 DAO,並通過 UserService 向上層暴露訪問 User 模型的介面;另外,還存在一個 HelloService,其調用 UserService 並返回一條問候信息;之後,由 Controller 對外暴露 RESTful 介面;最終再通過 Spring Boot 的 Application 整合成一個完整應用。

2.2 模塊化拆分

通常來說,一個具有三層架構的單體系統,其 Controller、Service 和 DAO 是存在於一整個模塊內的,如果要進行微服務改造,就要先對這個整體進行拆分。拆分的方法是以 Service 層為分界,將其分割為兩個子模塊:Service 層往上作為一個子模塊(稱為 hello-web),對外提供 RESTful 介面;Service 層往下作為另外一個子模塊(稱為 hello-core),包括 Service、DAO 以及模型。hello-corehello-web 依賴。當然,為了更好的體現面向契約的編程精神,可以把 hello-core 再進一步拆分:所有的介面和模型都獨立出來,形成 hello-api,而 hello-core 依賴 hello-api。最終,拆分後的模塊關係如下:

hello-dubbo|-- hello-web(包含 Application 和 Controller)|-- hello-core(包含 Service 和 DAO 的實現) |-- hello-api(包含 Service 和 DAO 的介面以及模型)

2.3 核心代碼分析

2.3.1 User

public class User implements Serializable { private String id; private String name; private Date createdTime;

public String getId() {

return this.id;

}

public void setId(String id) {

this.id = id;

}

public String getName() {

return this.name;

}

public void setName(String name) {

this.name = name;

}

public Date getCreatedTime() {

return this.createdTime;

}

public void setCreatedTime(Date createdTime) {

this.createdTime = createdTime;

}

@Override

public String toString() {

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");if (this.getCreatedTime() != null) { return String.format("%s (%s)", this.getName(), sdf.format(this.getCreatedTime()));}return String.format("%s (N/A)", this.getName());

}

}

User 模型是一個標準的 POJO,實現了 Serializable 介面(因為模型數據要在網路上傳輸,因此必須能夠支持序列化和反序列化)。為了方便控制台輸出,這裡覆蓋了默認的 toString 方法。

2.3.2 UserRepository

public interface UserRepository { User getById(String id);

void create(User user);

}

UserRepository 介面是訪問 User 模型的 DAO,為了簡單起見,該介面只包含兩個方法:getByIdcreate

2.3.3 InMemoryUserRepository

@Repositorypublic class InMemoryUserRepository implements UserRepository { private static final Map STORE = new HashMap<>();

static {

User tom = new User();tom.setId("tom");tom.setName("Tom Sawyer");tom.setCreatedTime(new Date());STORE.put(tom.getId(), tom);

}

@Override

public User getById(String id) {

return STORE.get(id);

}

@Override

public void create(User user) {

STORE.put(user.getId(), user);

}

}

InMemoryUserRepositoryUserRepository 介面的實現類。該類型使用一個 Map 對象 STORE 來存儲數據,並通過靜態代碼塊向該對象內添加了一個默認用戶。getById 方法根據 id 參數從 STORE 中獲取用戶數據,而 create 方法就是簡單將傳入的 user 對象存儲到 STORE 中。由於所有這些操作都只是在內存中完成的,因此該類型被叫做 InMemoryUserRepository

2.3.4 UserService

public interface UserService { User getById(String id);

void create(User user);

}

UserRepository 的方法一一對應,向更上層暴露訪問介面。

2.3.5 DefaultUserService

@Service("userService")public class DefaultUserService implements UserService { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultUserService.class);

@Autowired

private UserRepository userRepository;

@Override

public User getById(String id) {

User user = this.userRepository.getById(id);LOGGER.info(user.toString());return user;

}

@Override

public void create(User user) {

user.setCreatedTime(new Date());this.userRepository.create(user);LOGGER.info(user.toString());

}

}

DefaultUserServiceUserService 介面的默認實現,並通過 @Service 註解聲明為一個服務,服務 id 為 userService(該 id 在後面會需要用到)。該服務內部注入了一個 UserRepository 類型的對象 userRepositorygetUserById 方法根據 id 從 userRepository 中獲取數據,而 createUser 方法則將傳入的 user 參數通過 userRepository.create 方法存入,並在存入之前設置了該對象的創建時間。很顯然,根據 1.6 節關於副作用的描述,為 user 對象設置創建時間的操作就屬於具有副作用的操作,需要在微服務改造之後加以保留。為了方便看到系統工作效果,這兩個方法裡面都列印了日誌。

2.3.6 HelloService

public interface HelloService { String sayHello(String userId);}

HelloService 介面只提供一個方法sayHello,就是根據傳入的userId 返回一條對該用戶的問候信息。

2.3.7 DefaultHelloService

@Service("helloService")public class DefaultHelloService implements HelloService { @Autowired private UserService userService;

@Override

public String sayHello(String userId) {

User user = this.userService.getById(userId);return String.format("Hello, %s.", user);

}

}

DefaultHelloServiceHelloService 介面的默認實現,並通過 @Service 註解聲明為一個服務,服務 id 為 helloService(同樣,該名稱在後面的改造過程中會被用到)。該類型內部注入了一個 UserService 類型的對象 userServicesayHello 方法根據 userId 參數通過 userService 獲取用戶信息,並返回一條經過格式化後的消息。

2.3.8 Application

@SpringBootApplicationpublic class Application { public static void main(String[] args) throws Exception {SpringApplication.run(Application.class, args);

}

}

Application 類型是 Spring Boot 應用的入口,詳細描述請參考 Spring Boot 的官方文檔,在此不詳細展開。

2.3.9 Controller

@RestControllerpublic class Controller { @Autowired private HelloService helloService;

@Autowired

private UserService userService;

@RequestMapping("/hello/{userId}")

public String sayHello(@PathVariable("userId") String userId) {

return this.helloService.sayHello(userId);

}

@RequestMapping(path = "/create", method = RequestMethod.POST)

public String createUser(@RequestParam("userId") String userId, @RequestParam("name") String name) {

User user = new User();user.setId(userId);user.setName(name);this.userService.createUser(user);return user.toString();

}

}

Controller 類型是一個標準的 Spring MVC Controller,在此不詳細展開討論。僅僅需要說明的是這個類型注入了 HelloServiceUserService 類型的對象,並在 sayHello createUser 方法中調用了這兩個對象中的有關方法。

2.4 打包運行

hello-dubbo 項目包含三個子模塊:hello-apihello-core hello-web,是用 Maven 來管理的。到目前為止所涉及到的 POM 文件都比較簡單,為了節約篇幅,就不在此一一列出了,感興趣的朋友可以到項目的 Github 倉庫上自行研究。

hello-dubbo 項目的打包和運行都非常直接:

編譯、打包和安裝

在項目根目錄下執行命令

$ mvn clean install

運行

在 hello-web 目錄下執行命令

$ mvn spring-boot:run

測試結果如下,注意每次輸出括弧裡面的日期時間,它們都應該是有值的。

再返回 hello-web 系統的控制台,查看一下日誌輸出,時間應該與上面是一樣的。

三、動手改造

3.1 改造目標

上一章,我們已經成功構建了一個模擬系統,該系統是一個單體系統,對外提供了兩個 RESTful 介面。本章要達到的目標是將該單體系統拆分為兩個獨立運行的微服務系統。如 2.2 節所述,進行模塊化拆分是實施微服務改造的重要一步,因為在接下來的描述中會暗含一個約定:hello-webhello-core hello-api 這三個模塊與上一章中所設定的能力是相同的。基於 1.7 節所提到的「盡量少改動(最好不改動)遺留系統的內部代碼」的改造要求,這三個模塊中的代碼是不會被大面積修改的,只會有些許調整,以適應新的微服務環境。

具體將要實現的目標效果如下:

第一個微服務系統:

hello-web(包含 Application 和 Controller)

|-- hello-service-reference(包含 Dubbo 有關服務引用的配置)

|-- hello-api(包含 Service 和 DAO 的介面以及模型)

第二個微服務系統:

hello-service-provider(包含 Dubbo 有關服務暴露的配置)

|-- hello-core(包含 Service 和 DAO 的實現)

|-- hello-api(包含 Service 和 DAO 的介面以及模型)

hello-web 與原來一樣,是一個面向最終用戶提供 Web 服務的終端系統,其只包含 Application、Controller、Service 介面、 DAO 介面以及模型,因此它本身是不具備任何業務能力的,必須通過依賴 hello-service-reference 模塊來遠程調用 hello-service-provider 系統才能完成業務。而 hello-service-provider 系統則需要暴露可供 hello-service-reference 模塊調用的遠程介面,並實現 Service 及 DAO 介面定義的具體業務邏輯。

本章節就是要重點介紹 hello-service-provider 和 hello-service-reference 模塊是如何構建的,以及它們在微服務改造過程中所起到的作用。

3.2 暴露遠程服務

Spring Boot 和 Dubbo 的結合使用可以引入諸如 spring-boot-starter-dubbo 這樣的起始包,使用起來會更加方便。但是考慮到項目的單純性和通用性,本文仍然延用 Spring 經典的方式進行配置。

首先,我們需要創建一個新的模塊,叫做 hello-service-provider,這個模塊的作用是用來暴露遠程服務介面的。依託於 Dubbo 強大的服務暴露及整合能力,該模塊不用編寫任何代碼,僅需添加一些配置即可完成。

註:有關 Dubbo 的具體使用和配置說明並不是本文討論的重點,請參考官方文檔。

3.2.1 添加 dubbo-services.xml 文件

dubbo-services.xml 配置是該模塊的關鍵,Dubbo 就是根據這個文件,自動暴露遠程服務的。這是一個標準 Spring 風格的配置文件,引入了 Dubbo 命名空間,需要將其擺放在 src/main/resources/META-INF/spring 目錄下,這樣 Maven 在打包的時候會自動將其添加到 classpath。

3.2.2 添加 POM 文件

有關 Maven 的使用與配置也不是本文關注的重點,但是這個模塊用到了一些 Maven 插件,在此對這些插件的功能和作用進行一下描述。

3.2.3添加 assembly.xml 文件

Assembly 插件的主要功能是對項目重新打包,以便自定義打包方式和內容。對本項目而言,需要生成一個壓縮包,裡面包含所有運行該服務所需要的 jar 包、配置文件和啟動腳本等。Assembly 插件需要 assembly.xml 文件來描述具體的打包過程,該文件需要擺放在 src/main/assembly 目錄下。有關 assembly.xml 文件的具體配置方法,請參考官方文檔。

3.2.4 添加 logback.xml 文件

由於在 POM 文件中指定了使用 logback 作為日誌輸出組件,因此還需要在 logback.xml 文件中對其進行配置。該文件需要擺放在 src/main/resources 目錄下,有關該配置文件的具體內容請參見代碼倉庫,有關配置的詳細解釋,請參考官方文檔。

####3.2.5 打包

由於已經在 POM 文件中定義了打包的相關配置,因此直接在 hello-service-provider 目錄下運行以下命令即可:

$ mvn clean package

成功執行以後,會在其 target 目錄下生成一個名為 hello-service-provider-0.1.0-SNAPSHOT-assembly.tar.gz 的壓縮包,裡面的內容如圖所示:

3.2.6 運行

如此配置完成以後,就可以使用如下命令來啟動服務:

$ MAVEN_OPTS="-Djava.net.preferIPv4Stack=true" mvn exec:java

註:在 macOS 系統里,使用 multicast 機制進行服務註冊與發現,需要添加-Djava.net.preferIPv4Stack=true 參數,否則會拋出異常。

可以使用如下命令來判斷服務是否正常運行:

$ netstat -antl | grep 20880

如果有類似如下的信息輸出,則說明運行正常。

如果是在正式環境運行,就需要將上一步生成的壓縮包解壓,然後運行 bin 目錄下的相應腳本即可。

3.2.7 總結

使用這種方式來暴露遠程服務具有如下一些優勢:

使用 Dubbo 進行遠程服務暴露,無需關注底層實現細節

對原系統沒有任何入侵,已有系統可以繼續按照原來的方式啟動和運行

暴露過程可插拔

Dubbo 服務與原有服務在開發期和運行期均可以共存

無需編寫任何代碼

3.3 引用遠程服務

3.3.1 添加服務引用

hello-service-provider 模塊的處理方式相同,為了不侵入原有系統,我們創建另外一個模塊,叫做 hello-service-reference。這個模塊只有一個配置文件 dubbo-references.xml 放置在 src/main/resources/META-INF/spring/ 目錄下。文件的內容非常簡單明了:

但不同於 hello-service-provider 模塊的一點在於,該模塊只需要打包成一個 jar 即可,POM 文件內容如下:

總結一下,我們曾經的遺留系統分為三個模塊 hello-web, hello-corehello-api。經過微服務化處理以後,hello-corehello-api 被剝離了出去,加上 hello-service-provider 模塊,形成了一個可以獨立運行的 hello-service-provider 系統,因此需要打包成一個完整的應用;而 hello-web 要想調用 hello-core 提供的服務,就不能再直接依賴 hello-core 模塊了,而是需要依賴我們這裡創建的 hello-service-reference 模塊,因此 hello-service-reference 是作為一個依賴庫出現的,其目的就是遠程調用 hello-service-provider 暴露出來的服務,並提供本地代理。

這時 hello-web 模塊的依賴關係就發生了變化:原來 hello-web 模塊直接依賴 hello-core,再通過 hello-core 間接依賴 hello-api,而現在我們需要將其改變為直接依賴 hello-service-reference 模塊,再通過 hello-service-reference 模塊間接依賴 hello-api。改造前後的依賴關係分別為:

3.3.2 啟動服務

因為是測試環境,只需要執行以下命令即可,但在進行本操作之前,需要先啟動 hello-service-provider 服務。

$ MAVEN_OPTS="-Djava.net.preferIPv4Stack=true" mvn spring-boot:run

Oops!系統並不能像期望的那樣正常運行,會拋出如下異常:

意思是說 net.tangrui.demo.dubbo.hello.web.Controller 這個類的 helloService 欄位需要一個類型為 net.tangrui.demo.dubbo.hello.service.HelloService 的 Bean,但是沒有找到。相關代碼片段如下:

@RestControllerPublic class Controller { @Autowired private HelloService helloService;

@Autowired

private UserService userService;

...

}

顯然,helloServiceuserService 都是無法注入的,這是為什麼呢?

原因自然跟我們修改 hello-web 這個模塊的依賴關係有關。原本 hello-web 是依賴於 hello-core的,hello-core 裡面聲明了 HelloServiceUserService 這兩個服務(通過 @Service 註解),然後 Controller@Autowired 的時候就可以自動綁定了。但是,現在我們將 hello-core替換成了 hello-service-reference,在 hello-service-reference 的配置文件中聲明了兩個對遠程服務的引用,按道理來說這個注入應該是可以生效的,但顯然實際情況並非如此。

仔細思考不難發現,我們在執行 mvn exec:java 命令啟動 hello-service-provider 模塊的時候指定了啟動 com.alibaba.dubbo.container.Main 類型,然後才會開始啟動並載入 Dubbo 的有關配置,這一點從日誌中可以得到證實(日誌裡面會列印出來很多帶有 [DUBBO] 標籤的內容),顯然在這次運行中,我們並沒有看到類似這樣的日誌,說明 Dubbo 在這裡沒有被正確啟動。歸根結底還是 Spring Boot 的原因,即 Spring Boot 需要一些配置才能夠正確載入和啟動 Dubbo。

讓 Spring Boot 支持 Dubbo 有很多種方法,比如前面提到的 spring-boot-starter-dubbo 起始包,但這裡同樣為了簡單和通用,我們依舊採用經典的方式來解決。

繼續思考,該模塊沒有成功啟動 Dubbo,僅僅是因為添加了對 hello-service-reference 的引用,而 hello-service-reference 模塊就只有一個文件 dubbo-references.xml,這就說明 Spring Boot 並沒有載入到這個文件。順著這個思路,只需要讓 Spring Boot 能夠成功載入這個文件,問題就可以了。Spring Boot 也確實提供了這樣的能力,只可惜無法完全做到代碼無侵入,只能說這些改動是可以被接受的。修改方式是替換 Application 中的註解(至於為什麼要修改成這樣的結果,超出了本文的討論範圍,請自行 Google)。

@Configuration@EnableAutoConfiguration@ComponentScan@ImportResource("classpath:META-INF/spring/dubbo-references.xml")public class Application { public static void main(String[] args) throws Exception {SpringApplication.run(Application.class, args);

}

}

這裡的主要改動,是將一個 @SpringBootApplication 註解替換為 @Configuration@EnableAutoConfiguration@ComponentScan@ImportResource 四個註解。不難看出,最後一個 @ImportResource 就是我們需要的。

這時再重新嘗試啟動,就一切正常了。

但是,我們如何驗證結果確實是從 hello-service-provider 服務過來的呢?這時就需要用到 DefaultUserService 裡面的那幾行日誌輸出了,回到 hello-service-provider 服務的控制台,能夠看到類似這樣的輸出:

如此便可以確信系統的拆分是被成功實現了。再試試創建用戶的介面:

$ curl -X POST http://127.0.0.1:8080/create?userId=huckleberry&name=Huckleberry%20Finn

等等,什麼!括弧裡面的創建時間為什麼是 N/A,這說明 createdTime 欄位根本沒有值!

3.4 保持副作用效果

讓我們先來回顧一下 1.6 節所提到的副作用效果。在 DefaultUserService.create 方法中,我們為傳入的 user 參數設置了創建時間,這一操作就是我們要關注的具有副作用效果的操作。

先說單體系統的情況。單體系統是運行在一個 Java 虛擬機中的,所有對象共享一片內存空間,彼此可以互相訪問。系統在運行的時候,先是由 Controller.create 方法獲取用戶輸入,將輸入的參數封裝為一個 user 對象,再傳遞給 UserService.create 方法(具體是在調用 DefaultUserService.create 方法),這時 user 對象的 createdTime 欄位就被設置了。由於 Java 是以引用的方式來傳遞參數,因此在 create 方法中對 user 對象所做的變更,是能夠反映到調用方那裡的——即 Controller.create 方法裡面也是可以獲取到變更的,所以返回給用戶的時候,這個 createdTime 就是存在的。

再說微服務系統的情況。此時系統是獨立運行在兩個虛擬機中的,彼此之間的內存是相互隔離的。起始點同樣是 hello-web 系統的 Controller.create 方法:獲取用戶輸入,封裝 user 對象。可是在調用 UserService.create 方法的時候,並不是直接調用DefaultUserService中的方法,而是調用了一個具有相同介面的本地代理,這個代理將 user 對象序列化之後,通過網路傳輸給了 hello-service-provider 系統。該系統接收到數據以後,先進行反序列化,生成跟原來對象一模一樣的副本,再由UserService.create 方法進行處理(這回調用的就是 DefaultUserService裡面的實現了)。至此,這個被設置過 createdTimeuser 對象副本是一直存在於 hello-service-provider 系統的內存裡面的,從來沒有被傳遞出去,自然是無法被 hello-web系統讀取到的,所以最終列印出來的結果,括弧裡面的內容就是 N/A 了。記得我們有在 DefaultUserService.create 方法中輸出過日誌,所以回到 hello-service-provider 系統的控制台,可以看到如下的日誌信息,說明在這個系統裡面 createdTime 欄位確實是有值的。

那麼該如何讓這個副作用效果也能夠被處於另外一個虛擬機中的 hello-web 系統感知到呢,方法只有一種,就是將變更後的數據回傳。

3.4.1 為方法添加返回值

這是最容易想到的一種實現方式,簡單的說就是修改服務介面,將變更後的數據返回。

首先,修改 UserService 介面的 create 方法,添加返回值:

public interface UserService { ...

// 為方法添加返回值

User create(User user);

}

然後,修改實現類中相應的方法,將變更後的 user 對象返回:

@Service("userService")public class DefaultUserService implements UserService { ...

@Override

public User create(User user) {

user.setCreatedTime(new Date());this.userRepository.create(user);LOGGER.info(user.toString());// 將變更後的數據返回return user;

}

}

最後,修改調用方實現,接收返回值:

@RestControllerpublic class Controller { ...

@RequestMapping(path = "/create", method = RequestMethod.POST)

public String createUser(@RequestParam("userId") String userId, @RequestParam("name") String name) {

User user = new User();user.setId(userId);user.setName(name);// 接收返回值User newUser = this.userService.create(user);return newUser.toString();

}

}

編譯、運行並測試(如下圖),正如我們所期望的,括弧中的創建時間又回來了。其工作原理與本節開始時所描述的是一樣的,只是方向相反而已。在此不再詳細展開,留給大家自行思考。

這種修改方式有如下一些優缺點:

方法簡單,容易理解

改變了系統介面,且改變後的介面與原有介面不兼容(違背了 1.4 節關於「保持原有介面不變或向後兼容」原則的要求)

由此也不可避免的造成了對遺留系統內部代碼的修改(違背了 1.7 節關於「盡量少改動(最好不改動)遺留系統的內部代碼」原則的要求)

修改方式不可插拔(違背了 1.9 節「改造結果可插拔」原則的要求)

由此可見,這種改法雖然簡單,卻是利大於弊的,除非我們能夠完全掌控整個系統,否則這種修改方式的風險會隨著系統複雜性的增加而急劇上升。

3.4.2 添加一個新方法

如果不能做到不改變介面,那我們至少要做到改變後的介面與原有介面向後兼容。保證向後兼容性的一種解決辦法,就是不改變原有方法,而是添加一個新的方法。過程如下:

首先,為 UserService 介面添加一個新的方法 __rpc_create。這個方法名雖然看起來有些奇怪,但卻有兩點好處:第一、不會和已有方法重名,因為 Java 命名規範不建議使用這樣的標識符來為方法命名;第二、在原有方法前加上 __rpc_ 前綴,能夠做到與原有方法對應,便於閱讀和理解。示例如下:

public interface UserService { ...

// 保持原有方法不變

void create(User user);

// 添加一個方法,新方法需要有返回值

User __rpc_create(User user);

}

然後,在實現類中實現這個新方法:

@Service("userService")public class DefaultUserService implements UserService { ...

// 保持原有方法實現不變

@Override

public void create(User user) {

user.setCreatedTime(new Date());this.userRepository.create(user);LOGGER.info(user.toString());

}

// 添加新方法的實現

@Override

public User __rpc_create(User user) {

// 調用原來的方法this.create(user);// 返回變更後的數據return user;

}

}

有一點需要展開解釋:在 __rpc_create 方法中,因為 user 參數是以引用的方式傳遞給 create方法的,因此 create 方法對參數所做的修改是能夠被 __rpc_create 方法獲取到的。這以後就與前面回傳的邏輯是相同的了。

第三,在服務引用端添加本地存根(有關本地存根的概念及用法,請參考官方文檔)。

需要在 hello-service-reference 模塊中添加一個類 UserServiceStub,內容如下:

public class UserServiceStub implements UserService { private UserService userService;

public UserServiceStub(UserService userService) {

this.userService = userService;

}

@Override

public User getById(String id) {

return this.userService.getById(id);

}

@Override

public void create(User user) {

User newUser = this.__rpc_create(user);user.setCreatedTime(newUser.getCreatedTime());

}

@Override

public User __rpc_create(User user) {

return this.userService.__rpc_create(user);

}

}

該類型即為本地存根。簡單來說,就是在調用方調用本地代理的方法之前,會先去調用本地存根中相應的方法,因此本地存根與服務提供方和服務引用方需要實現同樣的介面。本地存根中的構造函數是必須的,且方法簽名也是被約定好的——需要傳入本地代理作為參數。其中 getById__rpc_create 方法都是直接調用了本地代理中的方法,不必過多關注,重點來說說 create 方法。首先,create 調用了本地存根中的 __rpc_create 方法,這個方法透過本地代理訪問到了服務提供方的相應方法,並成功接收了返回值 newUser,這個返回值是包含修改後的 createdTime 欄位的,於是我們要做的事情就是從 newUser 對象裡面獲取到 createdTime 欄位的值,並設置給 user 參數,以達到產生副作用的效果。此時 user 參數會帶著新設置的 createdTime 的值,將其「傳遞」給 create 方法的調用方。

最後,在 dubbo-references.xml 文件中修改一處配置,以啟用該本地存根:

interface="net.tangrui.demo.dubbo.hello.service.UserService" version="1.0" stub="net.tangrui.demo.dubbo.hello.service.stub.UserServiceStub" />

鑒於本地存根的工作機制,我們是不需要修改調用方 hello-web 模塊中的任何代碼及配置的。編譯、運行並測試,同樣可以達到我們想要的效果。

這種實現方式會比第一種方式改進不少,但也有致命弱點:

保持了介面的向後兼容性

引入本地存根,無需修改調用方代碼

通過配置可以實現改造結果的可插拔

實現複雜,尤其是本地存根的實現,如果遺留系統的代碼對傳入參數里的內容進行了無節制的修改的話,那麼重現該副作用效果是非常耗時且容易出錯的

難以理解

四、總結

至此,將遺留系統改造為微服務系統的任務就大功告成了,而且基本上滿足了文章最開始提出來的十點改造原則與要求(此處應給自己一些掌聲),不知道是否對大家有所幫助?雖然示例項目是為了敘述要求而量身定製的,但文章中提到的種種理念與方法卻實實在在是從實踐中摸索和總結出來的——踩過的坑,遇到的問題,解決的思路以及改造的難點等都一一呈現給了大家。

微服務在當下已經不是什麼新鮮的技術了,但歷史包袱依然是限制其發展的重要因素,希望這篇文章能帶給大家一點啟發,在接下來的工作中更好的擁抱微服務帶來的變革。

作者:中間件小哥

原文鏈接:click.aliyun.com/m/1000

更多技術乾貨敬請關注云棲社區知乎機構號:阿里云云棲社區 - 知乎


推薦閱讀:

快速搭建微服務--手把手教你服務註冊與發現
《微服務設計》閱讀筆記(四)集成
Istio Egress 規則簡介
《Cloud Native Go》筆記(七)構建數據服務
微服務化的資料庫設計與讀寫分離

TAG:科技 | 計算機科學 | 微服務架構 |