分类 技术 下的文章

gRPC & netty

背景

  • 开发MQ测试环境多版本引流功能,复用RocketMQ的remoting模块netty通信模块,client以近原生的方式请求元信息服务控制消息引流
  • 开发故障检测过程中使用gRPC通信能力,java-gRPC底层默认由netty实现
  • SkyWalking多版本链路传递功能,学习SkyWalking通信部分设计

gRPC简介

一个高性能、开源通用RPC框架

亮点

CNCF孵化中

gRPC使用protobuf作为

从protobuf开始

protubuf是gRPC中的核心概念之一,gRPC使用protobuf同时作为接口定义语言(IDL)和底层消息交换的序列化结构(实际上也可以替换为json等),protobuf中即可以定义rpc(端点),也可以定义数据结构,也支持目录包引用、oneof、enum等特性,protobuf的定义和常用的编程语言数据结构还是有些区别的,例如没有继承关系(protobuf3不再支持extend,实际上可以通过oneof关键字曲线救国)

以下面故障检测服务的其中一个protobuf定义为例,import可以从其他包下引入定义,option则是代码生成的一些选项,service下定义了一个rpc:StreamChannel,入参和出参都是stream类型,表示是双向流;enum和message的数据结构中都由Field var=number的格式组成,其中number的值会在序列化时转成二进制,用来表示字段code。protobuf序列化时number在1~15时占用1个字节,16~2047会占用2个字节,所以最佳实践是把前15的序号保留给最常使用的字段。

syntax = "proto3";

import "command/server/register.proto";
import "command/client/thread_snapshot.proto";
import "command/client/profile.proto";
import "command/server/hot_thread.proto";

package com.enmonster.platform.hts.grpc;

option java_multiple_files = true;
option java_package = "com.enmonster.platform.hts.grpc";
option java_outer_classname = "CommandDispatcherRPC";

service CommandDispatcher {
  // 双向流,server和client可互相通讯
  rpc StreamChannel (stream StreamDataPackage) returns (stream StreamDataPackage) {}
}

// 保证向前兼容,添加命令请勿修改已有命令的field_value
enum Command {
  REGISTER = 0;  // client注册到server
  RECORD_HOT_THREAD = 1;  // 记录热点线程
  // 以上为server端支持的命令,以下为client端支持的命令
  THREAD_SNAPSHOT = 11;  // 采集线程快照
  PROFILE = 12;  // 开始async-profiler采样
}

// 双向流的逻辑数据包,response_body为空表示是请求,不为空表示是响应
message StreamDataPackage {
  string job_id = 1;  // 命令所属的job id
  Command command = 2;  // 命令
  string client_ip = 3;  // 客户端ip, aka node ip
  oneof request {  // 命令请求payload
    RegisterRequest register_request = 10;
    ThreadSnapshotRequest thread_snapshot_request = 11;
    HotThreadRequest hot_thread_request = 12;
    ProfileRequest profile_request = 13;
  }
  BaseResponse response = 20;  // 命令响应payload,不为null表示是命令结果,否则是命令请求
}

message BaseResponse {
  bool ok = 1;  // 是否成功
  string message = 2;  // 消息
  // 命令结果body
  oneof body {
    RegisterResponse register_response = 3;
    ThreadSnapshotResponse thread_snapshot_response = 4;
    HotThreadResponse hot_thread_response = 5;
    ProfileResponse profile_response = 6;
    ProfileResultResponse profile_result_response = 7;
  }
}

大部分字段类型都和常用编程语言类型相似,包括支持map<string, string>表示map、repeated表示数组等,同时也支持复用包里的数据结构,例如google.protobuf.Timestamp

编译protobuf

protobuf的优势在于其是跨平台、跨语言的DSL,比高级编程语言更抽象一级,所以写起来非常简洁(有点像写java接口哈)。这就意味着protobuf+grpc编译后的产物是高级编程语言(java、python、go、c/cpp……),有点像前端的.vue编译成css、js,不过感觉抽象级别更高一些。

这里以java编译为例,引入protobuf-maven-plugin插件执行compile就可以得到编译后的java文件(也可以直接用protoc二进制编译器,实际上这个maven插件也是去执行protoc编译的)

填充自己的业务逻辑吧

编译后的代码中可以看见对应的java类中已经有了完整的gRPC底层通讯逻辑,包括定义的RPC端点,几种同步异步的stub可以直接调用,各种数据结构的builder等,使用时可以非常方便地继承生成的ImplBase类,填充对应handler的逻辑。

可以把protobuf定义单独打到一个maven模块,server和client去引相同的依赖包,以保证版本的一致性和复用。因为是BI_DI类型的rpc,client的逻辑和server就很类似了,初始由client去主动连接server,我这里因为需要上报client的信息做了心跳保活,实际上因为gRPC基于HTTP/2的特性,如果没有显式地设置deadline/timeout,流式的rpc是可以一直传输的,而不用使用HTTP/1.X去轮询或者定时hold长链接。

@see HTC的client代码

SkyWalking是怎么玩的

SkyWalking作为tracing组件,每时每刻都会上报大量的数据到其后端server,除了对SW本身的处理能力、吞吐性有要求外,稳固可靠的传输层/RPC框架也很重要,SkyWalking使用的就是gRPC。社区在8.X后也有了走kafka消息队列的方案,但是至少就我们公司而言,gRPC在链路传输和rpc性能方面已经表现得相当稳固了。

TraceSegmentServiceClient.java

Tracing.proto

RemoteServiceHandler.java

TraceSegmentReportServiceHandler.java

我看的也比较浅,如果感兴趣可以去看SW官方的这篇博客,SkyWalking创始人吴晟写的,介绍了一些通讯、路由、整个系统架构层面的一些内容,比较干货。

有趣的是,RocketMQ在最新发布的5.0版本中也在原来的纯netty通信基础上,选用了gRPC作为默认通讯及rpc方案,由此可见gRPC的高性能和可靠性也越来越被开源社区认可。

站在巨人的肩膀上——netty

既然上面说到gRPC这么强大,除了protobuf作为二进制序列化框架、rpc DSL,以及HTTP/2的底层协议,其他都是自己实现的吗?从grpc-java的角度看,底层还少不了一位重量级角色netty,作为网络层框架。(当然这么说也不是很严谨,实际上除了序列化协议可以由protobuf替换为json等,底层网络传输层也可以由ok-http等替换,这和语言也有关系)

netty简介

netty是一个异步的、事件驱动的网络应用框架,netty的性能众所周知非常强大,资料也非常多,我并没有写过原生的netty,最近在写虚拟环境MQ相关内容时发现RocketMQ的通讯模块对netty封装的也非常好,所以拿过来看下内部是怎么实现的,以及分享下如何基于rocket-remoting模块去拓展MQ client的逻辑,得到和原生consumer<->broker一致的rpc能力。

org.apache.rocketmq.remoting.netty.NettyRemotingAbstract#processRequestCommand

背景

对于一些底层业务系统,同步信息表填充基本信息的操作比较常见,过去的做法多为跨服务同步基础信息表到当前业务表并持久化,需要时join基础表填充字段。这里设计一个简单的CacheService,以内存缓存的方式实现以下功能:

  1. 从外部系统rpc调用刷新缓存并持久化到DB(全量刷新)
  2. 从MQ拿到增量变更数据,刷新缓存并更新DB(增量刷新)
  3. 应用重启从DB拉取全量基础信息维护到内存(初始化)
  4. 无法推MQ的外部系统支持定时任务统一同步(定时刷新)
  5. 需要填充基本字段时直接从内存中可取(读取)

接口

Cacheable,从外部系统接收的数据结构实现转换方法,处理成系统内需要的结构T:

/**
 * @author sunyongfei
 * @date 2022/3/28
 */
public interface Cacheable<T> {

    /**
     * 获取缓存key
     * @return
     */
    String getCacheKey();

    /**
     * 获取缓存值
     * @return
     */
    T getCacheValue();
}

CacheService,单个缓存维护逻辑,可能从不同的数据源同步,统一约束行为

/**
 * @author sunyongfei
 * @genertic T: destination target class
 * @date 2022/3/28
 */
public interface CacheService<T> {

    /**
     * @PostConstruct needed in actual impl bean,
     * if you want to add annotation in the interface, use abstract class instead.
     */
    void initCacheFromDB();

    /**
     * refresh cache
     */
    boolean refreshCache();

    /**
     * refresh single cache item
     */
    boolean refreshCache(T t);

    /**
     * get cache by key
     * @param cacheKey
     * @return
     */
    T getCacheItem(String cacheKey);
}

使用例:

SysInfoSimpleDTO implements XXX, Cacheable<SysInfoDetailDTO>: 接收外部系统的DTO

SysInfoServiceImpl implements XXX, CacheService: 自定义逻辑

SyncServiceImpl: 公共逻辑,如简单的定时任务同步

@Resource
private List<CacheService<?>> cacheServices;

@Scheduled(cron = "0 0/5 * * * ?")
public void refreshCache() {
    if (CollectionUtils.isNotEmpty(cacheServices)) {
        cacheServices.forEach(CacheService::refreshCache);
    }
}

其他逻辑略

Jorges Luis Borges, 博尔赫斯

I offer you lean streets, desperate sunsets, the moon of the jagged suburbs.
我给你贫穷的街道、绝望的日落、破败郊区的月亮。

I offer you the bitterness of a man who has looked long and long at the lonely moon.
我给你一个久久地望着孤月的人的悲哀。

I offer you my ancestors, my dead men, the ghosts that living men have honoured in marble: my father’s father killed in the frontier of Buenos Aires, two bullets through his lungs, bearded and dead, wrapped by his soldiers in the hide of a cow;
my mother’s grandfather -just twentyfour- heading a charge of three hundred men in Perú, now ghosts on vanished horses.
I offer you whatever insight my books may hold. whatever manliness or humour my life.
我给你我已死去的先辈,人们用大理石纪念他们的幽灵:在布宜偌斯艾利斯边境阵亡的我父亲的父亲,两颗子弹穿了他的胸膛。蓄着胡子的他死去了,士兵们用牛皮裹起他的尸体;我母亲的祖父——时年二十四岁——在秘鲁率领三百名士兵冲锋,如今都成了消失的马背上的幽灵。
我给你我写的书中所能包含的一切悟力、我生活中所能有的男子气概或幽默。

I offer you the loyalty of a man who has never been loyal.
我给你一个从未有过信仰人的忠诚。

I offer you that kernel of myself that I have saved somehow -the central heart that deals not in words, traffics not with dreams and is untouched by time, by joy, by adversities.
我给你我设法保全的我自己的核心——不营字造句,不和梦想交易,不被时间、欢乐和逆境触动的核心。

I offer you the memory of a yellow rose seen at sunset, years before you were born.
我给你,早在你出生前多年的一个傍晚看到的一朵黄玫瑰的记忆。

I offer you explanations of yourself, theories about yourself, authentic and surprising news of yourself.
我给你对自己的解释,关于你自己的理论,你自己的真实而惊人的消息。

I can give you my loneliness, my darkness, the hunger of my heart; I am trying to bribe you with uncertainty, with danger, with defeat.
我给你我的寂寞、我的黑暗、我心的饥渴;我试图用困惑、危险、失败来打动你。

最近开发javaagent应用,参照jps命令引入了sun.jvmstat包下的一些工具写了些逻辑,Maven打包时确实有些tricky,很容易打包后执行时找不到sun.jvmstat包下的一些类,记录下解决过程

1. dependency 引入

引入jdk内部依赖方法:定位.jar文件位置,scope设置为system引入

<!-- for jdk internal lib -->
<dependency>
    <groupId>com.sun</groupId>
    <artifactId>tools</artifactId>
    <version>1.8.0</version>
    <scope>system</scope>
    <systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>

2. Assembly Plugin 配置

正常maven-compiler-plugins是没问题的,但是如果要打单个包,使用Assembly Plugin或Shade Plugin直接打包会有问题,因为system scope的缘故tools.jar内的库还是打不进去,此时需要添加descriptor配置:

<assembly
        xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
    <id>jar-with-all-dependencies</id>
    <formats>
        <format>jar</format>
    </formats>
    <includeBaseDirectory>false</includeBaseDirectory>
    <dependencySets>
        <dependencySet>
            <outputDirectory>/</outputDirectory>
            <useProjectArtifact>true</useProjectArtifact>
            <unpack>true</unpack>
            <scope>runtime</scope>
        </dependencySet>
        <dependencySet>
            <outputDirectory>/</outputDirectory>
            <unpack>true</unpack>
            <scope>system</scope>
        </dependencySet>
    </dependencySets>
</assembly>

保存为assembly.xml,在pom.xml的assembly-plugin插件配置段添加如下配置引入discriptor:

<configuration>
    <appendAssemblyId>false</appendAssemblyId>
    <descriptors>
        <descriptor>${basedir}/assembly.xml</descriptor>
    </descriptors>
</configuration>

再执行assembly打包,可以看到打出来的.jar文件内已经包含sun.jvmstat包内的相关class,可以正常执行了

参考

  1. https://stackoverflow.com/questions/3080437/jdk-tools-jar-as-maven-dependency