通过 Netty、ZooKeeper 手撸一个 RPC 服务
- 说明
- 本地 Docker 搭建 ZooKeeper
- RPC 接口
- Netty RPC server
- Netty RPC Client
- 编解码
- RpcServerInboundHandler
- Server 在 ZooKeeper 的路径
- 说明
- 参考链接
说明
使用 Netty、ZooKeeper 和 Spring Boot 手撸一个微服务框架。
项目链接
微服务框架都包括什么?
详细信息可参考: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;
}
}
这里涉及到了两个问题:
- Server 应该决定将哪些接口实现注册到 ZooKeeper 上?
- 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();
}
}
}
这个类的作用是:
- 通过 Netty 启动一个 Socket 服务,端口号通过参数传入
- 将上面的接口实现注册到 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 端请求的方法,至少需要:
- 类名 interfaceName
- 方法名 methodName
- 参数类型 Class<?>[] parameterTypes
- 传入参数 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
通过 Netty、ZooKeeper 手撸一个 RPC 服务的更多相关文章
- 【RPC】手撸一个简单的RPC框架实现
涉及技术 序列化.Socket通信.Java动态代理技术,反射机制 角色 1.服务提供者:运行在服务端,是真实的服务实现类 2.服务发布监听者:运行在RPC服务端,1将服务端提供的 ...
- 手撸一个SpringBoot-Starter
1. 简介 通过了解SpringBoot的原理后,我们可以手撸一个spring-boot-starter来加深理解. 1.1 什么是starter spring官网解释 starters是一组方便的依 ...
- 使用Java Socket手撸一个http服务器
原文连接:使用Java Socket手撸一个http服务器 作为一个java后端,提供http服务可以说是基本技能之一了,但是你真的了解http协议么?你知道知道如何手撸一个http服务器么?tomc ...
- 【手撸一个ORM】MyOrm的使用说明
[手撸一个ORM]第一步.约定和实体描述 [手撸一个ORM]第二步.封装实体描述和实体属性描述 [手撸一个ORM]第三步.SQL语句构造器和SqlParameter封装 [手撸一个ORM]第四步.Ex ...
- 第二篇-用Flutter手撸一个抖音国内版,看看有多炫
前言 继上一篇使用Flutter开发的抖音国际版 后再次撸一个国内版抖音,大部分功能已完成,主要是Flutter开发APP速度很爽, 先看下图 项目主要结构介绍 这次主要的改动在api.dart 及 ...
- C#基于Mongo的官方驱动手撸一个Super简易版MongoDB-ORM框架
C#基于Mongo的官方驱动手撸一个简易版MongoDB-ORM框架 如题,在GitHub上找了一圈想找一个MongoDB的的ORM框架,未偿所愿,就去翻了翻官网(https://docs.mongo ...
- 手撸一个springsecurity,了解一下security原理
手撸一个springsecurity,了解一下security原理 转载自:www.javaman.cn 手撸一个springsecurity,了解一下security原理 今天手撸一个简易版本的sp ...
- 五分钟,手撸一个Spring容器!
大家好,我是老三,Spring是我们最常用的开源框架,经过多年发展,Spring已经发展成枝繁叶茂的大树,让我们难以窥其全貌. 这节,我们回归Spring的本质,五分钟手撸一个Spring容器,揭开S ...
- Golang:手撸一个支持六种级别的日志库
Golang标准日志库提供的日志输出方法有Print.Fatal.Panic等,没有常见的Debug.Info.Error等日志级别,用起来不太顺手.这篇文章就来手撸一个自己的日志库,可以记录不同级别 ...
随机推荐
- Go语言学习笔记——在本地建立一个官网查看
命令行内运行 godoc -http=:8080 运行完成后打开浏览器输入网址:http://localhost:8080能看到一个和官网一模一样的网站,然后查看帮助文档
- 归一化(Normalization)和标准化(Standardization)
归一化和标准化是机器学习和深度学习中经常使用两种feature scaling的方式,这里主要讲述以下这两种feature scaling的方式如何计算,以及一般在什么情况下使用. 归一化的计算方式: ...
- juc下Condition类解析
在使用Lock之前,我们使用的最多的同步方式应该是synchronized关键字来实现同步方式了.配合Object的wait().notify()系列方法可以实现等待/通知模式. Condition接 ...
- [换根DP][倍增]luogu P5666 树的重心
题面 https://www.luogu.com.cn/problem/P5666 分析 对于一棵以i为根的树来说,它的重心必然在其size大于等于sumsize/2的子树中. 那么断掉一条边e(u, ...
- HarmonyOS三方件开发指南(15)-LoadingView功能介绍
目录: 1. LoadingView组件功能介绍2. Lottie使用方法3. Lottie开发实现4.<HarmonyOS三方件开发指南>系列文章合集 1. LoadingView组件功 ...
- css详解position五种属性用法及其含义
position(定位) position - 作为css属性三巨头(position.display.float)之一,它的作用是用来决定元素在文档中的定位方式.其属性值有五种,分别是 - stat ...
- C# List常用方法及Dictionary常用方法汇总
本文主要汇总了在开发过程中,使用List和Dictionary常用的方法,例如增.删.改.查.排序等等各种常用操作. 在平时的开发过程中,List和Dictionary是我们经常使用到的数据结构,而且 ...
- MySQL性能压力基准测试工具sysbench
1.sysbench介绍 这里介绍一款MySQL数据库的压力测试软件sysbench,用它来进行基准测试. sysbench 是一个开源的.模块化的.跨平台的多线程性能测试工具, 可以用来进行CPU. ...
- 详细Tomcat安装及问题排查
一.安装 1.下载官网:https://tomcat.apache.org/ 2.将下载后的包解压到目录中会出现以下页面 3.设置环境变量,向path中添加tomcat的bin目录地址 4.cmd进入 ...
- 面试题:让你捉摸不透的 Go reslice
面试题: package main func a() []int { a1 := []int{3} a2 := a1[1:] return a2 } func main() { a() } 看到这个题 ...