透視RPC協議:SOFA-BOLT協議原始碼分析

throwable發表於2021-08-07

前提

最近在看Netty相關的資料,剛好SOFA-BOLT是一個比較成熟的Netty自定義協議棧實現,於是決定研讀SOFA-BOLT的原始碼,詳細分析其協議的組成,簡單分析其客戶端和服務端的原始碼實現。

  • 吐槽一下:SOFA-BOLT的程式碼縮排和FastJson類似,變數名稱強制對齊,對於一般開發者來說看著原始碼會有不適感

當前閱讀的原始碼是2021-08左右的SOFA-BOLT倉庫的master分支原始碼。

SOFA-BOLT簡單介紹

SOFA-BOLT是螞蟻金融服務集團開發的一套基於Netty實現的網路通訊框架,本質是一套Netty私有協議棧封裝,目的是為了讓開發者能將更多的精力放在基於網路通訊的業務邏輯實現上,而不是過多的糾結於網路底層NIO的實現以及處理難以除錯的網路問題和Netty二次開發問題。SOFA-BOLT的架構設計和功能如下:

上圖來源於SOFA-BOLT官網https://www.sofastack.tech/projects/sofa-bolt/overview

SOFA-BOLT協議透視

由於SOFA-BOLT協議是基於Netty實現的自定義協議棧,協議本身的實現可以快速地在EncoderDecoder的實現中找到,進一步定位到com.alipay.remoting.rpc包中。從原始碼得知,SOFA-BOLT協議目前有兩個版本,協議在RpcProtocolRpcProtocolV2的類頂部註釋中有比較詳細的介紹,基於這些介紹可以簡單整理出兩個版本協議的基本構成。

V1版本協議的基本構成

  • V1版本的協議請求Frame基本構成:

  • V1版本的協議響應Frame基本構成:

針對V1版本的協議,各個屬性展開如下:

  • 請求Frame和響應Frame的公共屬性:
屬性Code 屬性含義 Java型別 大小(byte) 備註
proto 協議編碼 byte 1 V1版本下,proto = 1V2版本下,proto = 2
type 型別 byte 1 0 => RESPONSE1 => REQUEST2 => REQUEST_ONEWAY
cmdcode 命令編碼 short 2 1 => rpc request2 => rpc response
ver2 命令版本 byte 1 從原始碼得知目前固定為1
requestId 請求ID int 4 某個請求CMD的全域性唯一標識
codec 編碼解碼器 byte 1 -

上表中,codec從字面上理解是編碼解碼器,實際上是序列化和反序列實現的標記,V1和V2目前都是固定codec = 1,通過原始碼跟蹤到SerializerManager的配置值為Hessian2 = 1,也就是預設使用Hessian2進行序列化和反序列化,詳細見原始碼中的HessianSerializer

  • 請求Frame特有的屬性:
屬性Code 屬性含義 Java型別 大小(byte) 備註
timeout 請求超時時間 int 4
classLen 請求物件(引數)型別的名稱長度 short 2 >=0
headerLen 請求頭長度 short 2 >=0
contentLen 請求內容長度 int 4 >=0
className bytes 請求物件(引數)型別的名稱 byte[] -
header bytes 請求頭 byte[] -
content bytes 請求內容 byte[] -
  • 響應Frame特有的屬性:
屬性Code 屬性含義 Java型別 大小(byte) 備註
respstatus 響應狀態值 short 2 ResponseStatus中定義,目前內建13種狀態,例如0 => SUCCESS
classLen 響應物件(引數)型別的名稱長度 short 2 >=0
headerLen 響應頭長度 short 2 >=0
contentLen 響應內容長度 int 4 >=0
className bytes 響應物件(引數)型別的名稱 byte[] -
header bytes 響應頭 byte[] -
content bytes 響應內容 byte[] -

這裡可以看出V1版本中的請求Frame和響應Frame只有細微的差別,(請求Frame中獨立存在timeout屬性,而響應Frame獨立存在respstatus屬性),絕大部分的屬性都是複用的,並且三個長度和三個位元組陣列是相互制約的:

  • classLen <=> className bytes
  • headerLen <=> header bytes
  • contentLen <=> content bytes

V2版本協議的基本構成

  • V2版本的協議請求Frame基本構成:

  • V2版本的協議響應Frame基本構成:

V2版本的協議相比V1版本多了2個必傳公共屬性和1個可選公共屬性:

屬性Code 屬性含義 Java型別 大小(byte) 備註
ver1 協議版本 byte 1 是為了在V2版本協議中相容V1版本的協議
switch 協議開關 byte 1 基於BitSet實現的開關,最多8
CRC32 迴圈冗餘校驗值 int 4 可選的,由開關ProtocolSwitch.CRC_SWITCH_INDEX決定是否啟用,啟用的時候會基於整個Frame進行計算

這幾個新增屬性中,switch代表ProtocolSwitch實現中的BitSet轉換出來的byte欄位,由於byte只有8位,因此協議在傳輸過程中最多隻能傳遞8個開關的狀態,這些開關的下標為[0,7]CRC32是基於整個Frame轉換出來的byte陣列進行計算,JDK中有原生從API,可以簡單構建一個工具類如下進行計算:

public enum Crc32Utils {

    /**
     * 單例
     */
    X;

    /**
     * 進行CRC32結果計算
     *
     * @param content 內容
     * @return crc32 result
     */
    public long crc32(byte[] content) {
        CRC32 crc32 = new CRC32();
        crc32.update(content, 0, content.length);
        long r = crc32.getValue();
        // crc32.reset();
        return r;
    }
}

V2版本協議把CRC32的計算結果強制轉換為int型別,可以思考一下這裡為什麼不會溢位。

SOFA-BOLT架構

考慮到如果分析原始碼,文章篇幅會比較長,並且如果有開發過Netty自定義協議棧的經驗,SOFA-BOLT的原始碼並不複雜,這裡僅僅分析SOFA-BOLT的架構和核心元件功能。協議由介面Protocol定義:

public interface Protocol {
    
    // 命令編碼器
    CommandEncoder getEncoder();

    // 命令解碼器
    CommandDecoder getDecoder();

    // 心跳觸發器
    HeartbeatTrigger getHeartbeatTrigger();

    // 命令處理器
    CommandHandler getCommandHandler();

    // 命令工廠
    CommandFactory getCommandFactory();
}

V2版本協議實現RpcProtocolV2可以得知:

另外,所有需要傳送或者接收的Frame都被封裝為Command,而Command的類族如下:

也就是:

  • RequestCommand定義了請求命令需要的所有屬性,最終由RpcCommandEncoderV2進行編碼
  • ResponseCommand定義了響應命令需要的所有屬性,最終由RpcCommandDecoderV2進行解碼

梳理完上面的元件就可以畫出下面的一個基於SOFA-BOLT協議進行的Client => Server的互動圖:

SOFA-BOLT使用

由於sofa-bolt已經封裝好了完整的RpcClientRpcServer,使用此協議只需要引用依賴,然後初始化客戶端和服務端,編寫對應的UserProcessor實現即可。引入相關依賴:

<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>bolt</artifactId>
    <version>1.6.3</version>
</dependency>
<dependency>
    <groupId>com.caucho</groupId>
    <artifactId>hessian</artifactId>
    <version>4.0.65</version>
</dependency>

新建請求實體類RequestMessage、響應實體類ResponseMessage和對應的處理器RequestMessageProcessor

@Data
public class RequestMessage implements Serializable {

    private Long id;

    private String content;
}

@Data
public class ResponseMessage implements Serializable {

    private Long id;

    private String content;

    private Long status;
}

public class RequestMessageProcessor extends SyncUserProcessor<RequestMessage> {

    @Override
    public Object handleRequest(BizContext bizContext, RequestMessage requestMessage) throws Exception {
        ResponseMessage message = new ResponseMessage();
        message.setContent(requestMessage.getContent());
        message.setId(requestMessage.getId());
        message.setStatus(10087L);
        return message;
    }

    @Override
    public String interest() {
        return RequestMessage.class.getName();
    }
}

其中處理器需要同步處理需要繼承超類SyncUserProcessor,選用非同步處理的時候需要繼承超類AsyncUserProcessor,作為引數的所有實體類必須實現Serializable介面(如果有巢狀物件,每個巢狀物件所在類也必須實現Serializable介面),否則會出現序列化相關的異常。最後編寫客戶端和服務端的程式碼:

@Slf4j
public class BlotApp {

    private static final int PORT = 8081;
    private static final String ADDRESS = "127.0.0.1:" + PORT;

    public static void main(String[] args) throws Exception {
        RequestMessageProcessor processor = new RequestMessageProcessor();
        RpcServer server = new RpcServer(8081, true);
        server.startup();
        server.registerUserProcessor(processor);
        RpcClient client = new RpcClient();
        client.startup();
        RequestMessage request = new RequestMessage();
        request.setId(99L);
        request.setContent("hello bolt");
        ResponseMessage response = (ResponseMessage) client.invokeSync(ADDRESS, request, 2000);
        log.info("響應結果:{}", response);
    }
}

執行輸出結果:

響應結果:ResponseMessage(id=99, content=hello bolt, status=10087)

基於SOFA-BOLT協議編寫簡單CURD專案

本地測試MySQL服務構建客戶表如下:

CREATE DATABASE test;

USE test;

CREATE TABLE t_customer
(
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    customer_name VARCHAR(32)     NOT NULL
);

為了簡化JDBC操作,引入spring-boot-starter-jdbc(這裡只借用JdbcTemplate的輕度封裝)相關依賴:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.20</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
    <version>2.3.0.RELEASE</version>
</dependency>

編寫核心同步處理器:

// 建立
@Data
public class CreateCustomerReq implements Serializable {

    private String customerName;
}

@Data
public class CreateCustomerResp implements Serializable {

    private Long code;

    private Long customerId;
}

public class CreateCustomerProcessor extends SyncUserProcessor<CreateCustomerReq> {

    private final JdbcTemplate jdbcTemplate;

    public CreateCustomerProcessor(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Object handleRequest(BizContext bizContext, CreateCustomerReq req) throws Exception {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement("insert into t_customer(customer_name) VALUES (?)",
                    Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, req.getCustomerName());
            return ps;
        }, keyHolder);
        CreateCustomerResp resp = new CreateCustomerResp();
        resp.setCustomerId(Objects.requireNonNull(keyHolder.getKey()).longValue());
        resp.setCode(RespCode.SUCCESS);
        return resp;
    }

    @Override
    public String interest() {
        return CreateCustomerReq.class.getName();
    }
}

// 更新
@Data
public class UpdateCustomerReq implements Serializable {

    private Long customerId;

    private String customerName;
}


public class UpdateCustomerProcessor extends SyncUserProcessor<UpdateCustomerReq> {

    private final JdbcTemplate jdbcTemplate;

    public UpdateCustomerProcessor(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Object handleRequest(BizContext bizContext, UpdateCustomerReq req) throws Exception {
        UpdateCustomerResp resp = new UpdateCustomerResp();
        int updateCount = jdbcTemplate.update("UPDATE t_customer SET customer_name = ? WHERE id = ?", ps -> {
            ps.setString(1, req.getCustomerName());
            ps.setLong(2, req.getCustomerId());
        });
        if (updateCount > 0) {
            resp.setCode(RespCode.SUCCESS);
        }
        return resp;
    }

    @Override
    public String interest() {
        return UpdateCustomerReq.class.getName();
    }
}

// 刪除
@Data
public class DeleteCustomerReq implements Serializable {

    private Long customerId;
}

@Data
public class DeleteCustomerResp implements Serializable {

    private Long code;
}

public class DeleteCustomerProcessor extends SyncUserProcessor<DeleteCustomerReq> {

    private final JdbcTemplate jdbcTemplate;

    public DeleteCustomerProcessor(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Object handleRequest(BizContext bizContext, DeleteCustomerReq req) throws Exception {
        DeleteCustomerResp resp = new DeleteCustomerResp();
        int updateCount = jdbcTemplate.update("DELETE FROM t_customer WHERE id = ?", ps -> ps.setLong(1,req.getCustomerId()));
        if (updateCount > 0){
            resp.setCode(RespCode.SUCCESS);
        }
        return resp;
    }

    @Override
    public String interest() {
        return DeleteCustomerReq.class.getName();
    }
}

// 查詢
@Data
public class SelectCustomerReq implements Serializable {

    private Long customerId;
}

@Data
public class SelectCustomerResp implements Serializable {

    private Long code;

    private Long customerId;

    private String customerName;
}

public class SelectCustomerProcessor extends SyncUserProcessor<SelectCustomerReq> {

    private final JdbcTemplate jdbcTemplate;

    public SelectCustomerProcessor(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Object handleRequest(BizContext bizContext, SelectCustomerReq req) throws Exception {
        SelectCustomerResp resp = new SelectCustomerResp();
        Customer result = jdbcTemplate.query("SELECT * FROM t_customer WHERE id = ?", ps -> ps.setLong(1, req.getCustomerId()), rs -> {
            Customer customer = null;
            if (rs.next()) {
                customer = new Customer();
                customer.setId(rs.getLong("id"));
                customer.setCustomerName(rs.getString("customer_name"));
            }
            return customer;
        });
        if (Objects.nonNull(result)) {
            resp.setCustomerId(result.getId());
            resp.setCustomerName(result.getCustomerName());
            resp.setCode(RespCode.SUCCESS);
        }
        return resp;
    }

    @Override
    public String interest() {
        return SelectCustomerReq.class.getName();
    }

    @Data
    public static class Customer {

        private Long id;
        private String customerName;
    }
}

編寫資料來源、客戶端和服務端程式碼:

public class CurdApp {

    private static final int PORT = 8081;
    private static final String ADDRESS = "127.0.0.1:" + PORT;

    public static void main(String[] args) throws Exception {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/test?useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai");
        config.setDriverClassName(Driver.class.getName());
        config.setUsername("root");
        config.setPassword("root");
        HikariDataSource dataSource = new HikariDataSource(config);
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        CreateCustomerProcessor createCustomerProcessor = new CreateCustomerProcessor(jdbcTemplate);
        UpdateCustomerProcessor updateCustomerProcessor = new UpdateCustomerProcessor(jdbcTemplate);
        DeleteCustomerProcessor deleteCustomerProcessor = new DeleteCustomerProcessor(jdbcTemplate);
        SelectCustomerProcessor selectCustomerProcessor = new SelectCustomerProcessor(jdbcTemplate);
        RpcServer server = new RpcServer(PORT, true);
        server.registerUserProcessor(createCustomerProcessor);
        server.registerUserProcessor(updateCustomerProcessor);
        server.registerUserProcessor(deleteCustomerProcessor);
        server.registerUserProcessor(selectCustomerProcessor);
        server.startup();
        RpcClient client = new RpcClient();
        client.startup();
        CreateCustomerReq createCustomerReq = new CreateCustomerReq();
        createCustomerReq.setCustomerName("throwable.club");
        CreateCustomerResp createCustomerResp = (CreateCustomerResp)
                client.invokeSync(ADDRESS, createCustomerReq, 5000);
        System.out.println("建立使用者[throwable.club]結果:" + createCustomerResp);
        SelectCustomerReq selectCustomerReq = new SelectCustomerReq();
        selectCustomerReq.setCustomerId(createCustomerResp.getCustomerId());
        SelectCustomerResp selectCustomerResp = (SelectCustomerResp)
                client.invokeSync(ADDRESS, selectCustomerReq, 5000);
        System.out.println(String.format("查詢使用者[id=%d]結果:%s", selectCustomerReq.getCustomerId(),
                selectCustomerResp));
        UpdateCustomerReq updateCustomerReq = new UpdateCustomerReq();
        updateCustomerReq.setCustomerId(selectCustomerReq.getCustomerId());
        updateCustomerReq.setCustomerName("throwx.cn");
        UpdateCustomerResp updateCustomerResp = (UpdateCustomerResp)
                client.invokeSync(ADDRESS, updateCustomerReq, 5000);
        System.out.println(String.format("更新使用者[id=%d]結果:%s", updateCustomerReq.getCustomerId(),
                updateCustomerResp));
        selectCustomerReq.setCustomerId(updateCustomerReq.getCustomerId());
        selectCustomerResp = (SelectCustomerResp)
                client.invokeSync(ADDRESS, selectCustomerReq, 5000);
        System.out.println(String.format("查詢更新後的使用者[id=%d]結果:%s", selectCustomerReq.getCustomerId(),
                selectCustomerResp));
        DeleteCustomerReq deleteCustomerReq = new DeleteCustomerReq();
        deleteCustomerReq.setCustomerId(selectCustomerResp.getCustomerId());
        DeleteCustomerResp deleteCustomerResp = (DeleteCustomerResp)
                client.invokeSync(ADDRESS, deleteCustomerReq, 5000);
        System.out.println(String.format("刪除使用者[id=%d]結果:%s", deleteCustomerReq.getCustomerId(),
                deleteCustomerResp));
    }
}

執行結果如下:

建立使用者[throwable.club]結果:CreateCustomerResp(code=0, customerId=1)
查詢使用者[id=1]結果:SelectCustomerResp(code=0, customerId=1, customerName=throwable.club)
更新使用者[id=1]結果:UpdateCustomerResp(code=0)
查詢更新後的使用者[id=1]結果:SelectCustomerResp(code=0, customerId=1, customerName=throwx.cn)
更新使用者[id=1]結果:DeleteCustomerResp(code=0)

確認最後刪除操作結束後驗證資料庫表,確認t_customer表為空。

基於GO語言編寫SOFA-BOLT協議客戶端

這裡嘗試使用GO語言編寫一個SOFA-BOLT協議客戶端,考慮到實現一個完整版本會比較複雜,這裡簡化為只實現Encode和命令呼叫部分,暫時不處理響應和Decode。編寫結構體RequestCommand如下:

// RequestCommand sofa-bolt v2 req cmd
type RequestCommand struct {
	ProtocolCode    uint8
	ProtocolVersion uint8
	Type            uint8
	CommandCode     uint16
	CommandVersion  uint8
	RequestId       uint32
	Codec           uint8
	Switch          uint8
	Timeout         uint32
	ClassLength     uint16
	HeaderLength    uint16
	ContentLength   uint32
	ClassName       []byte
	Header          []byte
	Content         []byte
}

這裡注意一點,所有的整數型別必須使用具體的型別,例如uint必須用uint32,否則會出現Buffer寫入異常的問題。接著編寫一個編碼方法:

// encode req => slice
func encode(cmd *RequestCommand) []byte {
	container := make([]byte, 0)
	buf := bytes.NewBuffer(container)
	buf.WriteByte(cmd.ProtocolCode)
	buf.WriteByte(cmd.ProtocolVersion)
	buf.WriteByte(cmd.Type)
	binary.Write(buf, binary.BigEndian, cmd.CommandCode)
	buf.WriteByte(cmd.CommandVersion)
	binary.Write(buf, binary.BigEndian, cmd.RequestId)
	buf.WriteByte(cmd.Codec)
	buf.WriteByte(cmd.Switch)
	binary.Write(buf, binary.BigEndian, cmd.Timeout)
	binary.Write(buf, binary.BigEndian, cmd.ClassLength)
	binary.Write(buf, binary.BigEndian, cmd.HeaderLength)
	binary.Write(buf, binary.BigEndian, cmd.ContentLength)
	buf.Write(cmd.ClassName)
	buf.Write(cmd.Header)
	buf.Write(cmd.Content)
	return buf.Bytes()
}

最後編寫TCP客戶端:

type Req struct {
	Id   int64  `json:"id"`
	Name string `json:"name"`
}

package main

import (
	"bytes"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"net"
)

func main() {
	con, err := net.Dial("tcp", "127.0.0.1:9999")
	if err != nil {
		fmt.Println("err:", err)
		return
	}
	defer con.Close()
	req := &Req{
		Id:   8080,
		Name: "throwx.cn",
	}
	content, err := json.Marshal(req)
	if err != nil {
		fmt.Println("err:", err)
		return
	}
	var header []byte
	className := []byte("com.alipay.remoting.Req")
	cmd := &RequestCommand{
		ProtocolCode:    2,
		ProtocolVersion: 2,
		Type:            1,
		CommandCode:     1,
		CommandVersion:  1,
		RequestId:       10087,
		Codec:           1,
		Switch:          0,
		Timeout:         5000,
		ClassLength:     uint16(len(className)),
		HeaderLength:    0,
		ContentLength:   uint32(len(content)),
		ClassName:       className,
		Header:          header,
		Content:         content,
	}
	pkg := encode(cmd)
	_, err = con.Write(pkg)
	if err != nil {
		fmt.Println("err:", err)
		return
	}
}

協議的V2版本Crc32屬性是可選的,這裡為了簡化處理也暫時忽略了

這裡看到Content屬性為了簡化處理使用了JSON做序列化,因此需要稍微改動SOFA-BOLT的原始碼,引入FastJsonFastJsonSerializer,改動見下圖:

先啟動BoltAppSOFA-BOLT服務端),再執行GO編寫的客戶端,結果如下:

小結

SOFA-BOLT是一個高效能成熟可擴充套件的Netty私有協議封裝,比起原生Netty程式設計,提供了便捷的同步、非同步呼叫,提供基礎心跳支援和重連等特性。引入SyncUserProcessorAsyncUserProcessor的功能,對於業務開發更加友好。SOFA-BOLT協議本質也是一個緊湊、高效能的RPC協議。在考慮引入Netty進行底層通訊的場景,可以優先考慮使用SOFA-BOLT或者考慮把SOFA-BOLT作為候選方案之一,只因SOFA-BOLT是輕量級的,學習曲線平緩,基本沒有其他中介軟體依賴。

Demo所在倉庫:

(本文完 c-5-d e-a-20210806)

相關文章