头图

前言

19年的时候,主要负责微服务治理平台BOMS交付工作,期间客户提到在微服务模块拆分方面需要咨询。

当时我还只是技术负责人,没有深入了解这方面的需求,只是网上找了点资料发给客户,再结合材料简单讲一下“正交分解”等原则。

后来听说客户找到埃森哲做培训,每天的培训费用大概是好几千,后面领导打算让我当架构师,可惜我拒绝了,现在想想非常可惜,但我并不是想做培训老师, 而是想能不能制作一款可视化的分析工具,给微服务模块拆分提供可量化的拆分依据,以及评估拆分前后的性能指标变化。

Spring依赖分析

我首先想到基于代码扫描的静态分析,得益于 Spring 的架构优势,做起来并不难,只需要把 Spring IoC 容器保存的 Bean 信息导出来即可,而正好 Spring Boot Actuator 模块刚好提供 /beans 接口用于导出Bean信息,Bean信息结构大概如下:

{
    "contexts": {
        "school-service": {
            "beans": {
                "spring.jpa-org.springframework.boot.autoconfigure.orm.jpa.JpaProperties": {
                    "aliases": [],
                    "scope": "singleton",
                    "type": "org.springframework.boot.autoconfigure.orm.jpa.JpaProperties",
                    "resource": null,
                    "dependencies": []
                }
            }
        }
    }
}

对于没有使用 Spring Boot 的老旧项目,以下就是参考 Actuator 源码实现的替代实现:

package com.springcloud.school;

import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;

@Component()
public class CustomBeansEndpoint {
@Autowired
ConfigurableApplicationContext context;

public ApplicationBeans beans() {
    Map contexts = new HashMap<>();

    for(ConfigurableApplicationContext context = this.context; context != null; context = getConfigurableParent(context)) {
        contexts.put(context.getId(), ContextBeans.describing(context));
    }

    return new ApplicationBeans(contexts);
}

static ConfigurableApplicationContext getConfigurableParent(ConfigurableApplicationContext context) {
    ApplicationContext parent = context.getParent();
    return parent instanceof ConfigurableApplicationContext ? (ConfigurableApplicationContext)parent : null;
}

public static class BeanDescriptor {
    @JsonProperty("aliases")
    String[] aliases;
    @JsonProperty("scope")
    String scope;
    @JsonProperty("type")
    Class type;
    @JsonProperty("resource")
    String resource;
    @JsonProperty("dependencies")
    String[] dependencies;

    private BeanDescriptor(String[] aliases, String scope, Class type, String resource, String[] dependencies) {
        this.aliases = aliases;
        this.scope = StringUtils.hasText(scope) ? scope : "singleton";
        this.type = type;
        this.resource = resource;
        this.dependencies = dependencies;
    }
}

public static class ContextBeans {
    @JsonProperty("beans")
    Map beans;
    @JsonProperty("parentId")
    String parentId;

    ContextBeans(Map beans, String parentId) {
        this.beans = beans;
        this.parentId = parentId;
    }

    static ContextBeans describing(ConfigurableApplicationContext context) {
        if (context == null) {
            return null;
        } else {
            ConfigurableApplicationContext parent = getConfigurableParent(context);
            return new ContextBeans(describeBeans(context.getBeanFactory()), parent != null ? parent.getId() : null);
        }
    }

    static Map describeBeans(ConfigurableListableBeanFactory beanFactory) {
        Map beans = new HashMap<>();
        String[] var2 = beanFactory.getBeanDefinitionNames();
        int var3 = var2.length;

        for(int var4 = 0; var4 < var3; ++var4) {
            String beanName = var2[var4];
            BeanDefinition definition = beanFactory.getBeanDefinition(beanName);
            if (isBeanEligible(beanName, definition, beanFactory)) {
                beans.put(beanName, describeBean(beanName, definition, beanFactory));
            }
        }

        return beans;
    }

    static BeanDescriptor describeBean(String name, BeanDefinition definition, ConfigurableListableBeanFactory factory) {
        return new BeanDescriptor(factory.getAliases(name), definition.getScope(), factory.getType(name), definition.getResourceDescription(), factory.getDependenciesForBean(name));
    }

    static boolean isBeanEligible(String beanName, BeanDefinition bd, ConfigurableBeanFactory bf) {
        return bd.getRole() != 2 && (!bd.isLazyInit() || bf.containsSingleton(beanName));
    }
}

public static class ApplicationBeans {
    @JsonProperty("contexts")
    Map contexts;

    ApplicationBeans(Map contexts) {
        this.contexts = contexts;
    }

    public Map getContexts() {
        return this.contexts;
    }
}

}

新建 CustomBeansEndpoint.java 文件,然后把以上Java代码复制粘贴进文件中,最后新增 BeanController

package com.springcloud.school.controller;

import com.springcloud.school.CustomBeansEndpoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController@RequestMapping("/beans")
public class BeanController {
    @Autowired()
    CustomBeansEndpoint beansEndpoint;
    @GetMapping("/")
    public Object beans() {
        return beansEndpoint.beans();
    }
}

把代码修改完再运行起来,在浏览器打开 https://localhost:8080/beans 就会返回JSON内容,然后复制JSON字符串保存到 beans.json 文件。

切记:为了稳妥起见,建议只在开发环境运行,不要提交或部署到生产/预生产环境,以免造成不可挽回的损失。

可视化分析

有了JSON数据,就可以进行可视化分析,首先去下载安装 Spring Module Analyzer 工具。

这是一款基于Qwik + Echarts 打造,专门用于Spring模块可视化分析的工具。目前该工具使用 tauri 打包成Web App,支持 Windows Mac Linux 等平台(暂不支持win7、xp),可以根据实际需求下载安装后进行使用。

打开工具后,在首页点击中间的 开始 按钮,选中上一步导出的 beans.json 文件,然后就自动跳转到 依赖分析 页面

看到满屏密密麻麻的文字先别慌,因为actuator导出的bean很多是spring框架自带的,可以在左上方 类名 右侧的输入框,输入你的 package 包名,然后点击最右侧的 过滤 按钮就可以把多余的bean过滤掉。比如输入 com.springclcoud 再点击 过滤 按钮:

现在可以看到不相干的bean已经被过滤掉,还可以通过鼠标滚轮来缩放画面

如果你的项目包含的bean数量非常多,可以追加子包名进行过滤,比如说 com.springcloud.school

随着包名的不断变长,原来连成一片的点集,会分割成几个相互独立的小点集,这些小点集就相当于划分好的微服务模块

以上基于代码的静态分析,实际上就是基于包名进行微服务模块划分,有一个非常重要的前提就是先前的项目结构需要非常合理,而实际情况却是大部分项目结构非常混乱,因此以包名作为微服务模块划分的依据并不合理。

但是有这么一个工具,可以非常直观了解现有的项目结构评估微服务模块拆分之前的代码质量还是很有帮助的,尤其是当你刚接手一个完全陌生的项目,spring 依赖分析是快速了解项目结构的有力工具。

调用链分析

前面说到的静态分析虽然非常直观,但明显缺乏量化指标。

比方说 A模块 同时依赖于 B模块C模块A模块 每分钟会调用 B模块 上百次,但只会调用 C模块 一次,这种情况静态分析是无法进行有效的模块划分,因此我们需要在运行时收集数据才能正确划分。

简单来说,就是通过 Spring AOP 给特定包下的所有方法织入一段代码逻辑,用于统计各模块之间相互调用的次数以及耗时,具体做法就是新建 Profile.java 文件,然后写入以下代码:

package com.springcloud.school;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.Stack;

@Aspect
@Component
public class Profile {
    // ThreadLocal用于解决线程安全问题,每个线程的方法栈是独占的,因此可以避免冲突
    ThreadLocal> methodStack = new ThreadLocal<>() {
        @Override
        protected Stack initialValue() {
            return new Stack<>();
        }
    };

    @Around("execution(* com.springcloud.school..*.*(..)) && !bean(profile)")
    public void count(ProceedingJoinPoint pj) {
        Stack stack = methodStack.get();
        String peak = stack.empty() ? "root" : stack.peek();
        String className = pj.getSignature().getDeclaringTypeName();
        stack.push(className);
        long start = System.currentTimeMillis();
        try {
            pj.proceed();
        } catch (Throwable e) {
            throw new RuntimeException(e);
        } finally {
            long end = System.currentTimeMillis();
            long gap = end - start;
            // 这里选择直接把调用记录输出到日志,有条件的可以选发送到消息队列进一步处理
            System.out.println("trace: " + peak + " -> " + className + " : " + gap);
            stack.pop();
        }
    }
}

解释一下统计调用次数以及耗时的思路:

首先初始化一个栈,然后每当方法被调用之前,就把方法名称入栈,方法调用结束后就出栈,然后把结果输出到日志文件或者消息队列,这里为了方便演示直接用 System.out.println。

这里记得把 @Around("execution(* com.springcloud.school..*.*(..)) && !bean(profile)")com.springcloud.school 换成你项目所用的包名,至于最右边的 !bean(profile) 就是把 Profile 类给排除,因为我把 Profile.java 也保存到 com.springcloud.school 下。

接着把修改后的代码放到环境上运行,然后跑一下全量接口测试,测试尽可能覆盖所有的业务模块,根据实际情况调整收集时长,等积累足够多的调用记录,就可以进行下一步:处理收集结果。

切记:为了安全起见,建议只在开发环境运行,不要提交或部署到生产/预生产环境,以免料

这里只说输出到日志的处理方法,首先根据前缀 trace: 过滤得到相关的调用记录,然后把每行的箭头 -> 和 冒号 : 替换成逗号 , ,然后保存到 trace.csv 文件,最终的文件结构如下:

com.springcloud.school.controller.BeanController,com.springcloud.school.CustomBeansEndpoint,10
root,com.springcloud.school.controller.BeanController,39
com.springcloud.school.service.BaseServiceImpl,org.springframework.data.repository.PagingAndSortingRepository,323

第一列是调用方,第二列是被调用方,第三列是耗时,三者通过逗号分割,只要符合这个规则的csv文件就可以被用于可视化分析。

因此微服务模块划分的粒度最小也是类,很少到方法级别,所以调用方被调用方其实是方法所属的类,而不是方法名。

可视化分析

我们重新打开之前的 Spring Module Analyzer 工具,然后点击右边 链路分析 按钮,再点击下方 开始 按钮,选择之前保存 trace.csv 文件,然后点击 确定 按钮

进入 链路分析 页面,左上角 起点终点 两个输入框,可以根据包名进行过滤,和之前的依赖分析大同小异

和前面不同的是多了 求值 下拉框,以及 范围 输入框。

“求值” 有五个选项:计数平均值求和最小值最大值 。以下是各选项适用的场景:

  • 计数 :就是累计两个模块之间的调用次数,可以配合调高范围的下界,来过滤掉某些出现次数很少的调用记录,从而分离出更多的点集,自动完成模块划分;也可以调低范围的上限,过滤某些因为循环或递归调用频繁出现的记录;
  • 平均值:就是用总的耗时除以总的调用次数,得出每次调用方法的平均耗时,可用于评估微服务模块划分前的实际延迟,一般平均耗时低于100毫秒的模块不宜拆分,因为微服务模块之间RESTful接口调用基本上都大于100毫秒,把原本平均耗时大于100毫秒的模块拆分到不同微服务并不会显著增加系统延迟;
  • 求和 :两个模块之间相互调用所需要的总耗时
  • 最小值 :两个模块之间相互调用耗时的最小值
  • 最大值 :两个模块之间相互调用耗时的最大值

总结

基于Spring依赖的可视化分析,可以快速大概掌握项目结构的基本情况,同时也能直观地感受代码架构是否符合 低耦合高内聚 的要求,对后续的代码重构优化也有一定的指导作用;在对项目结构有基本的了解后,就可以结合调用链分析,在微服务模块拆分之前,对系统整体的性能指标有确切的数据,也能通过和图表的交互,大致预估微服务模块拆分后的项目结构以及系统性能的变化。

我做这个工具的初衷,是因为职业生涯中,经常遇到空降的大厂架构师之类的人物,在没有充分了解项目现况的情况下,强行推行从别的企业生搬硬套的软件架构,结果往往导致项目进展受阻的情况;而熟悉项目的低级开发人员却没有任何发言权,但凡提出质疑总会被大厂光环的title压制。

这个可视化分析工具可以提供可量化的指标,可以打破领导对大厂经验的迷信,刹住行业的歪风邪气,让行业风气重新回到纯粹的理性探讨技术氛围。


zaoying
116 声望3 粉丝