仿開源框架從零到一完整實現高性能、可擴展的RPC框架 | 6個月做成教程免費送

去年年就在寫一本付費小冊,今年年初基本上就寫完了,本來預計計劃是春節上線結果由於平台的原因一直拖着沒上。五一前跟平台聯繫給的反饋是五月份能上,結果平台又在重構,停止小冊的申請和上線,最後我考慮了一下決定這本書免費放出來,書名是《跟着頂級項目學編程》,關注公眾號 渡碼 回復關鍵字 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的ExecutorFuture,用來創建任務並等待返回
  • 對線程池的讀寫保證線程安全

最後,網絡模塊要實現的是等待服務端返回的結果。由於網絡模塊同一時間會接收大量客戶端網絡請求,所以,我們可以創建一個單獨的線程,每隔一定時間輪詢是否有服務端的返回。

服務端

對於服務端來說,我們最關心的是性能問題。因為大量的客戶端請求最終都會匯總到服務端一個節點來處理。所以最原始的單線程+while循環的方式肯定滿足不了性能要求。所以比較最容易想到的改進點是多線程,雖然在一定程度上能解決第一種方式帶來的問題,但這種方式也有很大的缺點:頻繁創建線程成本比較大,並且線程之間的切換也需要一定的開銷,當線程數過多時顯然會降低服務端的性能。目前比較常用的解決方案是Reactor模式Reactor模式也分為單線程Reactor、多線程Reactor和多Reactor。這幾種的區別在書里都有具體說明,這裡我就不再介紹了。Reactor模式的優勢按照我自己的理解就四個字——各司其職。Manis 中使用的是多Reactor模式,設計圖如下:

簡單介紹一下圖中幾個線程的功能

  • Listener: 接收客戶端的連接請求,也可以叫做 Acceptor,封裝連接請求
  • Readr: 多線程並行地讀取客戶端請求,進行反序列化和解析操作
  • Handler: 多線程並行地讀取調用請求,解析調用方法並執行調用
  • Responder: 讀取響應結果,發送給客戶端

夠各司其職吧。那它們之間怎麼聯繫呢?從圖上可以看到是消息隊列,消息隊列可以很好地實現組件間的解耦。

雖然服務端的職責也比較明確、清晰,但涉及的內容一點不少,包括註冊不同的序列化方式,解析並調用相應的請求。最關鍵的是服務端線程是最多的,並且需要線程之間需要高度協調的,所以對並發編程的要求也更高,這塊書中也有重點講解。

最後我們看看Manis中核心組件的時序圖

avatar

由於 Manis 在設計上是足夠優秀的,所以開發的時候這三個模塊可以並行進行。有點像近幾年web開發比較火的前後端分離架構,只要各個模塊把協議定義好了後,開發就可以並行進行而不需要依賴彼此。至此,Manis 的核心技術就介紹完了,當然這只是冰山一角,畢竟 4600 行代碼。

最後,講解一下第一節的內容

第一節:搭建客戶端本地調用框架

本節開始我們就開啟 Manis 項目的實戰之旅,首先從客戶端開發入手。主要包括以下4個小節:

  • 定義接口
  • 創建代理工具類
  • 創建客戶端類
  • 課外拓展:代理模式

Manis 提供給用戶調用的類有兩個,一個是 ManisClient 給數據庫使用者提供的,另一個是 Manager 給數據庫管理員使用的。採用面向接口的編程思想,我們可以將提供的功能定義在接口中。

定義接口

定義ClientProtocol接口,代碼如下:

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接口,代碼如下:

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工具類,來實現獲取代理的相關邏輯,代碼如下:

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<PROXYTYPE> {
        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 <T> ProxyInfo<T> createProxy(Configuration conf, 
        URI uri, Class<T> xface)
            throws IOException {
        return null;
    }
}

ManisDbProxies 類中定義了一個靜態類ProxyInfo用來封裝代理對象。由於需要代理的接口不止一個,所以ProxyInfo類引入了泛型。另外,我們還定義createProxy方法用來獲取代理對象,裏面的邏輯後續會完善。

這裡簡單說一下createProxy方法第一個參數——Configuration對象,它保存了定義的配置信息,且定義了配置的setget方法,功能與 Hadoop 中的同名類一致,但實現上比 Hadoop 簡單。為了不影響我們對重點內容的介紹,這裡就不貼該類的代碼了,它的源碼帶可在上面的 GitHub 連接中找到。

創建客戶端類

準備工作已經就緒,下面分別看看兩種客戶端如何使用接口來實現我們需要的功能。

ManisClient的代碼如下:

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<ClientProtocol> 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的代碼如下:

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<ManagerProtocol> 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的實例化對象(代理對象) manisDbgetTableCount方法。從這裡我們就可以看出面向接口編程的一個優勢——擴展性高。雖然現在manisDb是通過代理對初始化的,但假設以後需求變了,變成直接調用本地的方法了呢?這時候我們就可以在本地創建一個實現了ClientProtocol接口的類,將其對象賦值給manisDb即可。這樣改變只是manisDb的初始化代碼,而其他業務代碼不需要做任何改變。這同時也提醒我們平時在做設計的時候要認清系統中不變的地方和可變的地方。

另外,ManisClientManager都實現了Closeable接口,目的是為了覆蓋close方法。在close方法中可以關閉客戶端,從而釋放佔用的資源。close方法的實現代碼會在後續的章節中會完善。

至此,這一節的內容就講解完畢了,下一節我們將定義不同的 RPC 引擎,並完善代理模式。

課外拓展:代理模式

代理模式是設計模式中的一種,該模式應用比較廣泛。如果不太理解該模式的朋友,可以閱讀這一小節。代理模式有好多種,本小節我們只介紹 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接口:

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接口的類:

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對象)綁定在一起。

下面,我們看看如何創建代理對象並進行綁定:

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.txttext
  3. invoke方法中的最後一行method.invoke(writer, args);代表method方法(即write方法)由writer對象調用,參數是args,跟writer.write("file1.txt", "text")是一樣的。

這樣我們就通過代理模式既實現新需求,有沒有修改現有的代碼。經過上述的講解,希望你對代理模式的概念和優勢有一定的了解。

代理模式除了上述提到的用處外,還有一個用處是轉發調用請求,以 Manis 為例,假如我們為ClientProtocol創建一個代理對象manisDb,在manisDb上調用getTableCount方法時,便會跳轉到代理對象的invoke方法中執行,在invoke方法中我們就可以將調用的方法和參數反序列化,並通過網絡發送服務端,這就實現了調用請求的轉發。

獲得本書

今天先介紹第一節, 想獲取完整內容可以關注公眾號 渡碼 回復關鍵字 manis

目錄

代碼結構

本書特色

在講解相冊內容同時,大部分章節都加入了課外拓展,針對每一節涉及的基礎知識,如:設計模式、序列化/反序列化基礎、單例測試、源碼分析、並發編程以及Hadoop源碼分析等內容都有拓展講解。力求讓零基礎的朋友也能跟上本書節奏,從0到1獨立完成一個項目。

希望你學完本書後不只學會了某項技術,而是提高了設計實現整個系統的能力。

歡迎公眾號「渡碼」,輸出別地兒看不到的乾貨。

Tags: