对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_exampleenum 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;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
可以看到,我们使用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_exampleexception StudentException { 1: required i64 errorCode; 2: required string description; 3: optional string causeInfo;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
我们可以看到,异常的定义和上面文件中的struct是极为相似的。
qinyi_student_service.thrift 定义服务
/** * qinyi student thrift service * @author qinyi * @since 2015-10-02 */namespace java com.qinyi.thrift_study.thrift_exampleinclude "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);}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
如果你熟悉C语言的话,对include肯定不会陌生,thrift中也可以这样引用其他的thrift文件,而且include之后需要是双引号,在文件中对于引用其他thrift文件的字段也都要使用全名。
- 使用thrift工具利用IDL生成目标代码
正如之前所述,这是thrift过程的第二个步骤,这里,为了便于操作,我们写一个shell脚本吧:
#!/bin/bashthrift_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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
由于目标语言是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); } }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
正如之前所述,所有的服务方法除了抛出我们自定义的异常之外,还都会抛出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; }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
接下来,我们服务器端需要做最后一步工作,开启服务器端的监听,实现文件是 : 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(); */ }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
没错,开启服务器端的代码就是这些,非常的简单,因为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(); }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
代码中对重要的位置进行了说明,这里不做过多的解释了。
这样,我们就完成了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";}
- 1
- 2
- 3
- 4
- 5
这样,我们就可以不需要在构造School对象的时候设置这两个字段了,当然,前提是这个默认值是你想要的。这个功能的好处是,当有多个required字段,且这些字段往往都是不变的,我们在定义对象的时候也必须要去一一设置这些字段,如果忘记了设置某一个,那么还会引起thrift抛出异常,会非常的麻烦,但是,如果我们在定义IDL文件的时候考虑了这些默认值,在构造对象的时候就不会遇到那些问题啦!
thrift中的序列化机制
之前,曾经提到过struct中每一个属性的前面都要有一个数字id,且定义好了之后最好不要改变,这里对它进行解释。为了更好的说明问题,我们举一个例子吧,假设我们的程序中需要定义一个School结构,它包含两个字段(string name, string address),就好像下面这样:
struct School { 1: required string name; 2: required string address;}
- 1
- 2
- 3
- 4
之后,我们利用thrift工具生成了目标代码(里面包含序列化),之后,我们这样构造这个School:
School school = new School();school.setName("大连理工大学").setAddress("凌工路2号");
- 1
- 2
然后,我们重新定义School(thrift文件):
struct School { 2: required string name; 1: required string address;}
- 1
- 2
- 3
- 4
然后重新生成目标代码,并编写下面的过程:
System.out.println(school.getName());System.out.println(school.getAddress());
- 1
- 2
问题来了,我们会得到什么样的输出呢?也许,你已经猜到了,名字和地址反过来了,并不是像我们之前定义的那样,要知道为什么,就需要了解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;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
看起来怪怪的,为什么没有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;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
这样的id设计会随着以后信息的增加而不会导致模糊不清的语义,尽管我们可以随便定义每个字段的id,不过,更好的做法是顺序定义各个字段的id,并相应的根据需要设定一些保留的字段,以备版本升级的时候使用,这样的用法在HBase,MySQL等数据库建表也是非常常见的。
学习Thrift的时间还不长,加上本人反应愚钝,水平有限,对新鲜事物的理解能力稍差,不过,乐于分享,对人类友善是本性使然,懂得分享的乐趣,才能更好的编程,无分享,不编程。