通過 Netty、ZooKeeper 手擼一個 RPC 服務

說明

使用 Netty、ZooKeeper 和 Spring Boot 手擼一個微服務框架。

項目鏈接

GitHub 源碼地址

微服務框架都包括什麼?

詳細信息可參考:RPC 實戰與原理

項目可以分爲調用方(client)和提供方(server),client 端只需要調用接口即可,最終調用信息會通過網絡傳輸到 server,server 通過解碼後反射調用對應的方法,並將結果通過網絡返回給 client。對於 client 端可以完全忽略網絡的存在,就像調用本地方法一樣調用 rpc 服務。

整個項目的 model 結構如下:

如何實現 RPC 遠程調用?

  • 客戶端、服務端如何建立網絡連接:HTTP、Socket
  • 服務端如何處理請求:NIO(使用 Netty)
  • 數據傳輸採用什麼協議
  • 數據如何序列化、反序列化:JSON,PB,Thrift

開源 RPC 框架

限定語言

  • Dubbo:Java,阿里
  • Motan:Java,微博
  • Tars:C++,騰訊(已支持多語言)
  • Spring Cloud:Java
    • 網關 Zuul
    • 註冊中心 Eureka
    • 服務超時熔斷 Hystrix
    • 調用鏈監控 Sleuth
    • 日誌分析 ELK

跨語言 RPC 框架

  • gRPC:HTTP/2
  • Thrift:TCP

本地 Docker 搭建 ZooKeeper

下載鏡像

啓動 Docker,並下載 ZooKeeper 鏡像。詳見 https://hub.docker.com/_/zookeeper

啓動容器

啓動命令如下,容器的名稱是zookeeper-rpc-demo,同時向本機暴露 8080、2181、2888 和 3888 端口:

docker run --name zookeeper-rpc-demo --restart always -p 8080:8080 -p 2181:2181 -p 2888:2888 -p 3888:3888  -d zookeeper
This image includes EXPOSE 2181 2888 3888 8080 (the zookeeper client port, follower port, election port, AdminServer port respectively), so standard container linking will make it automatically available to the linked containers. Since the Zookeeper "fails fast" it's better to always restart it.

查看容器日誌

可以通過下面的命令進入容器,其中fb6f95cde6ba是我本機的 Docker ZooKeeper 容器 id。

docker exec -it fb6f95cde6ba /bin/bash

在容器中進入目錄:/apache-zookeeper-3.7.0-bin/bin,執行命令 zkCli.sh -server 0.0.0.0:2181 鏈接 zk 服務。

RPC 接口

本示例提供了兩個接口:HelloService 和 HiService,裏面分別有一個接口方法,客戶端僅需引用 rpc-sample-api,只知道接口定義,並不知道里面的具體實現。

public interface HelloService {
    String hello(String msg);
}
public interface HiService {
    String hi(String msg);
}

Netty RPC server

啓動一個 Server 服務,實現上面的兩個 RPC 接口,並向 ZooKeeper 進行服務註冊。

接口實現

/**
 * @author yano
 * GitHub 項目: https://github.com/LjyYano/Thinking_in_Java_MindMapping
 * @date 2021-05-07
 */
@RpcServer(cls = HelloService.class)
public class HelloServiceImpl implements HelloService {

    @Override
    public String hello(String msg) {
        return "hello echo: " + msg;
    }
}
/**
 * @author yano
 * GitHub 項目: https://github.com/LjyYano/Thinking_in_Java_MindMapping
 * @date 2021-05-07
 */
@RpcServer(cls = HiService.class)
public class HiServiceImpl implements HiService {

    public String hi(String msg) {
        return "hi echo: " + msg;
    }
}

這裏涉及到了兩個問題:

  1. Server 應該決定將哪些接口實現註冊到 ZooKeeper 上?
  2. HelloServiceImpl 和 HiService 在 ZooKeeper 的路徑應該是什麼樣的?

服務啓動

本示例 Server 使用 Spring Boot,但是我們並不需要啓動一個 Web 服務,只需要保持後臺運行就可以,所以將 web 設置成 WebApplicationType.NONE

@SpringBootApplication
public class RpcServerApplication {

    public static void main(String[] args) {
        new SpringApplicationBuilder(RpcServerApplication.class)
                .web(WebApplicationType.NONE)
                .run(args);
    }
}

註冊服務

NettyApplicationContextAware 是一個 ApplicationContextAware 的實現類,程序在啓動時,將帶有 RpcServer(下面會講解)註解的實現類註冊到 ZooKeeper 上。

@Component
public class NettyApplicationContextAware implements ApplicationContextAware {

    private static final Logger logger = LoggerFactory.getLogger(NettyApplicationContextAware.class);

    @Value("${zk.address}")
    private String zookeeperAddress;

    @Value("${zk.port}")
    private int zookeeperPort;

    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        Map<String, Object> rpcBeanMap = new HashMap<>();
        for (Object object : applicationContext.getBeansWithAnnotation(RpcServer.class).values()) {
            rpcBeanMap.put("/" + object.getClass().getAnnotation(RpcServer.class).cls().getName(), object);
        }
        try {
            NettyServer.start(zookeeperAddress, zookeeperPort, rpcBeanMap);
        } catch (Exception e) {
            logger.error("register error !", e);
        }
    }
}

RpcServer 註解的定義如下:

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.TYPE)
@Component
public @interface RpcServer {

    /**
     * 接口類,用以接口註冊
     */
    Class<?> cls();

}

applicationContext.getBeansWithAnnotation(RpcServer.class).values() 就是獲取項目中帶有 RpcServer 註解的類,並將其放入一個 rpcBeanMap 中,其中 key 就是待註冊到 ZooKeeper 中的路徑。注意路徑使用接口的名字,而不是類的名字。

使用註解的好處是,Server A 可以僅提供 HelloService,Server B 僅提供 HiService,不會相互影響且更加靈活。

服務註冊主要在 com.yano.server.NettyServer#start 中。

public class NettyServer {

    private static final Logger logger = LoggerFactory.getLogger(NettyServer.class);

    public static void start(String ip, int port, Map<String, Object> params) throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .option(ChannelOption.SO_KEEPALIVE, true)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel socketChannel) {
                            socketChannel.pipeline()
                                    .addLast(new RpcDecoder(Request.class))
                                    .addLast(new RpcEncoder(Response.class))
                                    .addLast(new RpcServerInboundHandler(params));
                        }
                    });

            ChannelFuture future = serverBootstrap.bind(ip, port).sync();
            if (future.isSuccess()) {
                params.keySet().forEach(key -> ZooKeeperOp.register(key, ip + ":" + port));
            }
            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

}

這個類的作用是:

  1. 通過 Netty 啓動一個 Socket 服務,端口號通過參數傳入
  2. 將上面的接口實現註冊到 ZooKeeper 中
params.keySet().forEach(key -> ZooKeeperOp.register(key, ip + ":" + port));

ZooKeeper 實現

主要就是維護 zk 連接,並將 Server 的 ip 和 port 註冊到對應的 ZooKeeper 中。這裏使用 Ephemeral node,這樣 Server 在下線丟失連接之後,ZooKeeper 能夠自動刪除節點,這樣 Client 就不會獲取到下線的 Server 地址了。

public class ZooKeeperOp {

    private static final String zkAddress = "localhost:2181";
    private static final ZkClient zkClient = new ZkClient(zkAddress);

    public static void register(String serviceName, String serviceAddress) {
        if (!zkClient.exists(serviceName)) {
            zkClient.createPersistent(serviceName);
        }
        zkClient.createEphemeral(serviceName + "/" + serviceAddress);
        System.out.printf("create node %s \n", serviceName + "/" + serviceAddress);
    }

    public static String discover(String serviceName) {
        List<String> children = zkClient.getChildren(serviceName);
        if (CollectionUtils.isEmpty(children)) {
            return "";
        }
        return children.get(ThreadLocalRandom.current().nextInt(children.size()));
    }
}

Netty RPC Client

Netty RPC Client 主要是像調用本地方法一樣調用上述的兩個接口,驗證能夠正常返回即可。

public class RpcClientApplication {

    public static void main(String[] args) {
        HiService hiService = RpcProxy.create(HiService.class);
        String msg = hiService.hi("msg");
        System.out.println(msg);

        HelloService helloService = RpcProxy.create(HelloService.class);
        msg = helloService.hello("msg");
        System.out.println(msg);
    }
}

運行上述代碼,最終控制檯會輸出:

hi echo: msg
hello echo: msg

創建代理

HiService hiService = RpcProxy.create(HiService.class);
String msg = hiService.hi("msg");

Client 需要通過 com.yano.RpcProxy#create 創建代理,之後就可以調用 hiService 的 hi 方法了。

public class RpcProxy {

    public static <T> T create(final Class<?> cls) {
        return (T) Proxy.newProxyInstance(cls.getClassLoader(), new Class<?>[] {cls}, (o, method, objects) -> {

            Request request = new Request();
            request.setInterfaceName("/" + cls.getName());
            request.setRequestId(UUID.randomUUID().toString());
            request.setParameter(objects);
            request.setMethodName(method.getName());
            request.setParameterTypes(method.getParameterTypes());

            Response response = new NettyClient().client(request);
            return response.getResult();
        });
    }
}

Server 端要想能夠通過反射調用 Client 端請求的方法,至少需要:

  1. 類名 interfaceName
  2. 方法名 methodName
  3. 參數類型 Class<?>[] parameterTypes
  4. 傳入參數 Object parameter[]
@Data
public class Request {

    private String requestId;
    private String interfaceName;
    private String methodName;
    private Class<?>[] parameterTypes;
    private Object parameter[];

}

遠程調用

最終是通過下面這段代碼遠程調用的,其中 request 包含了調用方法的所有信息。

Response response = new NettyClient().client(request);
/**
 * @author yano
 * GitHub 項目: https://github.com/LjyYano/Thinking_in_Java_MindMapping
 * @date 2021-05-07
 */
public class NettyClient extends SimpleChannelInboundHandler<Response> {

    private Response response;

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        ctx.close();
    }

    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, Response response) {
        this.response = response;
    }

    public Response client(Request request) throws Exception {
        EventLoopGroup group = new NioEventLoopGroup();

        try {
            // 創建並初始化 Netty 客戶端 Bootstrap 對象
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel channel) {
                            channel.pipeline()
                                    .addLast(new RpcDecoder(Response.class))
                                    .addLast(new RpcEncoder(Request.class))
                                    .addLast(NettyClient.this);
                        }
                    });

            // 連接 RPC 服務器
            String[] discover = ZooKeeperOp.discover(request.getInterfaceName()).split(":");
            ChannelFuture future = bootstrap.connect(discover[0], Integer.parseInt(discover[1])).sync();

            // 寫入 RPC 請求數據並關閉連接
            Channel channel = future.channel();
            channel.writeAndFlush(request).sync();
            channel.closeFuture().sync();

            return response;
        } finally {
            group.shutdownGracefully();
        }
    }

}

這段代碼是核心,主要做了兩件事:

  • 請求 ZooKeeper,找到對應節點下的 Server 地址。如果有多個服務提供方,ZooKeeperOp.discover 會隨機返回 Server 地址
  • 與獲取到的 Server 地址建立 Socket 連接,請求並等待返回

編解碼

channel.pipeline()
    .addLast(new RpcDecoder(Response.class))
    .addLast(new RpcEncoder(Request.class))
    .addLast(NettyClient.this);

Client 和 Server 都需要對 Request、Response 編解碼。本示例採用了最簡單的 Json 格式。Netty 的消息編解碼具體不詳細講解,具體代碼如下。

RpcDecoder

RpcDecoder 是一個 ChannelInboundHandler,在 Client 端是對 Response 解碼。

public class RpcDecoder extends MessageToMessageDecoder<ByteBuf> {

    private final Class<?> genericClass;

    public RpcDecoder(Class<?> genericClass) {
        this.genericClass = genericClass;
    }

    @Override
    public void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) {
        if (msg.readableBytes() < 4) {
            return;
        }
        msg.markReaderIndex();
        int dataLength = msg.readInt();
        if (msg.readableBytes() < dataLength) {
            msg.resetReaderIndex();
            return;
        }
        byte[] data = new byte[dataLength];
        msg.readBytes(data);

        out.add(JSON.parseObject(data, genericClass));
    }
}

RpcEncoder

RpcEncoder 是一個 ChannelOutboundHandler,在 Client 端是對 Request 編碼。

public class RpcEncoder extends MessageToByteEncoder {

    private final Class<?> genericClass;

    public RpcEncoder(Class<?> genericClass) {
        this.genericClass = genericClass;
    }

    @Override
    public void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) {
        if (genericClass.isInstance(msg)) {
            byte[] data = JSON.toJSONBytes(msg);
            out.writeInt(data.length);
            out.writeBytes(data);
        }
    }
}

RpcServerInboundHandler

這個是 Server 反射調用的核心,這裏單獨拿出來講解。Netty Server 在啓動時,已經在 pipeline 中加入了 RpcServerInboundHandler。

socketChannel.pipeline()
    .addLast(new RpcDecoder(Request.class))
    .addLast(new RpcEncoder(Response.class))
    .addLast(new RpcServerInboundHandler(params));
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    Request request = (Request) msg;
    logger.info("request data {}", JSON.toJSONString(request));

    // jdk 反射調用
    Object bean = handle.get(request.getInterfaceName());
    Method method = bean.getClass().getMethod(request.getMethodName(), request.getParameterTypes());
    method.setAccessible(true);
    Object result = method.invoke(bean, request.getParameter());

    Response response = new Response();
    response.setRequestId(request.getRequestId());
    response.setResult(result);

    // client 接收到信息後主動關閉掉連接
    ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}

Server 在 ZooKeeper 的路徑

Server 啓動後的輸出如下:

其中有 2 行 log:

create node /com.yano.service.HelloService/127.0.0.1:3000 
create node /com.yano.service.HiService/127.0.0.1:3000 

在 ZooKeeper 中查看節點,發現服務已經註冊上去了。

[zk: 0.0.0.0:2181(CONNECTED) 0] ls /com.yano.service.HelloService
[127.0.0.1:3000]
[zk: 0.0.0.0:2181(CONNECTED) 1] ls /com.yano.service.HiService
[127.0.0.1:3000]

說明

使用 Netty、ZooKeeper 和 Spring Boot 手擼一個微服務 RPC 框架。這個 demo 只能作爲一個實例,手動實現能加深理解,切勿在生產環境使用。

本文代碼均可在 GitHub 源碼地址 中找到,歡迎大家 star 和 fork。

參考鏈接

https://github.com/yanzhenyidai/netty-rpc-example

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章