對Thrift的一點點理解

BruceZhang發表於2015-10-08

對Thrift的一點點理解


這是一篇學習Thrift的筆記,包含了這樣幾點內容:

  • 簡單介紹Thrift
  • 怎樣使用Thrift
  • Thrift整體架構
  • Thrift中的知識點
      struct可以設定預設值
      thrift中的序列化機制
      thrift中的版本控制

簡單介紹Thrift

  它是一款RPC通訊框架,採用C/S架構,且擁有高效的序列化機制。要使用Thrift,首先我們需要在遠端伺服器上開啟Thrift服務,之後,伺服器端程式保持睡眠狀態,直到客戶端程式碼的呼叫。
  Thrift應用廣泛的一個主要原因是它支援多種主流的語言,且使用它的使用者不需要關注伺服器和客戶端是怎樣實現通訊,怎樣實現序列化的,只需要去考慮怎樣實現自己需要的業務邏輯。
  Thrift使用介面語言定義資料結構和服務,包含了最常用的資料型別,並一一對應各種語言的基本型別,還可以定義列舉和異常等等。


怎樣使用Thrift

  Thrift把它定義的相當簡潔,以致於我們的使用過程也是異常的方便,簡單來說,使用Thrift的過程只是需要以下的四個步驟:
  1. 設計需要互動的資料格式(struct、enum等等)和具體的服務(service),定義thrift介面描述檔案,也就是字尾名是 .thrift
  2. 利用thrift工具(我使用的是比較老的版本0.5.0),根據之前定義的介面檔案生成目標語言檔案(在這次的筆記中客戶端程式碼和伺服器程式碼都是使用java語言)
  3. 實現服務(service)程式碼,並把實現的業務邏輯設定為thrift伺服器的處理層,選擇埠,伺服器啟動監聽,等待客戶端的連線請求
  4. 客戶端使用相同的埠連線伺服器請求服務

下面簡單的介紹下thrift介面描述語言(IDL)的型別:
IDL包含基礎型別、結構、容器、異常和服務這樣幾種型別:
  基礎型別 : 包括了 bool,byte、i16,i32,i64,double,string,每一種都對應各種語言的基礎型別
  結構 : 在thrift中定義為struct,它類似於C語言中的結構體,是基礎型別的集合體,每一個結構都會生成一個單獨的類,在java中類似於pojo
  容器 : thrift中定義了常用的三種容器 – list,set,map,在Java中各自的對應實現是 ArrayList、HashSet、HashMap,其中模板型別可以是基礎型別或者結構型別
  異常 : 異常的定義類似於struct,只是換成了exception
  服務 : 服務類似於java中的介面,需要對服務中的每一個方法簽名定義返回型別、引數宣告、丟擲的異常,對於方法丟擲的異常,除了自己宣告的之外,每個方法還都會丟擲TException,對於返回值是void型別的方法,我們可以在方法簽名的前面加上oneway識別符號,將這個方法標記為非同步的模式,即呼叫之後會立即返回

  下面,為了更好的理解怎樣使用thrift,以及怎樣使用IDL中的型別,我將舉一個例子,當然,這個例子只是為了演示過程,並沒有過多的設計,可能會存在一些並不實用的邏輯。
  怎樣開始寫這個例子呢?對呀,就按照之前介紹的Thrift過程的四個步驟就可以了:

  • 定義介面描述檔案(.thrift)
      qinyi_student_model.thrift     定義學生資訊和學校資訊的資料結構
/**
 * qinyi student thrift model
 * @author qinyi
 * @since 2015-10-02
 */

namespace java com.qinyi.thrift_study.thrift_example

enum Sex {
    Boy = 1;
    Girl = 2;
}

struct StudentInfo {
    1: required string name;
    2: required Sex sex;
    3: required i32 age;
    4: optional list<string> hobby;
    5: required map<string, i64> number;
}

struct School {
    1: required string name;
    2: required list<StudentInfo> students;
    3: optional string description;
}

  可以看到,我們使用namespace定義檔案的名稱空間,由於目的碼是java語言,所以namespace java之後的宣告代表的就是包名,struct結構中每一個屬性前都有一個數字id標識,這個一旦定義了,最好不要去更改,具體的原因下文會有具體說明,屬性型別前有required/optional宣告,代表這個屬性是必須要設定的或者可以選擇不設定,如果這個屬性被宣告為required,但是在程式碼中沒有set,thrift會認為這是一個異常,當然,我們可以對屬性設定預設值,就是宣告的時候賦值就可以了。檔案開始的部分使用的java風格的註釋,這也是可選的,thrift支援c,c++,shell,java風格的註釋,怎樣註釋根據個人習慣就好。

    qinyi_student_exception.thrift      定義異常

/**
 * qinyi student thrift exception
 * @author qinyi
 * @since 2015-10-02
 */

namespace java com.qinyi.thrift_study.thrift_example

exception StudentException {
    1: required i64 errorCode;
    2: required string description;
    3: optional string causeInfo;
}

  我們可以看到,異常的定義和上面檔案中的struct是極為相似的。

    qinyi_student_service.thrift      定義服務

/**
 * qinyi student thrift service
 * @author qinyi
 * @since 2015-10-02
 */

namespace java com.qinyi.thrift_study.thrift_example

include "qinyi_student_model.thrift"
include "qinyi_student_exception.thrift"

// 一個服務的定義在語義上相當於物件導向程式設計中的一個介面
service StudentService {

    // add student to school
    bool addStudentToSchool(1: qinyi_student_model.StudentInfo student) throws (1: qinyi_student_exception.StudentException ex);

    // get student info by name
    list<qinyi_student_model.StudentInfo> getStudentInfoByName(1: string name) throws (1: qinyi_student_exception.StudentException ex);

    // print single student info
    void printStudentInfo(1: qinyi_student_model.StudentInfo student) throws (1: qinyi_student_exception.StudentException ex);

    // print list students info
    void printStudentsInfo(1: list<qinyi_student_model.StudentInfo> students) throws (1: qinyi_student_exception.StudentException ex);
}

  如果你熟悉C語言的話,對include肯定不會陌生,thrift中也可以這樣引用其他的thrift檔案,而且include之後需要是雙引號,在檔案中對於引用其他thrift檔案的欄位也都要使用全名。

  • 使用thrift工具利用IDL生成目的碼
      正如之前所述,這是thrift過程的第二個步驟,這裡,為了便於操作,我們寫一個shell指令碼吧:
#!/bin/bash

thrift_home="{your_thrift_home}/thrift_version/bin"
thrift_file="{your_thrift_idl_files}"

${thrift_home}/thrift --gen java ${thrift_file}/qinyi_student_exception.thrift
${thrift_home}/thrift --gen java ${thrift_file}/qinyi_student_model.thrift
${thrift_home}/thrift --gen java ${thrift_file}/qinyi_student_service.thrift

    由於目標語言是java,且在thrift指令碼中定義了名稱空間,所以,執行上面的指令碼之後,生成的目錄結構會是這樣:
  /gen-java/com/qinyi/thrift_study/thrift_example

  • 實現服務業務邏輯並開始服務監聽
      接下來的第三步是實現介面中的業務邏輯,並等待客戶端呼叫這些業務邏輯,比較簡單,業務邏輯實現檔案是 : StudentServiceImpl.java
/**
 * Created by qinyi on 10/2/15.
 */
public class StudentServiceImpl implements StudentService.Iface {

    @Override
    public boolean addStudentToSchool(StudentInfo student) throws StudentException, TException {
        if (null == student) {
            throw new StudentException().setErrorCode(-1)
                    .setDescription("addStudentToSchool(StudentInfo student) error")
                    .setCauseInfo("student is null");
        }
        List<StudentInfo> students = SchoolMock.getInstance().getStudents();
        students.add(student);
        SchoolMock.getInstance().setStudents(students);

        return true;
    }

    @Override
    public List<StudentInfo> getStudentInfoByName(String name) throws StudentException, TException {
        if (null == name) {
            throw new StudentException().setErrorCode(-1)
                    .setDescription("getStudentInfoByName(String name) error")
                    .setCauseInfo("name is null");
        }

        List<StudentInfo> students = SchoolMock.getInstance().getStudents();
        List<StudentInfo> results = new ArrayList<StudentInfo>();
        for (StudentInfo student : students) {
            if (student.getName().equals(name)) {
                results.add(student);
            }
        }

        return results;
    }

    @Override
    public void printStudentInfo(StudentInfo student) throws StudentException, TException {
        if (null == student) {
            throw new StudentException().setErrorCode(-1)
                    .setDescription("printStudentInfo(StudentInfo student) error")
                    .setCauseInfo("student is null");
        }

        StringBuilder builder = new StringBuilder();
        builder.append("name : ").append(student.getName()).append("\n");
        if (student.getSex().getValue() == 1) {
            builder.append("sex : boy").append("\n");
        } else {
            builder.append("sex : girl").append("\n");
        }
        builder.append("age : ").append(student.getAge()).append("\n");
        if (student.isSetHobby()) {
            for (String hobby : student.getHobby()) {
                builder.append("hobby : ").append(hobby).append("\n");
            }
        }
        builder.append("id : ").append(student.getNumber().get(student.getName())).append("\n");

        System.out.println(builder.toString());
    }

    @Override
    public void printStudentsInfo(List<StudentInfo> students) throws StudentException, TException {
        if (null == students) {
            throw new StudentException().setErrorCode(-1)
                    .setDescription("printStudentsInfo(List<StudentInfo> students) error")
                    .setCauseInfo("students is null");
        }

        for (StudentInfo student : students) {
            printStudentInfo(student);
        }
    }
}

  正如之前所述,所有的服務方法除了丟擲我們自定義的異常之外,還都會丟擲TException這個檢查異常,其中這裡使用了一個SchoolMock的物件可以獲取到一個School物件,來完成模擬的業務邏輯,這裡也給出實現程式碼:
  SchoolMock.java

/**
 * Created by qinyi on 10/2/15.
 */
public class SchoolMock {

    private static School school;

    private SchoolMock() {
    }

    public static synchronized School getInstance() {
        if (null == school) {
            school = new School();
            school.setName("school");
            school.setDescription("this is just a mock school");
            school.setStudents(new ArrayList<StudentInfo>());
        }

        return school;
    }
}

  接下來,我們伺服器端需要做最後一步工作,開啟伺服器端的監聽,實現檔案是 : StudentThriftServer.java,由於程式碼中已經做了很多註釋,所以,不去過多的解釋:

/**
 * Created by qinyi on 10/2/15.
 */
public class StudentThriftServer {

    public static final int SERVER_PORT = 9527;

    public static void main(String[] args) throws TException{
        /**
         *  serverTransport : 設定伺服器的埠
         *  tProcessor : 關聯處理器的服務實現類
         *  server : 設定伺服器 (TSimpleServer -  單執行緒伺服器端使用標準的堵塞式I/O,只適合測試開發使用)
         *  server.serve() : 開啟服務,一般是處於睡眠狀態,直到客戶端的請求到來
         * */
        /**
         *  這裡開啟 Server 服務使用的方法是舊的API介面,這裡用的 thrift 是0.5.0的
         * */
        TServerSocket serverTransport = new TServerSocket(SERVER_PORT);
        TProcessor tProcessor = new StudentService.Processor(new StudentServiceImpl());
        /**
         *  單執行緒伺服器端使用標準的堵塞式I/O
         * */
        TServer server = new TSimpleServer(tProcessor, serverTransport);
        System.out.println("Start server on port 9527...");
        server.serve();

        /**
         *  thrift0.6.1以後的版本(如果我沒查錯的話)中,Tserver抽象類中定義了一個內部靜態類 Args,使用者串聯軟體棧(傳輸層、協議層、處理層)
         *  public static class Args extends AbstractServerArgs<Args> {
         *   public Args(TServerTransport transport) {
         *     super(transport);
         *   }
         * }
         *  新的介面中開啟 thrift 服務的介面呼叫大概是這樣:
         *  Args 串聯了: 傳輸層、協議層、處理層
         * */
        /**
         * TProcessor tprocessor = new StudentService.Processor<StudentService.Iface>(new StudentServiceImpl());
         * TServerSocket serverTransport = new TServerSocket(SERVER_PORT);
         * TServer.Args tArgs = new TServer.Args(serverTransport);
         * tArgs.processor(tprocessor);
         * tArgs.protocolFactory(new TBinaryProtocol.Factory());
         * TServer server = new TSimpleServer(tArgs);
         * System.out.println("Start server on port 9527...");
         * server.serve();
         */
    }
}

  沒錯,開啟伺服器端的程式碼就是這些,非常的簡單,因為thrift做了很多的工作,我們需要的僅僅是填充我們想要的業務邏輯和各個層的實現方式就OK啦。
  最後,只剩下客戶端連線獲取請求了。

  • 客戶端連線伺服器請求服務
       客戶端的實現也非常的簡單,我們只需要獲得一個thrift為我們定義好的Client,然後呼叫需要的業務邏輯就可以了,這裡的實現程式碼是 : StudentThriftClient.java
/**
 * Created by zhanghu on 10/2/15.
 */
public class StudentThriftClient {

    private static final String SERVER_IP = "127.0.0.1";
    private static final int SERVER_PORT = 9527;
    private static final int TIMEOUT = 5000;
    private static TTransport transport;
    private static StudentService.Client client;

    static {
        /**
         *  傳輸層使用的是堵塞式 I/O 進行傳輸
         * */
        transport = new TSocket(SERVER_IP, SERVER_PORT, TIMEOUT);
        /**
         *  定義記憶體和網路傳輸格式之間的對映
         *  binary: 相當簡單的二進位制編碼:將filed和對應的value合併在一起簡單的二進位制編碼TBinaryProtocol
         * */
        TProtocol protocol = new TBinaryProtocol(transport);
        client = new StudentService.Client(protocol);
    }

    private static void mockConstructStudent() throws TException, StudentException {
        /**
         * 構造物件需要注意的事項:
         * 1.如果在 thrift 指令碼檔案中定義的欄位是 required,那麼就一定需要 set,否則會報錯
         * 2.如果在 thrift 指令碼檔案中定義的欄位是 optional,那麼可以不用去 set
         * */
        StudentInfo student1 = new StudentInfo();
        student1.setName("qinyi");
        student1.setNumber(new HashMap<String, Long>() {{
            put("qinyi", 21209184L);
        }});
        student1.setAge(25);
        student1.setSex(Sex.Boy);
        student1.setHobby(new ArrayList<String>(Arrays.asList("ping pong", "swimming", "tai qiu")));

        StudentInfo student2 = new StudentInfo();
        student2.setName("brucezhang");
        student2.setNumber(new HashMap<String, Long>() {{
            put("brucezhang", 8205050122L);
        }});
        student2.setAge(18);
        student2.setSex(Sex.Boy);
//        student2.setHobby(new ArrayList<String>() {{
//            add("game");
//        }});

        client.addStudentToSchool(student1);
        client.addStudentToSchool(student2);
        /**
         *  下面的呼叫會丟擲異常:
         *  本例中列印的異常訊息如下:
         *  StudentException(errorCode:-1, description:addStudentToSchool(StudentInfo student) error, causeInfo:student is null)
         * */
        client.addStudentToSchool(null);
    }

    private static void mockGetService() throws TException, StudentException {
        mockConstructStudent();
        client.printStudentsInfo(client.getStudentInfoByName("qinyi"));
        client.printStudentsInfo(client.getStudentInfoByName("brucezhang"));
    }

    public static void main(String[] args) throws TException{
        /**
         *  transport : 設定傳輸通道
         *  protocol : 使用二進位制的傳輸協議
         *  client : 建立客戶端
         *  transport.open() : 開啟傳輸通道
         *  transport.close() : 關閉傳輸通道
         * */
        transport.open();
        try {
            mockGetService();
        } catch (StudentException e) {
            System.out.println(e.getMessage());
            e.printStackTrace();
        }
        transport.close();
    }
}

  程式碼中對重要的位置進行了說明,這裡不做過多的解釋了。
  這樣,我們就完成了thrift過程的四個步驟,接下來,可以開始測試RPC過程了,首先,我們需要執行伺服器端程式碼,會看到控制檯會列印出一條輸出:Start server on port 9527,之後,執行客戶端程式碼,等待客戶端程式終結,我們回到伺服器端的控制檯,可以看到業務邏輯中定義的輸出。
  哈哈,也許你不明白為什麼我要把輸出放在伺服器端,而不是客戶端,似乎不是正確的邏輯思維,沒錯,這裡要解釋下,只是因為方便,順手就寫在了伺服器端,實際中的應用一定是方法返回客戶端的查詢結果,然後客戶端這邊自己做解析工作。


Thrift整體架構

  其實寫這個部分難免有些心有餘而力不足,這個部分是整個thrift框架的組成,我對它的理解也只是基礎中的基礎,不過,由於是學習筆記,還是記錄在這裡吧。
  Thrift是由四層架構組成的,這樣設計的優點是可以自由的選擇每一層的實現方式應對不同的服務需求,比如我在上面的例子中伺服器端採用的是單執行緒阻塞式IO模型(這個只是Thrift實現的玩具,生產過程不可能會使用這種服務模式),你也可以根據需要換成其他的實現模型,而且程式碼部分的變動也是微乎其微的,分離的架構設計使得每一層之間都是透明的,不用考慮底層的實現,只需要一個介面就可以完成呼叫。下面,我將從最底層開始粗略的介紹Thrift中的每一層。

  • TTransport層
      傳輸層使用TCP、Http等協議實現,它包含了各種socket呼叫中的方法,如open,close,read,write。由於是框架中的最後一層,所以,最重要的實現部分當然是資料的讀出和寫入(read 和 write),它有阻塞和非阻塞的實現方式。

  • TProtocol層
      協議層是定義資料會以怎樣的形式到達傳輸層。它首先對IDL中的各個資料結構進行了定義,且對每一種型別都定義了read和write方法。我們需要在伺服器端和客戶端宣告相同的實現協議來作為記憶體和網路傳輸格式之間的對映。
      常用的協議有 TBinaryProtocol:它定義了資料會以二進位制的形式傳輸,它是最簡單的實現協議,同時也是最常用的實現協議,非常的高效;TCompactProtocol:它的名字叫做壓縮二進位制協議,與TBinaryProtocol相比,它會採用壓縮演算法對資料進行再壓縮,減少實際傳輸的資料量,提高傳輸效率。

  • TProcessor層
      處理層就是伺服器端定義的處理業務邏輯,它的主要程式碼是**Service.java檔案中的Iface介面和Processor類。
      Iface介面:這個介面中的所有方法都是使用者定義在IDL檔案中的service的方法,它需要丟擲TException這個檢查異常,伺服器端需要定義相應的實現類去 implements **.Iface 介面,完成服務的業務邏輯。
      Processor類:這個類中定義了一個processMap,裡面包含了service中定義的方法,伺服器端在構造這個Processor物件的時候,唯一需要做的就是把實現service(Iface)的物件作為引數傳遞給Processor的建構函式。

  • Server層
      server是Thrift框架中的最高層,它建立並管理下面的三層,同時提供了客戶端呼叫時的執行緒排程邏輯。
      服務層的基類是TServer,它相當於一個容器,裡面包含了TProcessor,TTransport,TProtocol,並實現對它們的管理和排程。TServer有多種實現方式,對於本例中使用的是TSimpleServer,這是一個單執行緒阻塞式IO模型,實際的生產中大多用到的是TThreadSelectorServer – 多執行緒非阻塞式IO模型。


Thrift中的知識點

struct可以設定預設值

  以我們之前定義的School舉例,我們還可以這樣定義struct School:

struct School {
    1: required string name = "school";
    2: required list<StudentInfo> students;
    3: optional string description = "this is just a mock school";
}

  這樣,我們就可以不需要在構造School物件的時候設定這兩個欄位了,當然,前提是這個預設值是你想要的。這個功能的好處是,當有多個required欄位,且這些欄位往往都是不變的,我們在定義物件的時候也必須要去一一設定這些欄位,如果忘記了設定某一個,那麼還會引起thrift丟擲異常,會非常的麻煩,但是,如果我們在定義IDL檔案的時候考慮了這些預設值,在構造物件的時候就不會遇到那些問題啦!

thrift中的序列化機制

  之前,曾經提到過struct中每一個屬性的前面都要有一個數字id,且定義好了之後最好不要改變,這裡對它進行解釋。為了更好的說明問題,我們舉一個例子吧,假設我們的程式中需要定義一個School結構,它包含兩個欄位(string name, string address),就好像下面這樣:

struct School {
    1: required string name;
    2: required string address;
}

  之後,我們利用thrift工具生成了目的碼(裡面包含序列化),之後,我們這樣構造這個School:

School school = new School();
school.setName("大連理工大學").setAddress("凌工路2號");

  然後,我們重新定義School(thrift檔案):

struct School {
    2: required string name;
    1: required string address;
}

  然後重新生成目的碼,並編寫下面的過程:

System.out.println(school.getName());
System.out.println(school.getAddress());

  問題來了,我們會得到什麼樣的輸出呢?也許,你已經猜到了,名字和地址反過來了,並不是像我們之前定義的那樣,要知道為什麼,就需要了解thrift是怎樣對物件進行序列化的。
  thrift中的struct定義最終是需要實現序列化的,它需要用到的資訊是屬性前面的id和型別,序列化儲存過程會形成這樣的對映關係:
  name : value —— id + type : value
  所以,屬性的名字是不重要的,實際過程是不需要的,所以,我們用物件去獲取屬性值的過程就是對映關係的一個反過程,根據id和type獲取相應的value,那麼,為什麼會得到相反的結果就清晰了。
  所以,在實際的應用中,如果已經定義好了struct中的欄位,增加沒有問題,只需要定義不同的id數值就可以了,儘量不要去改變原來屬性的id,也不要去刪除不再需要的欄位,以免導致原來的id使用重複,序列化的時候會導致結果混亂。

thrift中的版本控制

  這是設計thrift指令碼檔案的一個技巧,是針對序列化機制而言的,即struct。我們還是以舉例的形式來進行說明,假設我們需要設計一個School結構(怎麼老是School,不是不喜歡學校嘛?),裡面包含了學生資訊和教師資訊(通常會寫在兩個不同的struct中,這裡只是為了說明問題),它看起來就好像下面這樣:

struct School {
    1: required list<string> student_name;
    2: required map<string, i16> student_age;

    5: required list<string> teacher_name;
    6:required map<string, i16> teacher_age;
}

  看起來怪怪的,為什麼沒有id是3,4的屬性欄位呢?這是因為,如果我們的需求變化了,比如學生資訊中需要增加一個考試分數(score)的欄位,那麼,根據上一個版本IDL的設計,可以實現“無縫接入”,就好像下面這樣:

struct School {
    1: required list<string> student_name;
    2: required map<string, i16> student_age;
    3: required map<string, set<i16>> score;

    5: required list<string> teacher_name;
    6:required map<string, i16> teacher_age;
}

  這樣的id設計會隨著以後資訊的增加而不會導致模糊不清的語義,儘管我們可以隨便定義每個欄位的id,不過,更好的做法是順序定義各個欄位的id,並相應的根據需要設定一些保留的欄位,以備版本升級的時候使用,這樣的用法在HBase,MySQL等資料庫建表也是非常常見的。


  學習Thrift的時間還不長,加上本人反應愚鈍,水平有限,對新鮮事物的理解能力稍差,不過,樂於分享,對人類友善是本性使然,懂得分享的樂趣,才能更好的程式設計,無分享,不程式設計。

相關文章