仿開源框架從零到一完整實現高效能、可擴充套件的RPC框架 | 6個月做成教程免費送
阿新 • • 發佈:2020-05-25
去年年就在寫一本付費小冊,今年年初基本上就寫完了,本來預計計劃是春節上線結果由於平臺的原因一直拖著沒上。五一前跟平臺聯絡給的反饋是五月份能上,結果平臺又在重構,停止小冊的申請和上線,最後我考慮了一下決定這本書免費放出來,書名是《跟著頂級專案學程式設計》,關注公眾號 **渡碼** 回覆關鍵字 **manis**,可獲取電子書+原始碼+讀者交流群。下面給大家介紹下。
## 簡介
19年上半年,我閱讀了Hadoop RPC模組的原始碼,讀完後發現這個模組設計的非常好,與其他模組無耦合,完全可以獨立出來當成一個獨立的框架。為了總結學到的程式設計知識,同時也為了學習Apache頂級開源專案的程式碼是如何編寫的,我便把它做成了電子書,共350頁,從寫程式碼到做成電子書共花了6個月的時間。本來想做成付費專欄賺點小錢,並且已經到了上架階段了,但後來決定把它免費開放出來,讓更多的人能夠學習到優秀的實戰專案。
當然我們這本書並不是原始碼分析類教程,而是強調動手能力。在這裡我會帶著大家按照 Hadoop RPC 原始碼從 0 到 1 完整敲一遍,程式碼量在 4600 行左右。為了讓不熟悉 Hadoop 或 RPC 的朋友也能夠學習,我將 Hadoop RPC 稍微做了一點改造,賦予了新的業務含義,也有自己的名字,叫 Manis。Mnias 原始碼相比於 Hadoop RPC原始碼還原度為90%。為什麼不是100%呢?一方面為了突出重點,我會把不太重要、不是很核心的技術捨棄掉。另一方面為了符合新的業務定義,我會做一些改進,而不是照搬完全 Hadoop RPC。
雖然這個專案是實現 RPC 功能,但我覺得我們關注的重點不應該過多地放在 RPC 本身,而應該重點學習編寫 RPC 過程中所涉及的系統設計、面向物件設計思想和原則、工程/程式碼規範、客戶端開發、服務端開發、網路程式設計、多執行緒、併發程式設計、設計模式、異常處理等核心知識,可以說是麻雀雖小五臟俱全。尤其是對於剛學習 Java 還沒有接觸線上實戰專案的朋友,這是一次很好的練兵機會。
學習開源專案的一個優勢在於它是經過線上檢驗的,Hadoop叢集規模最大達到上萬臺服務端,足以證明它的 RPC 模組是優秀的。另外一個好處是可以積累頂級開源專案的開發經驗,大到架構設計,小到設計模式、程式碼規範,說不定日後就能為開源社群貢獻程式碼了。所以,學會了 Manis 後,不但有編寫實戰專案的經驗,同時也有能力閱讀 Hadoop RPC 的原始碼,這也算是面試的加分項。
## 涉及到的核心技術
下面我們來介紹一下 Manis 中涉及的核心技術點。作為一個 RPC 框架,最關鍵的幾個模組是客戶端、網路模組和服務端
### 客戶端
作為客戶端來說,它的職責非常明確,以資料庫客戶端為例,它的職責就是向用戶提供增刪改查介面,並將相應的請求傳送給服務端,接收結果並返回給使用者。由於客戶端職責邊界是非常明確的,所以我們從設計上就要將其與網路模組、與服務端解耦,解耦的方式就要用到設計模式中的**代理模式**。也就說客戶端只需要定義好它需要提供的功能(介面)並提供給使用者,具體如何實現就交給**代理**,至於**代理**是通過網路傳送給服務端還是通過其他什麼方式客戶端就不需要關心了,客戶端只關心呼叫**代理**並拿結果。這樣做的好處是客戶端與其他模組解耦,提高了系統擴充套件性。當然,**代理模式**還有個容易被忽略的好處是它天然地適合用在 RPC 場景。
Manis 中支援多種序列化/反序列化方式,每種序列化方式對應一個類,它們都繼承共同的基類。我們在設計時需要做到不同序列化方式之間的程式碼是解耦的,且序列化/反序列化模組與客戶端模組、與網路模組是解耦的,這樣才能做到任意地增加新的新的序列化方式以及刪除老的序列化方式。為了實現客戶端與序列化/反序列化模組的鬆耦合,我們需要用到一些**設計模式**,比如,用**介面卡**模式將客戶端定義的請求介面適配到不同序列化協議定義的請求介面。這樣做幾乎不需要修改現有的程式碼,符合面向物件的**開閉原則**。
### 網路模組
下面再來說說網路模組。
由於客戶端的請求可能來自不同的序列化協議,但的目的是相同的,都是為了通過網路模組的服務端,可以說是**殊途同歸**。這樣的話,我們就有必要在網路這一層定義一個統一的協議(介面),讓不同序列化方式都遵循相同的協議(介面),那麼網路模組就可以對它們“一視同仁”,編寫一套程式碼就可以了。就好比,不管你用U盤還是硬碟,只要是 USB 介面,那都能插到電腦的同一個介面進行相同的讀寫邏輯。對於服務端的返回值也是採用同樣的處理邏輯。
網路模組必不可少的功能就是傳送網路請求,當然除了這個還有一個更核心的功能是管理網路資源。聽起來有點抽象,如果用面向物件的思想來理解,其實就是建立一個類代表網路連線,比如就叫`Connection`類,每次建立一個網路連線其實就是建立一個`Connection`物件。當然,我們知道網路資源比較寶貴且建立成本較高,當系統客戶端請求量非常大的時候,我們不可能為每次請求都建立一個網路連線,所以,需要建立一個網路連線池,以達到複用網路資源的目的。我們可以再定義一個類`ConnectionId`,每個`ConnectionId`物件都唯一代表`Connection`物件,`ConnectionId`的屬性包含**服務端地址**和**請求網路的一些引數**,所以我們可以認為客戶端請求服務端的地址和引數相同的話,就可以複用同一個網路連線。當然,這裡還有一個很關鍵的問題不容忽視,網路連線池是公共資源,為了保證執行緒安全,在對資源池讀寫時需要加鎖,也是從這裡開始本書加大了對**併發程式設計**的相關講解。剛剛介紹的這部分在 Manis 中是自主實現的。
建立網路連線的過程中還會涉及傳送請求頭、請求上下文,裝飾網路輸入、輸出流等功能,這些比較偏業務,這裡就不再贅述了。
傳送網路請求時,為了將業務程式碼與傳送請求程式碼剝離,在 Manis 建立了一個建執行緒池,將傳送傳送請求的程式碼封裝成執行緒,丟到執行緒池中等待執行。所以,這裡又涉及到三部分知識
* 使用**工廠模式**建立執行緒池,並用**單例模式**保證不被重複建立
* 使用Java的**Executor**和**Future**,用來建立任務並等待返回
* 對執行緒池的讀寫保證**執行緒安全**
最後,網路模組要實現的是等待服務端返回的結果。由於網路模組同一時間會接收大量客戶端網路請求,所以,我們可以建立一個單獨的執行緒,每隔一定時間輪詢是否有服務端的返回。
### 服務端
對於服務端來說,我們最關心的是效能問題。因為大量的客戶端請求最終都會彙總到服務端一個節點來處理。所以最原始的**單執行緒+while迴圈**的方式肯定滿足不了效能要求。所以比較最容易想到的改進點是**多執行緒**,雖然在一定程度上能解決第一種方式帶來的問題,但這種方式也有很大的缺點:頻繁建立執行緒成本比較大,並且執行緒之間的切換也需要一定的開銷,當執行緒數過多時顯然會降低服務端的效能。目前比較常用的解決方案是**Reactor模式**,**Reactor模式**也分為單執行緒Reactor、多執行緒Reactor和多Reactor。這幾種的區別在書裡都有具體說明,這裡我就不再介紹了。**Reactor模式**的優勢按照我自己的理解就四個字——**各司其職**。Manis 中使用的是**多Reactor模式**,設計圖如下:
![](https://user-gold-cdn.xitu.io/2019/12/1/16ebf870942c3a64?w=1108&h=642&f=png&s=98527)
簡單介紹一下圖中幾個執行緒的功能
* Listener: 接收客戶端的連線請求,也可以叫做 Acceptor,封裝連線請求
* Readr: 多執行緒並行地讀取客戶端請求,進行反序列化和解析操作
* Handler: 多執行緒並行地讀取呼叫請求,解析呼叫方法並執行呼叫
* Responder: 讀取響應結果,傳送給客戶端
夠各司其職吧。那它們之間怎麼聯絡呢?從圖上可以看到是**訊息佇列**,訊息佇列可以很好地實現元件間的解耦。
雖然服務端的職責也比較明確、清晰,但涉及的內容一點不少,包括註冊不同的序列化方式,解析並呼叫相應的請求。最關鍵的是服務端執行緒是最多的,並且需要執行緒之間需要高度協調的,所以對**併發程式設計**的要求也更高,這塊書中也有重點講解。
最後我們看看**Manis中核心元件的時序圖**
![avatar](https://user-gold-cdn.xitu.io/2019/10/24/16dfe4f20634297c?w=705&h=324&f=bmp&s=685638)
由於 Manis 在設計上是足夠優秀的,所以開發的時候這三個模組可以並行進行。有點像近幾年web開發比較火的前後端分離架構,只要各個模組把協議定義好了後,開發就可以並行進行而不需要依賴彼此。至此,Manis 的核心技術就介紹完了,當然這只是冰山一角,畢竟 4600 行程式碼。
最後,講解一下第一節的內容
## 第一節:搭建客戶端本地呼叫框架
本節開始我們就開啟 Manis 專案的實戰之旅,首先從客戶端開發入手。主要包括以下4個小節:
* 定義介面
* 建立代理工具類
* 建立客戶端類
* 課外拓展:代理模式
Manis 提供給使用者呼叫的類有兩個,一個是 `ManisClient` 給資料庫使用者提供的,另一個是 `Manager` 給資料庫管理員使用的。採用**面向介面**的程式設計思想,我們可以將提供的功能定義在**介面**中。
### 定義介面
定義`ClientProtocol`介面,程式碼如下:
```Java
package com.cnblogs.duma.protocol;
import java.io.IOException;
public interface ClientProtocol {
/**
* 獲取某個表的 meta 資訊
* @param dbName 資料庫名稱
* @param tbName 表名稱
* @return 表中的記錄數
*/
public int getTableCount(String dbName, String tbName) throws IOException;
}
```
`ClientProtocol`介面中定義了一個`getTableCount`方法,提供了獲取資料庫中某張表的記錄數的功能。該介面用在`ManisClient`中。
介面的**命名**需要解釋一下,名稱中包含了`protocol(協議)`單詞,因為它需要跟其他元件通訊,所以稱它們是協議也合理。後面程式碼中的變數、註釋將其稱為協議時大家不要覺得奇怪。
定義`ManagerProtocol`介面,程式碼如下:
```Java
package com.cnblogs.duma.protocol;
public interface ManagerProtocol {
/**
* 設定支援的最大表的個數
* @param tableNum 表數量
* @return 設定成功返回 true,設定失敗返回 false
*/
public boolean setMaxTable(int tableNum);
}
```
`ManagerProtocol`介面中定義了一個`setMaxTable`方法,可以讓管理員設定資料庫中能夠支援最多的表數量,該介面用在`Manager`中。
實現介面中的方法,最常見的方式就是在本地建立類並實現該介面,但這種方式顯然不適用於 RPC 場景。RPC 場景中的方法需要在服務端呼叫,而不是本地。因此,就需要另一種方式建立例項化物件,即通過**代理模式**。不理解的朋友可以閱讀本節的**課外拓展**。
### 建立代理工具類
在 Manis 中,我們便是通過**代理**的方式例項化介面。定義一個`ManisDbProxies`工具類,來實現獲取代理的相關邏輯,程式碼如下:
```Java
package com.cnblogs.duma;
import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ClientProtocol;
import com.cnblogs.duma.protocol.ManagerProtocol;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
/**
* @author duma
*/
public class ManisDbProxies {
public static class ProxyInfo {
private final PROXYTYPE proxy;
private final InetSocketAddress address;
public ProxyInfo(PROXYTYPE proxy, InetSocketAddress address) {
this.proxy = proxy;
this.address = address;
}
public PROXYTYPE getProxy() {
return proxy;
}
public InetSocketAddress getAddress() {
return address;
}
}
@SuppressWarnings("unchecked")
public static ProxyInfo createProxy(Configuration conf,
URI uri, Class xface)
throws IOException {
return null;
}
}
```
`ManisDbProxies` 類中定義了一個靜態類`ProxyInfo`用來封裝代理物件。由於需要代理的介面不止一個,所以`ProxyInfo`類引入了泛型。另外,我們還定義`createProxy`方法用來獲取代理物件,裡面的邏輯後續會完善。
這裡簡單說一下`createProxy`方法第一個引數——`Configuration`物件,它儲存了定義的配置資訊,且定義了配置的`set`和`get`方法,功能與 Hadoop 中的同名類一致,但實現上比 Hadoop 簡單。為了不影響我們對重點內容的介紹,這裡就不貼該類的程式碼了,它的原始碼帶可在上面的 GitHub 連線中找到。
### 建立客戶端類
準備工作已經就緒,下面分別看看兩種客戶端如何使用介面來實現我們需要的功能。
`ManisClient`的程式碼如下:
```Java
package com.cnblogs.duma;
import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ClientProtocol;
import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
/**
*
* @author duma
*/
public class ManisClient implements Closeable {
volatile boolean clientRunning = true;
final ClientProtocol manisDb;
public ManisClient(URI manisDbUri, Configuration conf) throws IOException {
ManisDbProxies.ProxyInfo proxyInfo = null;
proxyInfo = ManisDbProxies.createProxy(conf, manisDbUri, ClientProtocol.class);
this.manisDb = proxyInfo.getProxy();
}
/**
* 獲取遠端資料庫表中的記錄數
* @param dbName 資料庫名稱
* @param tbName 表名稱
* @return 表記錄數
* @see com.cnblogs.duma.protocol.ClientProtocol#getTableCount(String, String)
*/
public int getTableCount(String dbName, String tbName)
throws IOException {
return this.manisDb.getTableCount(dbName, tbName);
}
@Override
public void close() throws IOException {
}
}
```
`Manager`的程式碼如下:
```Java
package com.cnblogs.duma;
import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ManagerProtocol;
import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
public class Manager implements Closeable {
volatile boolean clientRunning = true;
final ManagerProtocol manisDb;
public Manager(URI manisDbUri, Configuration conf) throws IOException {
ManisDbProxies.ProxyInfo proxyInfo = null;
proxyInfo = ManisDbProxies.createProxy(conf, manisDbUri, ManagerProtocol.class);
this.manisDb = proxyInfo.getProxy();
}
public boolean setMaxTable(int tableNum) {
return this.manisDb.setMaxTable(tableNum);
}
@Override
public synchronized void close() throws IOException {
}
}
```
兩種客戶端的程式碼基本一致,以`ManisClient`為例簡單講解下。`ManisClient`中也定義了`getTableCount`方法,它直接呼叫了`ClientProtocol`的例項化物件(代理物件) `manisDb`的 `getTableCount`方法。從這裡我們就可以看出**面向介面**程式設計的一個優勢——擴充套件性高。雖然現在`manisDb`是通過代理對初始化的,但假設以後需求變了,變成直接呼叫本地的方法了呢?這時候我們就可以在本地建立一個實現了`ClientProtocol`介面的類,將其物件賦值給`manisDb`即可。這樣改變只是`manisDb`的初始化程式碼,而其他業務程式碼不需要做任何改變。這同時也提醒我們平時在做設計的時候要認清系統中不變的地方和可變的地方。
另外,`ManisClient`和`Manager`都實現了`Closeable`介面,目的是為了覆蓋`close`方法。在`close`方法中可以關閉客戶端,從而釋放佔用的資源。`close`方法的實現程式碼會在後續的章節中會完善。
至此,這一節的內容就講解完畢了,下一節我們將定義不同的 RPC 引擎,並完善代理模式。
### 課外拓展:代理模式
代理模式是設計模式中的一種,該模式應用比較廣泛。如果不太理解該模式的朋友,可以閱讀這一小節。代理模式有好多種,本小節我們只介紹 **Java 語言的動態代理**機制。如果想詳細瞭解其他代理模式可以閱讀我之前寫的[部落格](https://www.cnblogs.com/duma/p/10629302.html)。
假設我們有一個寫檔案的需求,我們首先定義介面:
```Java
package com.cnblogs.duma.dp.proxy.dynamic;
public interface Writer {
public void write(String fileName, String str);
public void write(String fileName, byte[] bs);
}
```
再定義寫檔案的類並實現`Writer`介面:
```Java
package com.cnblogs.duma.dp.proxy.dynamic;
public class FileWriter implements Writer {
@Override
public void write(String fileName, String str) {
System.out.println("call write str in FileWriter");
}
@Override
public void write(String fileName, byte[] bs) {
System.out.println("call write bytes in FileWriter");
}
}
```
如果我們要寫檔案的話,通過`Writer writer = new FileWriter()`就可以實現了。假設某天突然來了個需求,說我們寫磁碟的時候要判斷伺服器儲存空間是否達到某個臨界值,如果達到了就不能再寫了。對於這個需求來說我們可以直接修改`FileWriter`類來實現,但這樣做有兩個問題:
1. 改現有程式碼風險高,可能改動過程中影響原有邏輯,不符合**開閉原則——對擴充套件開放,對修改關閉**
2. 這個需求跟寫檔案的業務無關,直接放在業務程式碼裡面會導致耦合度比較大,不利於維護
通過代理模式就可以避免上述兩個問題,接下來我們看看如何利用 Java 的動態代理來實現這個需求。
首先,我們需要建立一個實現`java.lang.reflect.InvocationHandler`介面的類:
```Java
package com.cnblogs.duma.dp.proxy.dynamic;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class FileWriterInvocationHandler implements InvocationHandler {
Writer writer = null;
public FileWriterInvocationHandler(Writer writer) {
this.writer = writer;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
boolean localNoSpace = false;
System.out.println("check local filesystem space."); //檢測磁碟空間程式碼,返回值可以更新 localNoSpace 變數
if (localNoSpace) {
throw new Exception("no space."); //如果空間不足,丟擲空間不足的異常
}
return method.invoke(writer, args); //呼叫真實物件(FileWriter)的方法
}
}
```
`FileWriterInvocationHandler`的構造方法中會儲存實際用於寫檔案的物件,即`FileWriter`物件。`invoke`方法中先檢查磁碟,如果沒問題再呼叫檔案的寫方法`method.invoke(writer, args)`,這個寫法是**Java反射機制**提供的。看起來`invoke`方法就是我們想要的功能,但我們要怎麼呼叫`invoke`呢?這裡就用到 Java 的動態代理技術了,在執行時將`Writer`介面動態地跟代理物件(FileWriterInvocationHandler物件)繫結在一起。
下面,我們看看如何建立代理物件並進行繫結:
```Java
package com.cnblogs.duma.dp.proxy.dynamic;
import java.lang.reflect.Proxy;
public class DynamicProxyDriver {
public static void main(String[] args) {
/**
* Proxy.newProxyInstance 包括三個引數
* 第一個引數:定義代理類的 classloader,一般用被代理介面的 classloader
* 第二個引數:需要被代理的介面列表
* 第三個引數:實現了 InvocationHandler 介面的物件
* 返回值:代理物件
*/
Writer writer = (Writer) Proxy.newProxyInstance(
Writer.class.getClassLoader(),
new Class[]{Writer.class},
new FileWriterInvocationHandler(new FileWriter())); //這就是動態的原因,執行時才建立代理類
try {
writer.write("file1.txt", "text"); //呼叫代理物件的write方法
} catch (Exception e) {
e.printStackTrace();
}
writer.write("file2.txt", new byte[]{}); //呼叫代理物件的write方法
}
}
```
通過Java語言提供的`Proxy.newProxyInstance()`即可建立`Writer`介面的動態代理物件,程式碼註釋中有該方法的引數說明。對照本例,簡單梳理一下**Java動態代理機制**
1. 當通過`Proxy.newProxyInstance()`建立代理物件後,在`Writer`介面中呼叫`write`方法便會跳轉到`FileWriterInvocationHandler`物件的`invoke`方法中執行
2. 比如,執行`writer.write("file1.txt", "text");`時,程式跳轉到`invoke`方法,它的第二個引數`method`物件是`write`方法,第三個引數`args`是呼叫`write`方法的實參`file1.txt`和`text`。
3. `invoke`方法中的最後一行`method.invoke(writer, args);`代表`method`方法(即`write`方法)由`writer`物件呼叫,引數是`args`,跟`writer.write("file1.txt", "text")`是一樣的。
這樣我們就通過代理模式既實現新需求,有沒有修改現有的程式碼。經過上述的講解,希望你對代理模式的概念和優勢有一定的瞭解。
代理模式除了上述提到的用處外,還有一個用處是**轉發呼叫請求**,以 Manis 為例,假如我們為`ClientProtocol`建立一個代理物件`manisDb`,在`manisDb`上呼叫`getTableCount`方法時,便會跳轉到代理物件的`invoke`方法中執行,在`invoke`方法中我們就可以將呼叫的方法和引數反序列化,並通過網路傳送服務端,這就實現了呼叫請求的轉發。
## 獲得本書
今天先介紹第一節, 想獲取完整內容可以關注公眾號 **渡碼** 回覆關鍵字 **manis**。
## 目錄
![](https://imgkr.cn-bj.ufileos.com/1a34fe0c-72c9-49b3-85b5-538f9c454ea6.png)
## 程式碼結構
![](https://imgkr.cn-bj.ufileos.com/46b6d6a9-0e2c-43a2-bad5-4e9228f2c55c.png)
## 本書特色
在講解相簿內容同時,大部分章節都加入了課外拓展,針對每一節涉及的基礎知識,如:設計模式、序列化/反序列化基礎、單例測試、原始碼分析、併發程式設計以及Hadoop原始碼分析等內容都有拓展講解。力求讓零基礎的朋友也能跟上本書節奏,從0到1獨立完成一個專案。
希望你學完本書後不只學會了某項技術,而是提高了設計實現整個系統的能力。
歡迎公眾號「**渡碼**」,輸出別地兒看不到的乾貨。