对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;
}

  可以看到,我们使用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;
}

  我们可以看到,异常的定义和上面文件中的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 schoolbool addStudentToSchool(1: qinyi_student_model.StudentInfo student) throws (1: qinyi_student_exception.StudentException ex);// get student info by namelist<qinyi_student_model.StudentInfo> getStudentInfoByName(1: string name) throws (1: qinyi_student_exception.StudentException ex);// print single student infovoid printStudentInfo(1: qinyi_student_model.StudentInfo student) throws (1: qinyi_student_exception.StudentException ex);// print list students infovoid 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 {@Overridepublic 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;}@Overridepublic 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;}@Overridepublic 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());}@Overridepublic 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的时间还不长,加上本人反应愚钝,水平有限,对新鲜事物的理解能力稍差,不过,乐于分享,对人类友善是本性使然,懂得分享的乐趣,才能更好的编程,无分享,不编程。

对Thrift的一点点理解相关推荐

  1. 重学概率论的一点点理解(持续更新)

    前言:这里是毕业N年后重学概率的一点点理解,因为上学的时候贪玩了,现在到了还的时候,要花费大量的时间,悲剧...所以请同学们抓紧在校时间,务必多夯实基础. 文章内容都是自己手工敲的,算是一点点学习笔记 ...

  2. HGMMs的一点点理解~

    Robust Ellipse Fitting Using Hierarchical Gaussian Mixture Models Motivation Related works Ellipse f ...

  3. javascript中函数参数以及函数中局部变量作用域一点点理解

    2019独角兽企业重金招聘Python工程师标准>>> 函数中局部变量如果与外部变量重名,则用的是函数内部局部变量,用完就会被释放.我的理解函数是一个function定义的代码段,以 ...

  4. 对线性时不变系统(LTI)中时不变(Time Invariant)的一点点理解

    这个博客又找回来了,重新恢复更新 ==========================================x 这个问题讨论的是一个系统对于一个随时间变化的输入信号x的一个处理问题. 时不变 ...

  5. 对sizeof的一点点理解

    sizeof sizeof的常量性 常量性的概念:简单的说就是编译的时候就可以确定他的值了,就好像是const一样 在一些老式的编译器里面你是不能够声明一个不确定大小的数组的,也就是说以下写法是错误的 ...

  6. 软件定义安全的一点点理解

    万事开头难,中间也难,最后也难.第一次写博客,内容.排版都不太好,请见谅.文章内容部分来源绿盟的<软件定义下的新型安全架构和实践>.<软件定义安全>以及<软件定义安全:S ...

  7. 强化学习(Reinforcement Learning)之策略梯度(Policy Gradient)的一点点理解以及代码的对应解释

    一.策略梯度算法推导以及解释 1.1 背景 设πθ(s)\pi_{\theta }(s)πθ​(s)是一个有网络参数θ\thetaθ的actor,然后我们让这个actor和环境(environment ...

  8. 网关是什么意思 这我对网关的一点点理解

    现在信息网络的发达,人们在使用电脑的过程中必然会遇到一些问题,而对于一些电脑的专用名词,如果你不是专业人士,可能在遇到这些问题时是一筹莫展的.其实网关有一个很简单的理解思路,就是当我们从一个空间到另一 ...

  9. 用我对HTML的点点理解来做个简单的百度首页

    为什么80%的码农都做不了架构师?>>>    在我心里,HTML一直以来都是一个新鲜而神秘的东西,好多次想静下心来研究研究,最终因为各种原因搁置下来.终于,最近终于有时间看看其中的 ...

最新文章

  1. 五点讲述C++智能指针的点点滴滴
  2. 实验: VMware使用快照间接备份原始VMDK文件
  3. java并发编程详解,Java架构师成长路线
  4. delphi 中的dll编程注意事项
  5. java如何解析json_java 中解析json步骤
  6. IOS之Swift5.x和OC网络请求JSON
  7. jquery ui datepicker 只能选今天以后的日期
  8. 在latex或者mathtype中如何输入花体,如拉式量L
  9. 如何看待阿里巴巴推荐的Python400集视频?零基础入门学习Python
  10. Samsung Galaxy S III GT-I9300详细刷机教程
  11. hadoop 实现序列化
  12. 从文本界面安装RHEL5操作系统详解
  13. UVa1587 - Box
  14. W32Dasm缓冲区溢出分析【转载】
  15. 【模电笔记】6.集成运算放大器应用电路
  16. 《塞尔达传说》与氛围游戏的兴起:在游戏中感受禅意
  17. 云数据库RDS与自建数据库相比到底有什么优势?
  18. WIN 7和WIN 10添加和删除静态路由
  19. Android:设置背景色以及theme(主题)设置(一)
  20. 浏览器翻译功能在哪里,如何使用浏览器翻译网页

热门文章

  1. 缓存服务器syns to listen sockets drop导致创建socket失败
  2. php Reportico 开源报表
  3. Track与nqa联动 VS 静态路由优先级相同
  4. ----==《在路上》==----
  5. yii2 nginx去掉index.php?r=
  6. [linux]windows无法访问samba的安全性问题(关闭selinux)
  7. Python.Scrapy.12-scrapy-source-code-analysis-part-2
  8. 内核compiler.h的学习
  9. HDU1081:To The Max(最大子矩阵,线性DP)
  10. 互联网或将进入泡沫2.0时代