公司微服务内我的服务接口都谁在调用呢?还在脑力记吗?

前言

微服务已经在越来越多的企业开花。企业在享受微服务优势的同时,会产生一些问题。如随着企业的业务发展,相依的服务数量不断增加,服务调用关系越来越错综复杂。

以下对话是软件开发人员经常遇到的。
场景一:我的接口都谁在调用呢

1
2
3
4
5
小张:小王,user服务的获取getById()接口被哪些服务使用呢?
小王:原来业务不多的时候,我还知道。现在业务太复杂了,调用方太多,我已经记不清了。
小张:那么办,我接了一个需求,涉及原接口逻辑的变动,这个接口的调用方都要评估下影响面
小王:那你只能挨个负责服务的同事问下了
小张:太麻烦了,时间都用在寻找调用方上了

RD每天的宝贵时间都浪费在了维护服务和接口的调用关系上,为此失去了专研更有价值的业务的精力

场景二:我的服务都调了谁的服务的哪些接口

1
2
3
4
5
小姚:小修,我接手了一个product服务,为了尽量熟悉,我需要知道这个服务都依赖哪些服务,调了哪些接口?
小修:这个···,服务有个wiki文档,你看看呢
小姚:我看了,最新的更新时间是一年前
小修:恐怕你只能翻代码了
小姚:一行一行看代码!,我嚎哭

RD每天的宝贵时间都浪费在了维护服务和接口的调用关系上,为此失去了学习新技术的时间

本项目产生的意义就是为了收集和展示服务的调用关系,特别是服务中接口的调用关系。带来的价值为很好的避免了以往只能通过开发人员头脑记忆,要知道记忆是会减退的。所以利于准备评估后续需求开发涉及的影响面。
从而维护项目上线的稳定,增强服务可用性。所以,项目name:微服务梳子。意在自动化理清微服务体系下的服务间关系调用

业务背景

微服务架构的流行,在业务分隔,服务复用,敏捷开发等方面带来了很大的飞跃。随着业务场景越来越丰富,业务领域越来越广泛,服务数量越来越庞大。随之,服务间调用越来越错综复杂。

微服务带来的问题

所以,当服务数量越来越庞大时,服务间调用越来越错综复杂。举一个很实际的例子:一个服务从诞生开始,随着业务的不断发展,服务接口数量和调用方都在不断增加,而开发人员又在不断变化。慢慢的,当前服务接口的被使用方有哪些就无人能说清了。
具体说,假设服务A有一个接口a(其实a被3个上游其他的服务调用),但是当前维护a接口的人员可能是新来的,或是时间太过久远而不一定能准确的知道调用a接口的上游服务。这就带来了诸多不确定性风险

本项目能带来什么

为了准确的知道服务及其接口的调用方信息,本项目为此而出现。通过本项目,你可以准确的、实时的获取服务及其接口的被调用(使用)情况,从而帮助你准备的评估迭代需求的工作量,及时统计出更改所影响的上游服务方,拔高说增强了服务的可维护性和稳定性,提高了可用性。

核心目标

本项目意在提供简单快速的使用教程,真正的协助微服务架构模式下的服务维护成本,减少RD们对服务接口调动错乱的记忆,当然也告别前人留下的大坑。

架构图

架构图

组件依赖

本项目基于spring cloud openfeign(目前版本:2.1.0.RELEASE)实现

实现原理

本项目分为三部分,一是调用信息收集及发送,二是接收并存储调用信息,三是图表展示微服务调用关系。调用信息的传递(发送和接收)采用的是CQRS模型

  1. 调用信息收集及发送
    这部分功能由microservice-comb-infrastructure模块负责。具体原理为调用信息收集采用的是动态代理,通过代理LoadBalancerFeignClient类,使得有机会在服务调用时收集调用信息;并通过kafka发送调用信息给接收方。收集的逻辑通过自定义的InvocationHandler:LoadBalancerFeignClientInvocationHandler完成,发送调用信息通过MessageSender类完成。

  2. 接收并存储调用信息
    这部分功能由microservice-comb-server模块负责。使用kafka接收调用信息,随之将信息持久化到硬盘,当前是保存到mysql数据库,后期会采用动态切换,支持mongodb等方式。

  3. 图表展示微服务调用关系
    这部分功能由microservice-comb-admin模块负责。使用js等图表组件展示服务调用关系图,通过检索服务名/接口名可以知道:A服务的a接口的调用方信息,什么时间调的,一段时间内调用的次数等。但由于本人还不熟悉前端技术,暂时搁置这个模块。

项目结构

module description
microservice-comb-admin 调用信息图表展示,核心组件
microservice-comb-base 基础工具包,核心组件
microservice-comb-infrastructure 调用信息收集及发送,超核心组件
microservice-comb-server 接收并保存调用信息,核心组件
microservice-comb-example 使用例子,模拟业务方服务

特别说明:

1
2
3
4
microservice-comb-example包含三个module。
microservice-comb-server-a 模拟业务方服务,此模块引用microservice-comb-server-b-sdk
microservice-comb-server-b 模拟业务方服务,此模块引用microservice-comb-server-b-sdk
microservice-comb-server-b-sdk 模拟业务方服务sdk,此模块引用microservice-comb-infrastructure

使用实例

使用方式

使用很简单

总体分两步:

  • 资源准备
  • 引用sdk

资源准备

  1. kafka。调用信息发送和接收采用的kafka,所以你需要准备好运行着的kafka server,并把kafka的配置信息换成你的`kafka server信息

  2. mysql。你需要有mysql数据库database: server_info,并建表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    CREATE TABLE `server_invocation` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id,自增',
    `from_application` varchar(100) NOT NULL DEFAULT '' COMMENT '服务调用方',
    `to_application` varchar(100) NOT NULL DEFAULT '' COMMENT '服务被调用方',
    `from_path` varchar(100) NOT NULL DEFAULT '' COMMENT '服务调用方接口路径path',
    `to_path` varchar(100) NOT NULL DEFAULT '' COMMENT '服务被调用方接口路径path',
    `method` varchar(32) NOT NULL DEFAULT '' COMMENT '请求方式: GET POST',
    `ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `utime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `creator_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '操作人id',
    `create_name` varchar(32) NOT NULL DEFAULT '' COMMENT '操作人姓名',
    PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务调用信息表';
  3. eureka
    本项目是基于spring cloud体系,所以eureka是需要的

引用sdk

import源码后,通过mvn将microservice-comb-infrastructure打jar包, 然后引入到应用project中。将原来引用spring-cloud-starter-openfeign的模块或服务换成引用microservice-comb-infrastructure,如下

1
2
3
4
5
<dependency>
<groupId>com.skyler.cobweb</groupId>
<artifactId>microservice-comb-infrastructure</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>

模拟实战

为方便使用和接入。microservice-comb-example模块提供了模拟实际公司中的微服务架构模式,microservice-comb-example包含三个子模块,整体关系为

1
2
3
microservice-comb-server-a依赖microservice-comb-server-b-sdk,因为microservice-comb-server-a需要调用sdk的feign接口
microservice-comb-server-b依赖microservice-comb-server-b-sdk,因为microservice-comb-server-b的controller实现sdk的feign接口
microservice-comb-server-b-sdk依赖microservice-comb-infrastructure

  1. 运行服务

依次运行如下服务

1
2
3
microservice-comb-server-a
microservice-comb-server-b
microservice-comb-server

  1. 访问microservice-comb-server-a接口

skyler@192 ~  curl -X GET 'localhost:9090/combo/getById?id=10'

  1. 查看数据库数据
    如图所示

20200315002513.png

模拟企业实际应用

microservice-comb-admin的功能负责展示数据库的服务间调用关系图。由于本人没有学习前端技术。效果暂时通过查询sql展示

查看微服务下多个服务间调用关系

20200315002437.png

  • 查询microservice-comb-server-b调用了哪些服务及接口
    1
    SELECT * FROM server_invocation WHERE from_application = 'microservice-comb-server-b'

结果如下图,
20200314235524.png

Note: 支持展示精确到调用方法上,附加条件查询就好

  • 查询哪些服务及接口调用了microservice-comb-server-b
    1
    SELECT * FROM server_invocation WHERE to_application = 'microservice-comb-server-b'

结果如下图
20200314235605.png

项目地址: microservice-comb

微服务架构下服务依赖分析

1
2
3
4
5
6
// 前台business业务服务
@RequestMapping("/getById")
public Dto getById(Long id) {
User user = userFeignClient.getUserById(userId); // 调用user用户服务
return productFeignClient.getById(userId); // 调用服务product商品服务
}

服务依赖分析的重要性

采用微服务架构的企业中,为了业务模块间解耦,更好的迭代发展业务。整个公司业务分成10个模块服务都是很正常的。而且随着业务的不断发展,服务数量和每个服务中接口的数量都在不断增加,且开发人员又在不断更迭。试想,10模块服务间,比较相互依赖,相互调用,链路如一张网。尤其细化到接口级别,更加繁琐。如果靠开发人员用脑子记,恐怕不现实了。如何准确的知晓一个服务被哪些服务调用,一个服务用调用了哪些服务,对服务的稳定,代码的维护,影响的评估,业务的迭代等等都产生着巨大的帮助。

作为owner或leader,掌握自己的服务及服务上下游的使用情况绝对的必须的。首先,谁依赖我的服务呢,具体到依赖哪些接口。其次,我依赖谁的服务呢,依赖了他们的哪些接口呢。这个做到门清,需求来时,才能
因此,依赖分析是研发过程中非常重要的一环。

java agent原理

示例

  1. 创建agent class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    public class AgentDemo {
    /*
    * 该方法在main方法之前运行,与main方法运行在同一个JVM中
    */
    public static void premain(String agentArgs, Instrumentation inst) {
    System.out.println("------ premain方法 有两个入参 ------ agentArgs:" + agentArgs + " inst:" + inst.toString());

    // Instrumentation打印加载的类名
    for (Class loadedClass : inst.getAllLoadedClasses()) {
    System.out.println("loadedClass.getName():" + loadedClass.getName());
    }

    // ClassFileTransformer打印加载的类名
    inst.addTransformer(new ClassFileTransformer() {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
    System.out.println("transform method className:" + className);
    return new byte[0];
    }
    });
    }

    /**
    * 如果不存在 {@link AgentDemo#premain(String, Instrumentation)}, 则会执行本方法
    */
    public static void premain(String agentArgs) {
    System.out.println("------ premain方法,有一个入参 ------ agentArgs:" + agentArgs);
    }
    }
  2. pom中添加配置,用于生成META-INF/manifest.mf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <configuration>
    <appendAssemblyId>false</appendAssemblyId>
    <descriptorRefs>
    <descriptorRef>jar-with-dependencies</descriptorRef>
    </descriptorRefs>
    <archive>
    <manifestEntries>
    <!-- 值为包含premain方法的类. 启动方式为命令行启动时,javaagent JAR文件清单必须包含 Premain-Class 属性, 代理类必须实现 public static premain()-->
    <Premain-Class>com.ke.utopia.combo.agent.javassist.CombAgent</Premain-Class>
    <!-- 值为包含agentmain方法的类. 启动方式为JVM启动后启动时 文件清单必须包含 Agent-Class 属性, 代理类必须实现 public static agentmain() -->
    <!--<Agent-Class>com.ke.utopia.combo.agent.AgentDemo</Agent-Class>-->
    <Can-Redefine-Classes>true</Can-Redefine-Classes>
    <Can-Retransform-Classes>true</Can-Retransform-Classes>
    </manifestEntries>
    </archive>
    </configuration>
    <executions>
    <execution>
    <id>make-assembly</id><!-- this is used for inheritance merges -->
    <phase>package</phase><!-- 指定在打包节点执行jar包合并操作 -->
    <goals>
    <goal>single</goal>
    </goals>
    </execution>
    </executions>
    </plugin>

运行使用,idea或者命令行启动时加入如下指令

1
VM options: -javaagent:/Users/yaoliang/skyler/project/lianjia/microservice-comb/microservice-comb-javaagent/target/microservice-comb-javaagent-1.0.0-SNAPSHOT.jar=hello

java agent原理

JVMTI
1
2
3
4
5
6
7
8
9
Java平台调试器架构(Java Platform Debugger Architecture,JPDA)是一组用于调试Java代码的API(摘自维基百科):

Java调试器接口(Java Debugger Interface,JDI)——定义了一个高层次Java接口,开发人员可以利用JDI轻松编写远程调试工具
Java虚拟机工具接口(Java Virtual Machine Tools Interface,JVMTI)——定义了一个原生(native)接口,可以对运行在Java虚拟机的应用程序检查状态、控制运行
Java虚拟机调试接口(JVMDI)——JVMDI在J2SE 5中被JVMTI取代,并在Java SE 6中被移除
Java调试线协议(JDWP)——定义了调试对象(一个 Java 应用程序)和调试器进程之间的通信协议
JVMTI 提供了一套"代理"程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能。JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。

JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。Instrument Agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),是专门为java语言编写的插桩服务提供支持的代理。
Instrumentation原理

获取Instrumentation接口实例的方法

  • JVM在指定代理的方式下启动,此时Instrumentation实例会传递到代理类的premain方法。
  • JVM提供一种在启动之后的某个时刻启动代理的机制,此时Instrumentation实例会传递到代理类代码的agentmain方法。

简单来说就是premain方法和agentmain方法

1
premain对应的就是VM启动时的Instrument Agent加载,即agent on load,agentmain对应的是VM运行时的Instrument Agent加载,即agent on attach。两种加载形式所加载的Instrument Agent都关注同一个JVMTI事件 – ClassFileLoadHook事件,这个事件是在读取字节码文件之后回调时用,也就是说premain和agentmain方式的回调时机都是类文件字节码读取之后(或者说是类加载之后),之后对字节码进行重定义或重转换,不过修改的字节码也需要满足一些要求,在最后的局限性有说明

premain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1.编写premain函数,包含下面两个方法的其中之一:

public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);

如果两个方法都被实现了,那么带Instrumentation参数的优先级高一些,会被优先调用。agentArgs是premain函数得到的程序参数,通过命令行参数传入

2.定义一个 MANIFEST.MF 文件,必须包含 Premain-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项

3.将 premain 的类和 MANIFEST.MF 文件打成 jar 包

4.使用参数 -javaagent: jar包路径启动代理

premain加载过程如下:
a.创建并初始化 JPLISAgent
b.MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容
c.监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:
(1)创建 InstrumentationImpl 对象 ;
(2)监听 ClassFileLoadHook 事件 ;
(3)调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法
agentmain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1.编写agentmain函数,包含下面两个方法的其中之一:
public static void agentmain(String agentArgs, Instrumentation inst);
public static void agentmain(String agentArgs);

如果两个方法都被实现了,那么带Instrumentation参数的优先级高一些,会被优先调用。agentArgs是premain函数得到的程序参数,通过命令行参数传入

2.定义一个 MANIFEST.MF 文件,必须包含 Agent-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项

3.将 agentmain 的类和 MANIFEST.MF 文件打成 jar 包

4.通过attach工具直接加载Agent,执行attach的程序和需要被代理的程序可以是两个完全不同的程序:

// 列出所有VM实例
List<VirtualMachineDescriptor> list = VirtualMachine.list();
// attach目标VM
VirtualMachine.attach(descriptor.id());
// 目标VM加载Agent
VirtualMachine#loadAgent("代理Jar路径","命令参数");
agentmain方式加载过程类似:

1.创建并初始化JPLISAgent
2.解析MANIFEST.MF 里的参数,并根据这些参数来设置 JPLISAgent 里的一些内容
3.监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:
(1)创建 InstrumentationImpl 对象 ;
(2)监听 ClassFileLoadHook 事件 ;
(3)调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。

Java Instrumentation结构

Instrumentation是Java提供的一个来自JVM的接口,该接口提供了一系列查看和操作Java类定义的方法,例如修改类的字节码、向classLoader的classpath下加入jar文件等。使得开发者可以通过Java语言来操作和监控JVM内部的一些状态,进而实现Java程序的监控分析,甚至实现一些特殊功能(如AOP、热部署)。
  Instrumentation的一些主要方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface Instrumentation {
/**
* 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
* Transformer可以直接对类的字节码byte[]进行修改
*/
void addTransformer(ClassFileTransformer transformer);

/**
* 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

/**
* 获取一个对象的大小
*/
long getObjectSize(Object objectToSize);

/**
* 将一个jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);

/**
* 获取当前被JVM加载的所有类对象
*/
Class[] getAllLoadedClasses();
}

其中最常用的方法就是addTransformer(ClassFileTransformer transformer)了,这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer接口,定义如下:

1
2
3
4
5
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException;

JVM加载一个class,回调一次transform方法

javaagent开源组件选型

解析 Java 字节码的工具,最常用的包括 Javassit,ASM,CGLib。ASM 是一个轻量级的类库,性能较好,但需要直接操作 JVM 指令。CGLib 是对 ASM 的封装,提供了更高级的接口。相比而言,Javassist 要简单的多,它基于 Java 的 API ,无需操作 JVM 指令,但其性能要差一些(因为 Javassit 增加了一层抽象)。在工程原型阶段,为了快速验证结果,我们优先选择了Javassist。arthas使用了javassist;Byte Buddy操作的是字节码,觉得有些复杂。所以最后使用Javassist实现

javassist学习

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AccountTest {
public static void main(String[] args) throws Exception {
ClassPool classPool = ClassPool.getDefault();
// CtClass ctClass = classPool.makeClass("com.ke.utopia.combo.agent.javassist.SubAccount");
// ctClass.setSuperclass(classPool.get("com.ke.utopia.combo.agent.javassist.Account"));
// // 调用方法
// ((Account) ctClass.toClass().newInstance()).operation("");
//
//
// // 添加方法并调用
// ctClass = classPool.makeClass("com.ke.utopia.combo.agent.javassist.SubAccount2");
// ctClass.setSuperclass(classPool.get("com.ke.utopia.combo.agent.javassist.Account"));
// ctClass.addMethod(CtNewMethod.make(
// "public void operation(String value) { System.out.println(\"operation... from Sub\"); }", ctClass));
// ((Account) ctClass.toClass().newInstance()).operation("");


// 更改现有方法
CtClass ctClass = classPool.get("com.ke.utopia.combo.agent.javassist.Account");

CtMethod method = ctClass.getDeclaredMethod("operation");

// $1 表示函数入栈第一个参数
method.insertBefore("{ System.out.println($1); }");
MethodInfo methodInfo = method.getMethodInfo();
// Object o = ctClass.toClass().newInstance();
// Method operationMethod = o.getClass().getMethod("operation", String.class);
// operationMethod.invoke(o, "111");
((Account)ctClass.toClass().newInstance()).operation("111");
}
}

javassist原理

1
2
3
4
5
Javassist(Java Programming Assistant)使Java字节码操作变得简单。它是一个用于在Java中编辑字节码的类库;它使Java程序能够在运行时定义新类,并在JVM加载时修改类文件。

与其他类似的字节码编辑器不同,Javassist提供两个级别的API:源级别和字节码级别。如果用户使用源级API,他们可以在不了解Java字节码规范的情况下编辑class文件。整个API仅使用Java语言的词汇表进行设计。您甚至可以以源文本的形式指定插入的字节码; Javassist即时编译它。另一方面,字节码级API允许用户直接编辑class文件作为其他编辑器。

详见官网:https://github.com/jboss-javassist/javassist/wiki/Tutorial-1

javaagent 开源应用组件

1
2
https://github.com/alibaba/arthas
https://github.com/dingjs/javaagent

参考

https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html
https://paper.seebug.org/1099/#instrumentation_1
https://blog.csdn.net/wangzhongshun/article/details/100287986
https://ivanyu.me/blog/2017/11/04/java-agents-javassist-and-byte-buddy/
https://yq.aliyun.com/articles/135955
https://juejin.im/post/5da2fd6a6fb9a04e23576dd4

https://github.com/jboss-javassist/javassist/wiki/Tutorial-1
https://github.com/legend91325/Getting-Started-with-Javassist/blob/master/GetStartWithJavassist.md
https://zh.twgreatdaily.com/HxGYYWwBUcHTFCnf2NSG.html
https://baiqiantao.github.io/Java/aop/UVFzmi/#MethodCall
https://www.jianshu.com/p/b9b3ff0e1bf8

20200324103459.png