仿开源框架从零到一完整实现高性能、可扩展的RPC框架 | 6个月做成教程免费送

去年年就在写一本付费小册,今年年初基本上就写完了,本来预计计划是春节上线结果由于平台的原因一直拖着没上。五一前跟平台联系给的反馈是五月份能上,结果平台又在重构,停止小册的申请和上线,最后我考虑了一下决定这本书免费放出来,书名是《跟着顶级项目学编程》,关注公众号 渡码 回复关键字 manis,可获取电子书+源码+读者交流群。下面给大家介绍下。

简介

19年上半年,我阅读了Hadoop RPC模块的源代码,读完后发现这个模块设计的非常好,与其他模块无耦合,完全可以独立出来当成一个独立的框架。为了总结学到的编程知识,同时也为了学习Apache顶级开源项目的代码是如何编写的,我便把它做成了电子书,共350页,从写代码到做成电子书共花了6个月的时间。本来想做成付费专栏赚点小钱,并且已经到了上架阶段了,但后来决定把它免费开放出来,让更多的人能够学习到优秀的实战项目。

当然我们这本书并不是源码分析类教程,而是强调动手能力。在这里我会带着大家按照 Hadoop RPC 源码从 0 到 1 完整敲一遍,代码量在 4600 行左右。为了让不熟悉 Hadoop 或 RPC 的朋友也能够学习,我将 Hadoop RPC 稍微做了一点改造,赋予了新的业务含义,也有自己的名字,叫 Manis。Mnias 源码相比于 Hadoop RPC源码还原度为90%。为什么不是100%呢?一方面为了突出重点,我会把不太重要、不是很核心的技术舍弃掉。另一方面为了符合新的业务定义,我会做一些改进,而不是照搬完全 Hadoop RPC。

虽然这个项目是实现 RPC 功能,但我觉得我们关注的重点不应该过多地放在 RPC 本身,而应该重点学习编写 RPC 过程中所涉及的系统设计、面向对象设计思想和原则、工程/代码规范、客户端开发、服务端开发、网络编程、多线程、并发编程、设计模式、异常处理等核心知识,可以说是麻雀虽小五脏俱全。尤其是对于刚学习 Java 还没有接触线上实战项目的朋友,这是一次很好的练兵机会。

学习开源项目的一个优势在于它是经过线上检验的,Hadoop集群规模最大达到上万台服务端,足以证明它的 RPC 模块是优秀的。另外一个好处是可以积累顶级开源项目的开发经验,大到架构设计,小到设计模式、代码规范,说不定日后就能为开源社区贡献代码了。所以,学会了 Manis 后,不但有编写实战项目的经验,同时也有能力阅读 Hadoop RPC 的源码,这也算是面试的加分项。

涉及到的核心技术

下面我们来介绍一下 Manis 中涉及的核心技术点。作为一个 RPC 框架,最关键的几个模块是客户端、网络模块和服务端

客户端

作为客户端来说,它的职责非常明确,以数据库客户端为例,它的职责就是向用户提供增删改查接口,并将相应的请求发送给服务端,接收结果并返回给用户。由于客户端职责边界是非常明确的,所以我们从设计上就要将其与网络模块、与服务端解耦,解耦的方式就要用到设计模式中的代理模式。也就说客户端只需要定义好它需要提供的功能(接口)并提供给用户,具体如何实现就交给代理,至于代理是通过网络发送给服务端还是通过其他什么方式客户端就不需要关心了,客户端只关心调用代理并拿结果。这样做的好处是客户端与其他模块解耦,提高了系统扩展性。当然,代理模式还有个容易被忽略的好处是它天然地适合用在 RPC 场景。

Manis 中支持多种序列化/反序列化方式,每种序列化方式对应一个类,它们都继承共同的基类。我们在设计时需要做到不同序列化方式之间的代码是解耦的,且序列化/反序列化模块与客户端模块、与网络模块是解耦的,这样才能做到任意地增加新的新的序列化方式以及删除老的序列化方式。为了实现客户端与序列化/反序列化模块的松耦合,我们需要用到一些设计模式,比如,用适配器模式将客户端定义的请求接口适配到不同序列化协议定义的请求接口。这样做几乎不需要修改现有的代码,符合面向对象的开闭原则

网络模块

下面再来说说网络模块。

由于客户端的请求可能来自不同的序列化协议,但的目的是相同的,都是为了通过网络模块的服务端,可以说是殊途同归。这样的话,我们就有必要在网络这一层定义一个统一的协议(接口),让不同序列化方式都遵循相同的协议(接口),那么网络模块就可以对它们“一视同仁”,编写一套代码就可以了。就好比,不管你用U盘还是硬盘,只要是 USB 接口,那都能插到电脑的同一个接口进行相同的读写逻辑。对于服务端的返回值也是采用同样的处理逻辑。

网络模块必不可少的功能就是发送网络请求,当然除了这个还有一个更核心的功能是管理网络资源。听起来有点抽象,如果用面向对象的思想来理解,其实就是创建一个类代表网络连接,比如就叫Connection类,每次创建一个网络连接其实就是创建一个Connection对象。当然,我们知道网络资源比较宝贵且创建成本较高,当系统客户端请求量非常大的时候,我们不可能为每次请求都创建一个网络连接,所以,需要建立一个网络连接池,以达到复用网络资源的目的。我们可以再定义一个类ConnectionId,每个ConnectionId对象都唯一代表Connection对象,ConnectionId的属性包含服务端地址请求网络的一些参数,所以我们可以认为客户端请求服务端的地址和参数相同的话,就可以复用同一个网络连接。当然,这里还有一个很关键的问题不容忽视,网络连接池是公共资源,为了保证线程安全,在对资源池读写时需要加锁,也是从这里开始本书加大了对并发编程的相关讲解。刚刚介绍的这部分在 Manis 中是自主实现的。

建立网络连接的过程中还会涉及发送请求头、请求上下文,装饰网络输入、输出流等功能,这些比较偏业务,这里就不再赘述了。

发送网络请求时,为了将业务代码与发送请求代码剥离,在 Manis 创建了一个建线程池,将发送发送请求的代码封装成线程,丢到线程池中等待执行。所以,这里又涉及到三部分知识

  • 使用工厂模式创建线程池,并用单例模式保证不被重复创建
  • 使用Java的ExecutorFuture,用来创建任务并等待返回
  • 对线程池的读写保证线程安全

最后,网络模块要实现的是等待服务端返回的结果。由于网络模块同一时间会接收大量客户端网络请求,所以,我们可以创建一个单独的线程,每隔一定时间轮询是否有服务端的返回。

服务端

对于服务端来说,我们最关心的是性能问题。因为大量的客户端请求最终都会汇总到服务端一个节点来处理。所以最原始的单线程+while循环的方式肯定满足不了性能要求。所以比较最容易想到的改进点是多线程,虽然在一定程度上能解决第一种方式带来的问题,但这种方式也有很大的缺点:频繁创建线程成本比较大,并且线程之间的切换也需要一定的开销,当线程数过多时显然会降低服务端的性能。目前比较常用的解决方案是Reactor模式Reactor模式也分为单线程Reactor、多线程Reactor和多Reactor。这几种的区别在书里都有具体说明,这里我就不再介绍了。Reactor模式的优势按照我自己的理解就四个字——各司其职。Manis 中使用的是多Reactor模式,设计图如下:

简单介绍一下图中几个线程的功能

  • Listener: 接收客户端的连接请求,也可以叫做 Acceptor,封装连接请求
  • Readr: 多线程并行地读取客户端请求,进行反序列化和解析操作
  • Handler: 多线程并行地读取调用请求,解析调用方法并执行调用
  • Responder: 读取响应结果,发送给客户端

够各司其职吧。那它们之间怎么联系呢?从图上可以看到是消息队列,消息队列可以很好地实现组件间的解耦。

虽然服务端的职责也比较明确、清晰,但涉及的内容一点不少,包括注册不同的序列化方式,解析并调用相应的请求。最关键的是服务端线程是最多的,并且需要线程之间需要高度协调的,所以对并发编程的要求也更高,这块书中也有重点讲解。

最后我们看看Manis中核心组件的时序图

avatar

由于 Manis 在设计上是足够优秀的,所以开发的时候这三个模块可以并行进行。有点像近几年web开发比较火的前后端分离架构,只要各个模块把协议定义好了后,开发就可以并行进行而不需要依赖彼此。至此,Manis 的核心技术就介绍完了,当然这只是冰山一角,毕竟 4600 行代码。

最后,讲解一下第一节的内容

第一节:搭建客户端本地调用框架

本节开始我们就开启 Manis 项目的实战之旅,首先从客户端开发入手。主要包括以下4个小节:

  • 定义接口
  • 创建代理工具类
  • 创建客户端类
  • 课外拓展:代理模式

Manis 提供给用户调用的类有两个,一个是 ManisClient 给数据库使用者提供的,另一个是 Manager 给数据库管理员使用的。采用面向接口的编程思想,我们可以将提供的功能定义在接口中。

定义接口

定义ClientProtocol接口,代码如下:

package com.cnblogs.duma.protocol;

import java.io.IOException;

public interface ClientProtocol {
    /**
     * 获取某个表的 meta 信息
     * @param dbName 数据库名称
     * @param tbName 表名称
     * @return 表中的记录数
     */
    public int getTableCount(String dbName, String tbName) throws IOException;
}

ClientProtocol接口中定义了一个getTableCount方法,提供了获取数据库中某张表的记录数的功能。该接口用在ManisClient中。

接口的命名需要解释一下,名称中包含了protocol(协议)单词,因为它需要跟其他组件通信,所以称它们是协议也合理。后面代码中的变量、注释将其称为协议时大家不要觉得奇怪。

定义ManagerProtocol接口,代码如下:

package com.cnblogs.duma.protocol;

public interface ManagerProtocol {
    /**
     * 设置支持的最大表的个数
     * @param tableNum 表数量
     * @return 设置成功返回 true,设置失败返回 false
     */
    public boolean setMaxTable(int tableNum);
}

ManagerProtocol接口中定义了一个setMaxTable方法,可以让管理员设置数据库中能够支持最多的表数量,该接口用在Manager中。

实现接口中的方法,最常见的方式就是在本地创建类并实现该接口,但这种方式显然不适用于 RPC 场景。RPC 场景中的方法需要在服务端调用,而不是本地。因此,就需要另一种方式创建实例化对象,即通过代理模式。不理解的朋友可以阅读本节的课外拓展

创建代理工具类

在 Manis 中,我们便是通过代理的方式实例化接口。定义一个ManisDbProxies工具类,来实现获取代理的相关逻辑,代码如下:

package com.cnblogs.duma;

import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ClientProtocol;
import com.cnblogs.duma.protocol.ManagerProtocol;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;

/**
 * @author duma
 */
public class ManisDbProxies {

    public static class ProxyInfo<PROXYTYPE> {
        private final PROXYTYPE proxy;
        private final InetSocketAddress address;

        public ProxyInfo(PROXYTYPE proxy, InetSocketAddress address) {
            this.proxy = proxy;
            this.address = address;
        }

        public PROXYTYPE getProxy() {
            return proxy;
        }

        public InetSocketAddress getAddress() {
            return address;
        }
    }

    @SuppressWarnings("unchecked")
    public static <T> ProxyInfo<T> createProxy(Configuration conf, 
        URI uri, Class<T> xface)
            throws IOException {
        return null;
    }
}

ManisDbProxies 类中定义了一个静态类ProxyInfo用来封装代理对象。由于需要代理的接口不止一个,所以ProxyInfo类引入了泛型。另外,我们还定义createProxy方法用来获取代理对象,里面的逻辑后续会完善。

这里简单说一下createProxy方法第一个参数——Configuration对象,它保存了定义的配置信息,且定义了配置的setget方法,功能与 Hadoop 中的同名类一致,但实现上比 Hadoop 简单。为了不影响我们对重点内容的介绍,这里就不贴该类的代码了,它的源码带可在上面的 GitHub 连接中找到。

创建客户端类

准备工作已经就绪,下面分别看看两种客户端如何使用接口来实现我们需要的功能。

ManisClient的代码如下:

package com.cnblogs.duma;

import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ClientProtocol;

import java.io.Closeable;
import java.io.IOException;
import java.net.URI;

/**
 *
 * @author duma
 */
public class ManisClient implements Closeable {
    volatile boolean clientRunning = true;
    final ClientProtocol manisDb;

    public ManisClient(URI manisDbUri, Configuration conf) throws IOException {
        ManisDbProxies.ProxyInfo<ClientProtocol> proxyInfo = null;

        proxyInfo = ManisDbProxies.createProxy(conf, manisDbUri, ClientProtocol.class);
        this.manisDb = proxyInfo.getProxy();
    }

    /**
     * 获取远程数据库表中的记录数
     * @param dbName 数据库名称
     * @param tbName 表名称
     * @return 表记录数
     * @see com.cnblogs.duma.protocol.ClientProtocol#getTableCount(String, String)
     */
    public int getTableCount(String dbName, String tbName)
            throws IOException {
        return this.manisDb.getTableCount(dbName, tbName);
    }

    @Override
    public void close() throws IOException {

    }
}

Manager的代码如下:

package com.cnblogs.duma;

import com.cnblogs.duma.conf.Configuration;
import com.cnblogs.duma.protocol.ManagerProtocol;

import java.io.Closeable;
import java.io.IOException;
import java.net.URI;

public class Manager implements Closeable {
    volatile boolean clientRunning = true;
    final ManagerProtocol manisDb;

    public Manager(URI manisDbUri, Configuration conf) throws IOException {
        ManisDbProxies.ProxyInfo<ManagerProtocol> proxyInfo = null;

        proxyInfo = ManisDbProxies.createProxy(conf, manisDbUri, ManagerProtocol.class);
        this.manisDb = proxyInfo.getProxy();
    }

    public boolean setMaxTable(int tableNum) {
        return this.manisDb.setMaxTable(tableNum);
    }

    @Override
    public synchronized void close() throws IOException {

    }
}

两种客户端的代码基本一致,以ManisClient为例简单讲解下。ManisClient中也定义了getTableCount方法,它直接调用了ClientProtocol的实例化对象(代理对象) manisDbgetTableCount方法。从这里我们就可以看出面向接口编程的一个优势——扩展性高。虽然现在manisDb是通过代理对初始化的,但假设以后需求变了,变成直接调用本地的方法了呢?这时候我们就可以在本地创建一个实现了ClientProtocol接口的类,将其对象赋值给manisDb即可。这样改变只是manisDb的初始化代码,而其他业务代码不需要做任何改变。这同时也提醒我们平时在做设计的时候要认清系统中不变的地方和可变的地方。

另外,ManisClientManager都实现了Closeable接口,目的是为了覆盖close方法。在close方法中可以关闭客户端,从而释放占用的资源。close方法的实现代码会在后续的章节中会完善。

至此,这一节的内容就讲解完毕了,下一节我们将定义不同的 RPC 引擎,并完善代理模式。

课外拓展:代理模式

代理模式是设计模式中的一种,该模式应用比较广泛。如果不太理解该模式的朋友,可以阅读这一小节。代理模式有好多种,本小节我们只介绍 Java 语言的动态代理机制。如果想详细了解其他代理模式可以阅读我之前写的博客

假设我们有一个写文件的需求,我们首先定义接口:

package com.cnblogs.duma.dp.proxy.dynamic;

public interface Writer {
    public void write(String fileName, String str);
    public void write(String fileName, byte[] bs);
}

再定义写文件的类并实现Writer接口:

package com.cnblogs.duma.dp.proxy.dynamic;

public class FileWriter implements Writer {
    @Override
    public void write(String fileName, String str) {
        System.out.println("call write str in FileWriter");
    }

    @Override
    public void write(String fileName, byte[] bs) {
        System.out.println("call write bytes in FileWriter");
    }
}

如果我们要写文件的话,通过Writer writer = new FileWriter()就可以实现了。假设某天突然来了个需求,说我们写磁盘的时候要判断服务器存储空间是否达到某个临界值,如果达到了就不能再写了。对于这个需求来说我们可以直接修改FileWriter类来实现,但这样做有两个问题:

  1. 改现有代码风险高,可能改动过程中影响原有逻辑,不符合开闭原则——对扩展开放,对修改关闭
  2. 这个需求跟写文件的业务无关,直接放在业务代码里面会导致耦合度比较大,不利于维护

通过代理模式就可以避免上述两个问题,接下来我们看看如何利用 Java 的动态代理来实现这个需求。

首先,我们需要创建一个实现java.lang.reflect.InvocationHandler接口的类:

package com.cnblogs.duma.dp.proxy.dynamic;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class FileWriterInvocationHandler implements InvocationHandler {
    Writer writer = null;

    public FileWriterInvocationHandler(Writer writer) {
        this.writer = writer;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
        boolean localNoSpace = false;
        System.out.println("check local filesystem space."); //检测磁盘空间代码,返回值可以更新 localNoSpace 变量
        if (localNoSpace) {
            throw new Exception("no space."); //如果空间不足,抛出空间不足的异常
        }
        return method.invoke(writer, args); //调用真实对象(FileWriter)的方法
    }
}

FileWriterInvocationHandler的构造方法中会保存实际用于写文件的对象,即FileWriter对象。invoke方法中先检查磁盘,如果没问题再调用文件的写方法method.invoke(writer, args),这个写法是Java反射机制提供的。看起来invoke方法就是我们想要的功能,但我们要怎么调用invoke呢?这里就用到 Java 的动态代理技术了,在运行时将Writer接口动态地跟代理对象(FileWriterInvocationHandler对象)绑定在一起。

下面,我们看看如何创建代理对象并进行绑定:

package com.cnblogs.duma.dp.proxy.dynamic;

import java.lang.reflect.Proxy;

public class DynamicProxyDriver {
    public static void main(String[] args) {
        /**
         * Proxy.newProxyInstance 包括三个参数
         * 第一个参数:定义代理类的 classloader,一般用被代理接口的 classloader
         * 第二个参数:需要被代理的接口列表
         * 第三个参数:实现了 InvocationHandler 接口的对象
         * 返回值:代理对象
         */
        Writer writer = (Writer) Proxy.newProxyInstance(
                Writer.class.getClassLoader(),
                new Class[]{Writer.class},
                new FileWriterInvocationHandler(new FileWriter())); //这就是动态的原因,运行时才创建代理类

        try {
            writer.write("file1.txt", "text"); //调用代理对象的write方法
        } catch (Exception e) {
            e.printStackTrace();
        }
        writer.write("file2.txt", new byte[]{}); //调用代理对象的write方法
    }
}

通过Java语言提供的Proxy.newProxyInstance()即可创建Writer接口的动态代理对象,代码注释中有该方法的参数说明。对照本例,简单梳理一下Java动态代理机制

  1. 当通过Proxy.newProxyInstance()创建代理对象后,在Writer接口中调用write方法便会跳转到FileWriterInvocationHandler对象的invoke方法中执行
  2. 比如,执行writer.write("file1.txt", "text");时,程序跳转到invoke方法,它的第二个参数method对象是write方法,第三个参数args是调用write方法的实参file1.txttext
  3. invoke方法中的最后一行method.invoke(writer, args);代表method方法(即write方法)由writer对象调用,参数是args,跟writer.write("file1.txt", "text")是一样的。

这样我们就通过代理模式既实现新需求,有没有修改现有的代码。经过上述的讲解,希望你对代理模式的概念和优势有一定的了解。

代理模式除了上述提到的用处外,还有一个用处是转发调用请求,以 Manis 为例,假如我们为ClientProtocol创建一个代理对象manisDb,在manisDb上调用getTableCount方法时,便会跳转到代理对象的invoke方法中执行,在invoke方法中我们就可以将调用的方法和参数反序列化,并通过网络发送服务端,这就实现了调用请求的转发。

获得本书

今天先介绍第一节, 想获取完整内容可以关注公众号 渡码 回复关键字 manis

目录

代码结构

本书特色

在讲解相册内容同时,大部分章节都加入了课外拓展,针对每一节涉及的基础知识,如:设计模式、序列化/反序列化基础、单例测试、源码分析、并发编程以及Hadoop源码分析等内容都有拓展讲解。力求让零基础的朋友也能跟上本书节奏,从0到1独立完成一个项目。

希望你学完本书后不只学会了某项技术,而是提高了设计实现整个系统的能力。

欢迎公众号「渡码」,输出别地儿看不到的干货。

Tags: