Tars-Java客戶端原始碼分析

vivo網際網路技術發表於2021-05-08

一、基本RPC框架簡介

在分散式計算中,遠端過程呼叫(Remote Procedure Call,縮寫 RPC)允許執行於一臺計算機的程式呼叫另一個地址空間計算機的程式,就像呼叫本地程式一樣,無需額外地為這個互動作用涉及到的代理物件構建、網路協議等進行程式設計。

一般RPC架構,有至少三種結構,分別為註冊中心,服務提供者和服務消費者。如圖1.1所示,註冊中心提供註冊服務和註冊資訊變更的通知服務,服務提供者執行在伺服器來提供服務,服務消費者使用服務提供者的服務。

服務提供者(RPC Server),執行在服務端,提供服務介面定義與服務實現類,並對外暴露服務介面。註冊中心(Registry),執行在服務端,負責記錄服務提供者的服務物件,並提供遠端服務資訊的查詢服務和變更通知服務。服務消費者(RPC Client),執行在客戶端,通過遠端代理物件呼叫遠端服務。

RPC框架基本結構

1.1 RPC呼叫流程

如下圖所示,描述了RPC的呼叫流程,其中IDL(Interface Description Language)為介面描述語言,使得在不同平臺上執行的程式和用不同語言編寫的程式可以相互通訊交流。

RPC呼叫流程

1)客戶端呼叫客戶端樁模組。該呼叫是本地過程呼叫,其中引數以正常方式推入堆疊。

2)客戶端樁模組將引數打包到訊息中,並進行系統呼叫以傳送訊息。打包引數稱為編組。

3)客戶端的本地作業系統將訊息從客戶端計算機傳送到伺服器計算機。

4)伺服器計算機上的本地作業系統將傳入的資料包傳遞到伺服器樁模組。

5)伺服器樁模組從訊息中解包出引數。解包引數稱為解組。

6)最後,伺服器樁模組執行伺服器程式流程。回覆是沿相反的方向執行相同的步驟。

二、Tars Java客戶端設計介紹

Tars Java客戶端整體設計與主流的RPC框架基本一致。我們先介紹Tars Java客戶端初始化過程。

2.1 Tars Java客戶端初始化過程

如圖2.1所示,描述了Tars Java的初始化過程。

Tars Java初始化過程

1)先出建立一個CommunicatorConfig配置項,命名為communicatorConfig,其中按需設定locator, moduleName, connections等引數。

2)通過上述的CommunicatorConfig配置項,命名為config,那麼呼叫CommunicatorFactory.getInstance().getCommunicator(config),建立一個Communicator物件,命名為communicator。

3)假設objectName="MESSAGE.ControlCenter.Dispatcher",需要生成的代理介面為Dispatcher.class,呼叫communicator.stringToProxy(objectName, Dispatcher.class)方法來生成代理物件的實現類。

4)在stringToProxy()方法裡,首先通過初始化QueryHelper代理物件,呼叫getServerNodes()方法獲取遠端服務物件列表,並設定該返回值到communicatorConfig的objectName欄位裡。具體的代理物件的程式碼分析,見下文中的“2.3 代理生成”章節。

5)判斷在之前呼叫stringToProxy是否有設定LoadBalance引數,如果沒有的話,就生成預設的採用RR輪訓演算法的DefaultLoadBalance物件。

6)建立TarsProtocolInvoker協議呼叫物件,其中過程有通過解析communicatorConfig中的objectName和simpleObjectName來獲取URL列表,其中一個URL對應一個遠端服務物件,TarsProtocolInvoker初始化各個URL對應的ServantClient物件,其中一個URL根據communicatorConfig的connections配置項確認生成多少個ServantClient物件。然後使用ServantClients等引數初始化TarsInvoker物件,並將這些TarsInvoker物件集合設定到TarsProtocolInvoker的allInvokers成員變數中,其中每個URL對應一個TarsInvoker物件。上述分析表明,一個遠端服務節點對應一個TarsInvoker物件,一個TarsInvoker物件包含connections個ServantClient物件,對於TCP協議,那麼就是一個ServantClient物件對應一個TCP連線。

7)使用api, objName, servantProxyConfig,loadBalance,protocolInvoker, this.communicator引數生成一個實現JDK代理介面InvocationHandler的ObjectProxy物件。

8)生成ObjectProxy物件的同時進行初始化操作,首先會執行loadBalancer.refresh()方法重新整理遠端服務節點到負載均衡器中便於後續tars遠端呼叫進行路由。

9)然後註冊統計資訊上報器,其中是上報方法採用JDK的ScheduledThreadPoolExecutor進行定時輪訓上報。

10)註冊服務列表重新整理器,採用的技術方法和上述統計資訊上報器基本一致。

2.2 使用範例

以下程式碼為最簡化示例,其中CommunicatorConfig裡的配置採用預設值,communicator通過CommunicatorConfig配置生成後,直接指定遠端服務物件的具體服務物件名、IP和埠生成一個遠端服務代理物件。

Tars Java程式碼使用範例// 先初始化基本Tars配置CommunicatorConfig cfg = new CommunicatorConfig();// 通過上述的CommunicatorConfig配置生成一個Communicator物件。Communicator communicator = CommunicatorFactory.getInstance().getCommunicator(cfg);// 指定Tars遠端服務的服務物件名、IP和埠生成一個遠端服務代理物件。

// 先初始化基本Tars配置
    CommunicatorConfig cfg = new CommunicatorConfig();
    // 通過上述的CommunicatorConfig配置生成一個Communicator物件。
    Communicator communicator = CommunicatorFactory.getInstance().getCommunicator(cfg);
    // 指定Tars遠端服務的服務物件名、IP和埠生成一個遠端服務代理物件。
    HelloPrx proxy = communicator.stringToProxy(HelloPrx.class, "TestApp.HelloServer.HelloObj@tcp -h 127.0.0.1 -p 18601 -t 60000");
    //同步呼叫,阻塞直到遠端服務物件的方法返回結果
    String ret = proxy.hello(3000, "Hello World");
    System.out.println(ret);
    //非同步呼叫,不關注非同步呼叫最終的情況
    proxy.async_hello(null, 3000, "Hello World");
      //非同步呼叫,註冊一個實現TarsAbstractCallback介面的回執處理物件,該實現類分別處理呼叫成功,呼叫超時和呼叫異常的情況。
    proxy.async_hello(new HelloPrxCallback() {
        @Override
        public void callback_expired() { //超時事件處理
        }
        @Override
        public void callback_exception(Throwable ex) { //異常事件處理
        }
        @Override
        public void callback_hello(String ret) { //呼叫成功事件處理
            Main.logger.info("invoke async method successfully {}", ret);
       }
    }, 1000, "Hello World");

在上述例子中,演示了常見的兩種呼叫方式,分別為同步呼叫和非同步呼叫。其中非同步呼叫,如果呼叫方想捕捉非同步呼叫的最終結果,可以註冊一個實現TarsAbstractCallback介面的實現類,對tars呼叫的異常,超時和成功事件進行處理。

2.3 代理生成

Tars Java的客戶端樁模組的遠端代理物件是採用JDK原生Proxy方法。如下文的原始碼所示,ObjectProxy實現了java.lang.reflect.InvocationHandler的介面方法,該介面是JDK自帶的代理介面。

代理實現

public final class ObjectProxy<T> implements ServantProxy, InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        Class<?>[] parameterTypes = method.getParameterTypes();
        InvokeContext context = this.protocolInvoker.createContext(proxy, method, args);
        try {
            if ("toString".equals(methodName) && parameterTypes.length == 0) {
                return this.toString();
            } else if
                //***** 省略程式碼 *****
            } else {
                // 在負載均衡器選取一個遠端呼叫類,進行應用層協議的封裝,最後呼叫TCP傳輸層進行傳送。
                Invoker invoker = this.loadBalancer.select(context);
                return invoker.invoke(context);
            }
        } catch (Throwable var8) {
            // ***** 省略程式碼 *****
        }
    }
}

當然生成上述遠端服務代理類,涉及到輔助類,Tars Java採用ServantProxyFactory來生成上述的ObjectProxy,並儲存ObjectProxy物件到Map結構,便於呼叫方二次使用時直接複用已存在的遠端服務代理物件。

具體相關邏輯如原始碼所示,ObjectProxyFactory是生成ObjectProxy的輔助工廠類,和ServantProxyFactory不同,其本身不快取生成的代理物件。

class ServantProxyFactory {
    private final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap();
    // ***** 省略程式碼 *****
    public <T> Object getServantProxy(Class<T> clazz, String objName, ServantProxyConfig servantProxyConfig, LoadBalance loadBalance, ProtocolInvoker<T> protocolInvoker) {
        Object proxy = this.cache.get(objName);
        if (proxy == null) {
            this.lock.lock(); // 加鎖,保證只生成一個遠端服務代理物件。
            try {
                proxy = this.cache.get(objName);
                if (proxy == null) {
                    // 建立實現JDK的java.lang.reflect.InvocationHandler介面的物件
                    ObjectProxy<T> objectProxy = this.communicator.getObjectProxyFactory().getObjectProxy(clazz, objName, servantProxyConfig, loadBalance, protocolInvoker);
                    // 使用JDK的java.lang.reflect.Proxy來生成實際的代理物件
                    this.cache.putIfAbsent(objName, this.createProxy(clazz, objectProxy));
                    proxy = this.cache.get(objName);
                }
            } finally {
                this.lock.unlock();
            }
        }
        return proxy;
    }
    /** 使用JDK自帶的Proxy.newProxyInstance生成代理物件 */
    private <T> Object createProxy(Class<T> clazz, ObjectProxy<T> objectProxy) {
        return Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{clazz, ServantProxy.class}, objectProxy);
    }
    // ***** 省略程式碼 *****
}

從以上的原始碼中,可以看到createProxy使用了JDK的Proxy.newProxyInstance方法來生成遠端服務代理物件。

2.4 遠端服務定址方法

作為一個RPC遠端框架,在分散式系統中,呼叫遠端服務,涉及到如何路由的問題,也就是如何從多個遠端服務節點中選擇一個服務節點進行呼叫,當然Tars Java支援直連特定節點的方式呼叫遠端服務,如上文的2.2 使用範例所介紹。

如圖下圖所示,ClientA某個時刻的一次呼叫使用了Service3節點進行遠端服務呼叫,而ClientB某個時刻的一次呼叫採用Service2節點。Tars Java提供多種負載均衡演算法實現類,其中有采用RR輪訓演算法的RoundRobinLoadBalance,一致性雜湊演算法的ConsistentHashLoadBalance和普通雜湊演算法的HashLoadBalance。

客戶端按特定路由規則呼叫遠端服務

(客戶端按特定路由規則呼叫遠端服務)

如下述原始碼所示,如果要自定義負載均衡器來定義遠端呼叫的路由規則,那麼需要實現com.qq.tars.rpc.common.LoadBalance介面,其中LoadBalance.select()方法負責按照路由規則,選取對應的Invoker物件,然後進行遠端呼叫,具體邏輯見原始碼代理實現。由於遠端服務節點可能發生變更,比如上下線遠端服務節點,需要重新整理本地負載均衡器的路由資訊,那麼此資訊更新的邏輯在LoadBalance.refresh()方法裡實現。

負載均衡介面

public interface LoadBalance<T> {
    /** 根據負載均衡策略,挑選invoker */
    Invoker<T> select(InvokeContext invokeContext) throws NoInvokerException;
    /** 通知invoker列表的更新 */
    void refresh(Collection<Invoker<T>> invokers);
}

2.5 網路模型

Tars Java的IO模式採用的JDK的NIO的Selector模式。這裡以TCP協議來描述網路處理,如下述原始碼所示,Reactor是一個執行緒,其中的run()方法中,呼叫了selector.select()方法,意思是如果除非此時網路產生一個事件,否則將一直執行緒阻塞下去。

假如此時出現一個網路事件,那麼此時執行緒將會被喚醒,執行後續程式碼,其中一個程式碼是dispatcheEvent(key),也就是將進行事件的分發。

其中將根據對應條件,呼叫acceptor.handleConnectEvent(key)方法來處理客戶端連線成功事件,或acceptor.handleAcceptEvent(key)方法來處理伺服器接受連線成功事件,或呼叫acceptor.handleReadEvent(key)方法從Socket裡讀取資料,或acceptor.handleWriteEvent(key)方法來寫資料到Socket 。

Reactor事件處理

public final class Reactor extends Thread {
    protected volatile Selector selector = null;
    private Acceptor acceptor = null;
    //***** 省略程式碼 *****
    public void run() {
        try {
            while (!Thread.interrupted()) {
                // 阻塞直到有網路事件發生。
                selector.select();
                //***** 省略程式碼 *****
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                    iter.remove();
                    if (!key.isValid()) continue;
                    try {
                        //***** 省略程式碼 *****
                        // 分發傳輸層協議TCP或UDP網路事件
                        dispatchEvent(key);
                //***** 省略程式碼 *****
            }
        }
        //***** 省略程式碼 *****
    }
        //***** 省略程式碼 *****
    private void dispatchEvent(final SelectionKey key) throws IOException {
        if (key.isConnectable()) {
            acceptor.handleConnectEvent(key);
        } else if (key.isAcceptable()) {
            acceptor.handleAcceptEvent(key);
        } else if (key.isReadable()) {
            acceptor.handleReadEvent(key);
        } else if (key.isValid() && key.isWritable()) {
            acceptor.handleWriteEvent(key);
        }
    }
}

網路處理採用Reactor事件驅動模式,Tars定義一個Reactor物件對應一個Selector物件,針對每個遠端服務(整體服務叢集,非單個節點程式)預設建立2個Reactor物件進行處理,通過修改com.qq.tars.net.client.selectorPoolSize這個JVM啟動引數值來決定一個遠端服務具體建立幾個Reactor物件。

Tars-Java的網路事件處理模型

上圖中的處理讀IO事件(Read Event)實現和寫IO事件(Write Event)的執行緒池是在Communicator初始化的時候配置的。具體邏輯如原始碼所示,其中執行緒池引數配置由CommunicatorConfig的corePoolSize, maxPoolSize, keepAliveTime等引數決定。

讀寫事件執行緒池初始化

private void initCommunicator(CommunicatorConfig config) throws CommunicatorConfigException {
    //***** 省略程式碼 *****
    this.threadPoolExecutor = ClientPoolManager.getClientThreadPoolExecutor(config);
    //***** 省略程式碼 *****
}
​
public class ClientPoolManager {
    public static ThreadPoolExecutor getClientThreadPoolExecutor(CommunicatorConfig communicatorConfig) {
        //***** 省略程式碼 *****
        clientThreadPoolMap.put(communicatorConfig, createThreadPool(communicatorConfig));
        //***** 省略程式碼 *****
        return clientPoolExecutor;
    }    
     
    private static ThreadPoolExecutor createThreadPool(CommunicatorConfig communicatorConfig) {
        int corePoolSize = communicatorConfig.getCorePoolSize();
        int maxPoolSize = communicatorConfig.getMaxPoolSize();
        int keepAliveTime = communicatorConfig.getKeepAliveTime();
        int queueSize = communicatorConfig.getQueueSize();
        TaskQueue taskqueue = new TaskQueue(queueSize);
​
        String namePrefix = "tars-client-executor-";
        TaskThreadPoolExecutor executor = new TaskThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, taskqueue, new TaskThreadFactory(namePrefix));
        taskqueue.setParent(executor);
        return executor;
    }
}

2.6 遠端呼叫互動模型

呼叫代理類的方法,那麼會進入實現InvocationHandler介面的ObjectProxy中的invoke方法。

下圖描述了遠端服務呼叫的流程情況。這裡著重講幾個點,一個是如何寫資料到網路IO。第二個是Tars Java通過什麼方式進行同步或者非同步呼叫,底層採用了什麼技術。

遠端呼叫流程

2.6.1 寫 IO 流程

如圖(底層程式碼寫IO過程)所示,ServantClient將呼叫底層網路寫操作,在invokeWithSync方法中,取得ServantClient自身成員變數TCPSession,呼叫TCPSession.write()方法,如圖(底層程式碼寫IO過程)和以下原始碼( 讀寫事件執行緒池初始化)所示,先獲取Encode進行請求內容編碼成IoBuffer物件,最後將IoBuffer的java.nio.ByteBuffer內容放入TCPSession的queue成員變數中,然後呼叫key.selector().wakeup(),喚醒Reactor中run()方法中的Selector.select(),執行後續的寫操作。

底層程式碼寫IO過程

具體Reactor邏輯見上文2.5 網路模型內容,如果Reactor檢查條件發現可以寫IO的話也就是key.isWritable()為true,那麼最終會迴圈從TCPSession.queue中取出ByteBuffer物件,呼叫SocketChannel.write(byteBuffer)執行實際的寫網路Socket操作,程式碼邏輯見原始碼中的doWrite()方法。

讀寫事件執行緒池初始化

public class TCPSession extends Session {
    public void write(Request request) throws IOException {
        try {
            IoBuffer buffer = selectorManager.getProtocolFactory().getEncoder().encodeRequest(request, this);
            write(buffer);
        //***** 省略程式碼 *****
    }
    protected void write(IoBuffer buffer) throws IOException {
        //***** 省略程式碼 *****
        if (!this.queue.offer(buffer.buf())) {
            throw new IOException("The session queue is full. [ queue size:" + queue.size() + " ]");
        }
        if (key != null) {
            key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
            key.selector().wakeup();
        }
    }
    protected synchronized int doWrite() throws IOException {
        int writeBytes = 0;
        while (true) {
            ByteBuffer wBuf = queue.peek();
            //***** 省略程式碼 *****
            int bytesWritten = ((SocketChannel) channel).write(wBuf);
            //***** 省略程式碼 *****
        return writeBytes;
    }
}

2.6.2 同步和非同步呼叫的底層技術實現

對於同步方法呼叫,如圖(遠端呼叫流程)和原始碼(ServantClient的同步呼叫)所示,ServantClient呼叫底層網路寫操作,在invokeWithSync方法中建立一個Ticket物件,Ticket顧名思義就是票的意思,這張票唯一標識本次網路呼叫情況。

ServantClient的同步呼叫

public class ServantClient {
    public <T extends ServantResponse> T invokeWithSync(ServantRequest request) throws IOException {
            //***** 省略程式碼 *****
            ticket = TicketManager.createTicket(request, session, this.syncTimeout);
            Session current = session;
            current.write(request);
            if (!ticket.await(this.syncTimeout, TimeUnit.MILLISECONDS)) {
            //***** 省略程式碼 *****
            response = ticket.response();
            //***** 省略程式碼 *****
            return response;
            //***** 省略程式碼 *****
        return response;
    }
}

如程式碼所示,在執行完session.write()操作後,緊接著執行ticket.await()方法,該方法執行緒等待直到遠端服務回覆返回結果到客戶端,ticket.await()被喚醒後,將執行後續操作,最終invokeWithSync方法返回response物件。其中Ticket的等待喚醒功能內部採用java.util.concurrent.CountDownLatch來實現。

對於非同步方法呼叫,將會執行ServantClient.invokeWithAsync方法,也會建立一個Ticket,並且執行Session.write()操作,雖然不會呼叫ticket.await(),但是在Reactor接收到遠端回覆時,首先會先解析Tars協議頭得到Response物件,然後將Response物件放入如圖(Tars-Java的網路事件處理模型)所示的IO讀寫執行緒池中進行進一步處理,如下述原始碼(非同步回撥事件處理)所示,最終會呼叫WorkThread.run()方法,在run()方法裡執行ticket.notifyResponse(resp),該方法裡面會執行類似上述程式碼2.1中的實現TarsAbstractCallback介面的呼叫成功回撥的方法。

非同步回撥事件處理

public final class WorkThread implements Runnable {
    public void run() {
        try {
            //***** 省略程式碼 *****
                Ticket<Response> ticket = TicketManager.getTicket(resp.getTicketNumber());
            //***** 省略程式碼 *****
                ticket.notifyResponse(resp);
                ticket.countDown();
                TicketManager.removeTicket(ticket.getTicketNumber());
            }
            //***** 省略程式碼 *****
    }
}

如下述原始碼所示,TicketManager會有一個定時任務輪訓檢查所有的呼叫是否超時,如果(currentTime - t.startTime) > t.timeout條件成立,那麼會呼叫t.expired()告知回撥物件,本次呼叫超時。

呼叫超時事件處理

public class TicketManager {
            //***** 省略程式碼 *****
    static {
        executor.scheduleAtFixedRate(new Runnable() {
            long currentTime = -1;
            public void run() {
                Collection<Ticket<?>> values = tickets.values();
                currentTime = System.currentTimeMillis();
                for (Ticket<?> t : values) {
                    if ((currentTime - t.startTime) > t.timeout) {
                        removeTicket(t.getTicketNumber());
                        t.expired();
                    }
                }
            }
        }, 500, 500, TimeUnit.MILLISECONDS);
    }
}

三、總結

程式碼的呼叫一般都是層層遞迴呼叫,程式碼的呼叫深度和廣度都很大,通過除錯程式碼的方式一步步學習原始碼的方式,更加容易理解原始碼的含義和設計理念。

Tars與其他RPC框架,並沒有什麼本質區別,通過類比其他框架的設計理念,可以更加深入理解Tars Java設計理念。

四、參考文獻

1.Remote procedure call

2.Tars Java原始碼Github倉庫

3.RPC框架簡介與原理

作者:vivo 網際網路伺服器團隊-Ke Shengkai

相關文章