说明

使用 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

通过 Netty、ZooKeeper 手撸一个 RPC 服务的更多相关文章

  1. 【RPC】手撸一个简单的RPC框架实现

      涉及技术   序列化.Socket通信.Java动态代理技术,反射机制   角色   1.服务提供者:运行在服务端,是真实的服务实现类   2.服务发布监听者:运行在RPC服务端,1将服务端提供的 ...

  2. 手撸一个SpringBoot-Starter

    1. 简介 通过了解SpringBoot的原理后,我们可以手撸一个spring-boot-starter来加深理解. 1.1 什么是starter spring官网解释 starters是一组方便的依 ...

  3. 使用Java Socket手撸一个http服务器

    原文连接:使用Java Socket手撸一个http服务器 作为一个java后端,提供http服务可以说是基本技能之一了,但是你真的了解http协议么?你知道知道如何手撸一个http服务器么?tomc ...

  4. 【手撸一个ORM】MyOrm的使用说明

    [手撸一个ORM]第一步.约定和实体描述 [手撸一个ORM]第二步.封装实体描述和实体属性描述 [手撸一个ORM]第三步.SQL语句构造器和SqlParameter封装 [手撸一个ORM]第四步.Ex ...

  5. 第二篇-用Flutter手撸一个抖音国内版,看看有多炫

    前言 继上一篇使用Flutter开发的抖音国际版 后再次撸一个国内版抖音,大部分功能已完成,主要是Flutter开发APP速度很爽,  先看下图 项目主要结构介绍 这次主要的改动在api.dart 及 ...

  6. C#基于Mongo的官方驱动手撸一个Super简易版MongoDB-ORM框架

    C#基于Mongo的官方驱动手撸一个简易版MongoDB-ORM框架 如题,在GitHub上找了一圈想找一个MongoDB的的ORM框架,未偿所愿,就去翻了翻官网(https://docs.mongo ...

  7. 手撸一个springsecurity,了解一下security原理

    手撸一个springsecurity,了解一下security原理 转载自:www.javaman.cn 手撸一个springsecurity,了解一下security原理 今天手撸一个简易版本的sp ...

  8. 五分钟,手撸一个Spring容器!

    大家好,我是老三,Spring是我们最常用的开源框架,经过多年发展,Spring已经发展成枝繁叶茂的大树,让我们难以窥其全貌. 这节,我们回归Spring的本质,五分钟手撸一个Spring容器,揭开S ...

  9. Golang:手撸一个支持六种级别的日志库

    Golang标准日志库提供的日志输出方法有Print.Fatal.Panic等,没有常见的Debug.Info.Error等日志级别,用起来不太顺手.这篇文章就来手撸一个自己的日志库,可以记录不同级别 ...

随机推荐

  1. AtCoder Beginner Contest 187

    A Large Digits int n; int main() { IOS; int a, b, resa = 0, resb = 0; cin >> a >> b; whi ...

  2. 来,Consul 服务发现入个门(一看就会的那种)

    前言 在微服务架构中,对于一个系统,会划分出多个微服务,而且都是独立开发.独立部署,最后聚合在一起形成一个系统提供服务.当服务数量增多时,这些小服务怎么管理?调用方又怎么能确定服务的IP和端口?服务挂 ...

  3. python基础学习之类的属性 增删改查

    类中的属性如何在类外部使用代码进行增删改查呢 增加.改变: setattr内置函数以及 __setattr__魔法方法 class A: aaa = '疏楼龙宿' a = A() setattr(a, ...

  4. 为什么是InfluxDB | 写在《InfluxDB原理和实战》出版之际

    1年前写的一篇旧文,文中的分析,以及探讨的问题和观点,至今仍有意义. 从2016年起,笔者在腾讯公司负责QQ后台的海量服务分布式组件的架构设计和研发工作,例如微服务开发框架SPP.名字路由CMLB.名 ...

  5. 关于mysql事物和特性

    事务的 四个特征(ACID) 事务具有四个特征:原子性( Atomicity ).一致性( Consistency ).隔离性( Isolation )和持续性( Durability ).这四个特性 ...

  6. windows 以管理员身份运行 代码

    1 // 以管理员身份运行本进程 2 // 1 获取本进程的文件路径. 3 TCHAR path[MAX_PATH] = { 0 }; // 需要初始化 4 DWORD dwPathSize = MA ...

  7. asp.net core 2.0 web api + Identity Server 4 + angular 5 可运行前后台源码

    前台使用angular 5, 后台是asp.net core 2.0 web api + identity server 4. 从头编写asp.net core 2.0 web api 基础框架: 第 ...

  8. [枚举]P1089 津津的储蓄计划

    津津的储蓄计划 题目描述 津津的零花钱一直都是自己管理.每个月的月初妈妈给津津300元钱,津津会预算这个月的花销,并且总能做到实际花销和预算的相同. 为了让津津学习如何储蓄,妈妈提出,津津可以随时把整 ...

  9. C# List常用方法及Dictionary常用方法汇总

    本文主要汇总了在开发过程中,使用List和Dictionary常用的方法,例如增.删.改.查.排序等等各种常用操作. 在平时的开发过程中,List和Dictionary是我们经常使用到的数据结构,而且 ...

  10. 无法Ping通阿里云服务器的公网IP地址的解决方法

    解决步骤: 1.打开控制台2.打开防火墙3.添加规则添加规则的详情页,可以添加全部TCP也可以自定义添加一定端口范围的TCP: