☰
自定义TestableMock扩展:用MockProcessor实现批量与动态Mock
2026/10/1 12:06:00 网站建设 项目流程

很多人在最开始用TestableMock时,都会被它那种“不用写Mockito那套when/thenReturn,直接在测试类里定义同名方法就能改掉被测类内部调用”的体验吸引。但等你真的在遗留系统里大规模补测试,就会发现默认约定是有边界的:遇到“某个包下的所有方法我都要统一mock掉”、或者“不同测试方法对同一个方法需要返回不同结果”这种需求时,一条条加@MockMethod注解能把你累到怀疑人生。

我在维护一个老服务的时候就被这种需求卡过。那套代码里service层十几个类、几十个方法,全都依赖RPC、MQ和数据库,为了测上层controller的校验逻辑,我不得不让这些外部依赖在测试环境里集体“哑火”。一开始按TestableMock的标准玩法,给每个外部调用写对应的mock方法,写了满满两个文件还没写完。后来我直接翻了TestableMock源码,找到它的自定义扩展点MockProcessor,用ByteBuddy在类加载阶段统一改了字节码,这才真正解脱。

这篇文章就以这个需求为主线,从TestableMock处理Mock的完整链路讲起,带你看MockProcessor这个接口的定位和实现方法,然后分别用“批量mock所有public方法”和“根据测试方法动态返回结果”两个实战案例,把自定义Mock处理器从注册到调试的完整过程走一遍。文章代码基于TestableMock 1.3.x版本、ByteBuddy 1.10以上版本编写,如果你的版本较老,个别包名和API会有差异,但整体思路是通用的。

如果你也正在被大面积外部依赖折磨,或者想在TestableMock上做更底层的功能扩展,这篇文章应该能帮你省下不少时间。

1. 理解TestableMock的Mock链路:为什么默认约定会不够用

1.1 一个Mock从声明到生效的完整过程

TestableMock的底层核心是Java Agent加字节码改写。它在JVM启动时通过premain挂载进去,之后所有加载到JVM里的类都会先经过它的AgentBuilder。整个Mock过程可以拆成几步来看:

第一步,扫描测试类,收集mock声明。TestableMock默认会识别两类测试类:一类是测试类内部以Mock结尾的内部类,另一类是和被测类在同一个包下、以被测类名加Test后缀命名的外部类。这些类里的方法一旦带有@MockMethod、@MockConstructor、@MockFieldGet、@MockFieldSet这些注解,就会被记录成一条“原方法到mock方法”的映射关系。

第二步,等待被测类加载。当被测类真正要加载进JVM时,TestableMock会检查当前有没有和它相关的mock映射关系。如果有,就对该类的字节码进行转换。

第三步,改写调用点。TestableMock用ByteBuddy在被测类的方法体里定位那些需要mock的方法调用点,把它们从“调用原方法”改成“调用测试类里对应的mock方法”。这样测试运行到这些调用点时,就会直接进入你写的mock方法,真实的外部依赖根本不会被触发。

这个流程有一个非常关键的特征:TestableMock只处理已经注册了映射关系的类和方法。没有注解声明过的调用点,它一概不动。这也是默认约定最大的边界——你要mock哪个方法,就必须在测试类里先写一个对应的方法并打上注解。

1.2 TestableMock的扩展点在哪里:AgentBuilder和MockProcessor

如果你只是用TestableMock做日常mock,那就只需要关注注解和测试类怎么写。但一旦你理解了上面的链路,就会意识到一个事实:TestableMock的整个字节码转换流程,本质上是建立在ByteBuddy的AgentBuilder机制上的。

AgentBuilder是一个链式构建器,它主要干两件事:用type()筛选哪些类需要转换,再用transform()定义对这些类怎么转换。TestableMock本身也是在这个链子上挂自己的匹配规则和转换逻辑的。

MockProcessor这个扩展点,就是把这个AgentBuilder以参数的形式传给你,让你在TestableMock的转换逻辑基础上,再追加自己的转换规则。它的核心结构几乎只有一个方法:

public interface MockProcessor { AgentBuilder process(AgentBuilder agentBuilder); }

这个方法会在TestableMock启动阶段被调用,入参是已经组装好默认规则的AgentBuilder,返回值是最终生效的AgentBuilder。你在里面做的事,本质上就是在原有规则上继续链式追加type(...)和transform(...)。

这里要注意一个很容易误解的点:process方法返回值不是让你重造一个新的AgentBuilder,而是基于传入的builder做装饰。如果你直接返回一个新的AgentBuilder,TestableMock默认的mock转换逻辑可能就丢了。正确的姿势是在传入的builder上继续追加规则。

1.3 什么时候真的需要写自定义Mock处理器

搞清楚扩展点的位置之后,下一个问题是:什么情况才值得动这个接口?我的经验是,出现下面这三类需求时,别犹豫,直接上MockProcessor:

第一类是批量拦截。比如某个包下的所有service、所有feign客户端、所有Repository,测试时统一返回默认值。你用注解一个个加要写几十上百个方法,用MockProcessor匹配包名前缀一次性搞定。

第二类是条件化mock。同一个方法,在测试A里要返回null,在测试B里要返回一个特定对象,在测试C里甚至要抛异常。这种动态差异用注解很难优雅表达,但在MockProcessor里结合运行时上下文就可以灵活控制。

第三类是TestableMock默认规则覆盖不到的字节码修改。TestableMock对类加载过程的干预是围绕“替换方法调用”这个目标设计的,但如果你想给某个类的所有方法都加一段耗时统计,或者想把某个第三方SDK的final方法改成非final来绕过限制,那就必须自己动手操作AgentBuilder了。

2. 自定义MockProcessor入门:接口、SPI注册和第一个Demo

2.1 MockProcessor接口让你操作的是什么

前面已经说了,MockProcessor方法签名极其简单,但简单不代表力量弱。它把你放到了和TestableMock几乎同等的地位上:你能看到它看到的全部类加载事件,也能对任何类做任何ByteBuddy支持的结构修改。

不过正因为力量大,理解“你在操作什么”就格外重要。AgentBuilder处理的对象是类加载事件,type()匹配器决定了哪些类会被纳管,transform()则负责把原始字节码的DynamicType.Builder改造成新字节码。这两个环节做得好,才是真正有效的自定义mock处理器。

举个例子,如果你想对所有继承自BaseService的类做处理,匹配器可以这样写:

.type(hasSuperType(named("com.example.BaseService")))

而如果你想在某个类加载时把它的所有public方法都返回null,转换器则可以这样写:

.transform((builder, typeDescription, classLoader, module) -> builder.method(isPublic()).intercept(FixedValue.nullValue()))

理解这个逻辑之后,自定义MockProcessor就不再是什么神秘的事了,本质上就是组合使用ByteBuddy的ElementMatchers和Implementation。

2.2 SPI注册细节:文件名、内容与加载顺序

接口本身不复杂,真正容易栽跟头的是注册过程。TestableMock运行在Java Agent上下文中,它无法识别Spring的扫描机制,也不能直接手工实例化你的类,而是采用JDK内置的SPI机制来发现所有实现类。

SPI注册需要做两件事。第一,在src/main/resources/META-INF/services/目录下新建一个文件,文件名必须是com.alibaba.testable.agent.tool.MockProcessor。第二,文件内容写入你的实现类的全限定类名,比如:

com.example.mockext.MyFirstMockProcessor

注意,如果有多行,每个实现类名占一行,文件末尾最好保留一个换行符。这个细节平时没人提,但真出了问题会很恶心。JDK的ServiceLoader按行读取类名,如果最后一行没有换行,某些实现会把后续的残留字符拼进类名里,然后抛一个莫名其妙的ClassNotFoundException。

SPI的加载顺序遵循文件内从上到下的顺序,ServiceLoader会依次实例化每个实现类并调用process方法。如果你的工程里有多个MockProcessor实现,它们的执行顺序就是文件里写的顺序,这一点在后面组合多个处理器时特别重要。

2.3 第一个可运行的处理器Demo

先别急着写复杂的转换逻辑,我建议第一步先写一个“只会打印日志”的最小处理器,用来验证SPI链路是否走通,顺便感受一下AgentBuilder整个处理过程。代码如下:

package com.example.mockext; import com.alibaba.testable.agent.tool.MockProcessor; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.description.type.TypeDescription; import net.bytebuddy.dynamic.DynamicType; import net.bytebuddy.utility.JavaModule; import static net.bytebuddy.matcher.ElementMatchers.any; public class FirstMockProcessor implements MockProcessor { @Override public AgentBuilder process(AgentBuilder agentBuilder) { System.out.println("[TestableMock] custom MockProcessor started"); return agentBuilder .type(any()) .transform(FirstMockProcessor::onClassLoad); } private static DynamicType.Builder<?> onClassLoad(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) { System.out.println("[TestableMock] seeing class: " + typeDescription.getTypeName()); return builder; } }

这个处理器对每个加载进来的类都打印一行日志,但完全不做修改,所以它是绝对安全的。跑一个测试用例,如果控制台能看到custom MockProcessor started和一系列seeing class的日志,说明SPI注册成功,链路已通。

如果你的ByteBuddy版本较新,transform还可能有5参数甚至6参数的重载,多出来的参数是ProtectionDomain和JavaModule之类的信息,不需要时可以忽略。

2.4 如何确认字节码真的被改过

日志可以证明处理器被调用过,但如果你想确认某个具体类的某个方法确实被改掉了,靠肉眼在控制台找日志效率太低。ByteBuddy提供了一个比较实用的调试手法:把转换后的类字节码dump到本地文件,再用javap反编译查看。

实现方式是在transform里手动写文件。比如想把com.example.service.OrderService这个类的字节码保存下来,可以在处理该类时写入指定路径:

private static DynamicType.Builder<?> onClassLoad(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule module) { if (typeDescription.getTypeName().equals("com.example.service.OrderService")) { try { byte[] bytes = builder.make().getBytes(); Files.write(Paths.get("/tmp/classes/OrderService.class"), bytes); } catch (IOException e) { throw new IllegalStateException(e); } } return builder; }

当然这只适用于确实需要亲眼确认底层字节码的场景。一般验证mock行为是否生效,最直接的方式还是写一个@Test调用被测方法,看返回值和副作用是否符合预期。字节码dump更适合排查一些“明明处理器触发了,但行为不对”的疑难杂症。

3. 实战:批量Mock指定包下的所有public方法

3.1 需求背景和方案设计

回到我碰到的真实问题:一个老服务,controller层需要写单元测试,但service层的十几个类全部依赖外部RPC、消息队列和数据库Mapper。写测试时我根本不想让service的真实逻辑被触发,只想让它们全部返回默认值。

方案可以设计成这样:写一个MockProcessor,匹配com.example.service包下的所有类,把它们所有public实例方法都替换成StubMethod。所谓StubMethod是ByteBuddy内置的一种实现,它的作用就是“返回当前返回类型的默认值”——String返回null,int返回0,boolean返回false,void直接结束方法。

这样一来,我只需要正常调用controller层,当它调用到service方法时,方法体内部不再执行真实逻辑,而是直接返回默认值,整个测试就可以聚焦在controller的入参校验、结果封装和异常处理上。

3.2 核心处理器代码与逐行解释

下面是这个批量mock处理器的具体实现:

package com.example.mockext; import com.alibaba.testable.agent.tool.MockProcessor; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.implementation.StubMethod; import static net.bytebuddy.matcher.ElementMatchers.*; public class BatchServiceMockProcessor implements MockProcessor { private static final String SERVICE_PACKAGE = "com.example.service"; @Override public AgentBuilder process(AgentBuilder agentBuilder) { return agentBuilder .type(nameStartsWith(SERVICE_PACKAGE)) .transform((builder, typeDescription, classLoader, module) -> { System.out.println("[TestableMock] processing: " + typeDescription.getTypeName()); return builder .method(not(isConstructor()) .and(isPublic()) .and(not(isDeclaredBy(Object.class)))) .intercept(StubMethod.INSTANCE); }); } }

逐行解释一下几个关键点:

nameStartsWith(SERVICE_PACKAGE)负责匹配包名,这里匹配的是com.example.service及其子包下所有类。因为类加载时TypeDescription记录的是全限定类名,这个方法可以捕获到com.example.service.OrderService,也能捕获到com.example.service.impl.OrderServiceImpl。

not(isConstructor())排除构造函数。构造函数不能用StubMethod拦截,强行拦截会干扰对象实例化,轻则对象完全没初始化,重则直接抛异常。实际项目中如果连构造方法都想mock,那应该用另外一套方案,而不是盲目替换。

and(isPublic())表示只处理public方法,因为controller层调用service的入口基本是public方法;protected和private方法保留原样,避免误伤内部辅助逻辑。

not(isDeclaredBy(Object.class))这个条件是我吃过亏之后才加上的。如果不加,Object类里的toString()、hashCode()、equals()这些公共方法也会被拦截。它们被替换成默认值之后,后续很多依赖对象字符串输出的断言会变得极为诡异,排查起来非常费劲。

StubMethod.INSTANCE前面说过了,它就是返回类型默认值实现。这里不要用FixedValue.nullValue(),后者遇到基本类型返回时会报错,StubMethod则处理得干净利落。

3.3 边界条件:构造函数、Object方法和基本类型返回值

批量替换看起来简单,实际做起来边界条件不少。上面代码里排除了构造函数和Object方法,这两类是最大的坑。

构造函数还有一个坑是:如果你的匹配规则是isPublic(),它也会把public的构造函数匹配进去,所以构造函数排除必须显式写出来。我在最初版本里忘写了,结果所有service类实例化时报错,错误信息里还找不大到明确原因,花了很长时间才定位到是MockProcessor把构造过程改坏了。

基本类型返回值也需要特别留意。假设某个service方法签名是int getCount(),用StubMethod会返回0;签名是boolean isReady(),会返回false;签名返回void,直接结束方法。这些默认值虽然不会抛异常,但可能改变被测方法的逻辑分支。比如原方法执行到if (service.isReady())时,本来是true,现在变成false,上层代码可能直接走了else分支。所以批量mock不是无脑操作,你心里必须清楚被测代码的依赖逻辑,否则测试可能“顺利通过”,但测的其实是一条完全错误的分支。

如果某些方法确实不能默认返回,可以在处理器里把这些方法排除掉,继续走真实调用。匹配条件写成这样:

.method(not(isConstructor()) .and(isPublic()) .and(not(named("isReady"))) .and(not(named("getCount"))))

这种“默认全mock,特定方法放行”的做法,比单独给每个方法写mock注解要清晰得多。

3.4 效果验证和后续演化

处理器写完注册后,我在controller测试里做了一次快速验证:正常注入OrderController,调用它的创建订单接口。在没有这个处理器时,接口会一路调用到RPC,测试直接超时;加上处理器后,service层所有public方法变成默认返回,controller可以顺利完成它的校验逻辑和返回结构组装。

这里要提醒一句,控制器测试里往往依赖Spring上下文的正常启动,而Spring容器需要创建那些service的实例。因为StubMethod只替换方法的实现,类的构造过程和字段注入依然是正常的,所以Spring不会因为mock处理器而启动失败。这一点是批量Mock处理器和手动mock方案相比的最大优势——不用动容器的组装方式,只改方法行为。

后续我把这个处理器的匹配范围做了细分,把包含rpc、mapper、mq这些关键词的包一并纳入:

.type(nameStartsWith("com.example.service") .or(nameStartsWith("com.example.rpc")) .or(nameStartsWith("com.example.mapper")))

在实际项目中,这种按业务模块划分的匹配规则比单一包名更实用。

4. 进阶:结合MockContext动态决定Mock结果

4.1 固定返回值解决不了的问题

批量Mock处理器能把所有方法变成默认值,但很多东西不是默认值能满足的。比如一个service方法返回Order对象,controller拿到null之后直接NPE,连后面的校验逻辑都走不到。这时候你没法简单地把所有方法都stub掉,必须让某些方法在特定测试条件下返回特定对象。

典型的需求是:测试A里getOrderById(100L)返回一个id为100的Order,其余情况返回null;测试B里这个接口反而要抛异常,用来验证controller的错误处理分支。这种需求用纯静态的StubMethod和FixedValue都实现不了,只能把intercept逻辑下沉到运行期,让代码在每次方法被调用时都看一眼当前是不是站在期待的测试场景里。

4.2 MethodDelegation和Dispatcher模式

把逻辑放到运行期的办法是ByteBuddy的MethodDelegation。它可以把被拦截的方法委托给另一个“Dispatcher”类的静态方法去执行,然后由Dispatcher返回结果。这样就能在Dispatcher里写完整的分支判断了。

先看核心代码。假设需要一个Dispatcher类:

package com.example.mockext; import net.bytebuddy.implementation.bind.annotation.*; import java.lang.reflect.Method; import java.util.concurrent.Callable; public class SmartDispatchDispatcher { @RuntimeType public static Object dispatch(@Origin Method method, @AllArguments Object[] args, @SuperCall Callable<?> callable) throws Exception { // 这里可以读取测试上下文,决定是走原方法还是返回mock值 return callable.call(); } }

@RuntimeType注解允许Dispatcher的返回类型在运行时根据实际返回值的需求做强制转换,这样不管目标方法返回的是Order、String还是boolean,都能被同一个Object类型的入口方法接住。@Origin注入目标方法信息,@AllArguments注入当前调用的参数数组,@SuperCall则代表原始方法的调用入口。

然后在MockProcessor里,把之前的StubMethod.INSTANCE换成MethodDelegation.to(SmartDispatchDispatcher.class):

.overrideMethod .intercept(MethodDelegation.to(SmartDispatchDispatcher.class))

这样每次目标方法被调用时,都会先进到dispatch方法里,由Dispatcher决定是返回mock值还是继续走原逻辑。

4.3 在Dispatcher中读取测试上下文

要真正做到“根据当前测试方法动态返回”,Dispatcher里需要能拿到当前测试方法的上下文。TestableMock提供了MockContext工具类,它在运行时持有当前线程正在执行的测试方法信息。

不同版本的TestableMock暴露的方法名略有差异,我在1.3.x版本里常用的是获取当前测试方法名的方式,大致形如:

String currentTestName = MockContext.currentTestName();

拿到测试方法名之后,就可以做分支判断了。比如测试方法名称里带shouldMockRpc,就让某些方法返回固定对象;否则放行原方法:

@RuntimeType public static Object dispatch(@Origin Method method, @AllArguments Object[] args, @SuperCall Callable<?> callable) throws Exception { String testName = MockContext.currentTestName(); if (testName != null && testName.contains("shouldMockRpc")) { Class<?> returnType = method.getReturnType(); if (returnType == Order.class) { return buildMockOrder(); } return null; } return callable.call(); }

这样同一个被mock方法,在不同测试里就能产生完全不同的结果。而且因为@SuperCall的存在,不满足mock条件时还能继续执行原方法,灵活性比注解固定替换高了一个层次。

4.4 多个处理器的叠加规则与优先级陷阱

组合使用多个MockProcessor时,优先级问题是一个隐藏的大坑。

我在项目里一开始把“批量返回默认值”和“特定方法返回特定对象”拆成了两个处理器,SPI文件里写了两个类名:

com.example.mockext.BatchServiceMockProcessor com.example.mockext.SpecificServiceMockProcessor

跑测试之后发现,特定方法返回特定对象的逻辑完全没有生效。原因在于ByteBuddy的AgentBuilder链式规则是“先add的Transformer先执行”,先注册的批量处理器匹配到方法并替换成了StubMethod,后注册的特定处理器哪怕又匹配到了同一个方法,ByteBuddy也不会再去覆盖已存在的intercept定义。

这个“先到先得”的机制很容易让人栽跟头。解决方案有两个思路。

第一种是合并处理器,把默认返回和定向返回都放进同一个SmartDispatchDispatcher里,用Dispatcher内的分支条件去区分,而不是用多个处理器去分层覆盖。这是我最推荐的方式,逻辑集中,优先级明确。

第二种是用AgentBuilder.RedefinitionStrategy等方式手动控制规则优先级,但这种方式在AgentBuilder链上写起来比较绕,而且一旦引入多个处理器,可读性会很差。如果不需要复杂的规则组合,还是优先选择方案一。

5. 避坑指南:我把MockProcessor用进生产环境后的经验

5.1 处理器不生效的排查链路

自定义处理器写了没反应,是最常见的症状。我总结了一套固定的排查链路,每一步都能定位一个大方向。

第一步,确认TestableMock本身工作正常。先写一个最简单的@MockMethod场景跑通,如果连标准玩法都没生效,说明Agent根本没挂载成功,这时候跟自定义处理器无关,你需要回去检查-javaagent参数和依赖配置。

第二步,确认SPI文件在classpath里。很多IDE不会自动把src/main/resources下的SPI文件同步到target目录,你可以直接去target/classes里的META-INF/services目录看一眼,有没有对应的文件名。

第三步,确认类名拼写和全限定名路径。文件里写的是内部类时,$符号不能省略;写的是普通类时,包路径不能出错。

第四步,在process方法第一行加打印。如果打印没出现,说明ServiceLoader压根没加载到你这个类;如果打印出现了但转换没生效,那就在transform里加打印,确认目标类有没有匹配上。

我遇到的情况里,80%都卡在第一步和第二步。SPI注册的失败是“静默失败”的,没有异常没有警告,只能靠这些探针打印去定位。

5.2 不要破坏TestableMock自身的转换规则

MockProcessor的能力是让你能在AgentBuilder上追加规则,但这不意味着你可以无所顾忌地针对所有类做转换。

TestableMock能实现mock调用,是因为它内部维护了被测类方法调用点和mock方法之间的映射。如果你在自定义处理器里对TestableMock的某些内部辅助类也做了大范围修改,比如把所有方法都Stub掉、把构造方法替换掉,那么TestableMock自身的代理逻辑可能直接崩掉,运行时报VerifyError或NoSuchMethodError,错误信息还极其隐晦,很难排查到真正原因。

我的经验是:type匹配范围必须尽量限定在你的业务包或被测包内,不要轻易使用any()或者覆盖到框架类、TestableMock内部类。你想对整个JVM做观测性处理时,反而适合写普通的ByteBuddy Agent,而不是借TestableMock的扩展点。

5.3 性能陷阱:大范围字节码转换的代价

这种东西看着很酷,但性能问题从来不缺席。每个类加载时都会经过AgentBuilder的type匹配,如果你的匹配器写得特别重,比如用了复杂的正则判断、或者对大量第三方类都做了transform,整个测试套件的启动时间会明显变长。

我自己的优化方法是两条。第一,把type匹配提到最前面,用nameStartsWith这样的前缀匹配代替复杂的matches正则。前缀匹配在ByteBuddy内部有优化,执行速度远快于正则。第二,如果确实需要broadcast式的处理,缩小范围到真正需要处理的少数包,不要把所有第三方依赖都纳入transform。

在一次实测里,我把匹配范围从any()改成nameStartsWith(SERVICE_PACKAGE),测试启动时间从三十多秒降到七八秒,差距非常大。这个数据足够说明问题了。

5.4 版本兼容性:ByteBuddy API变迁

自定义MockProcessor会直接依赖ByteBuddy的API,而TestableMock在不同版本里捆绑的ByteBuddy版本不同,API签名也在持续变化。

比如AgentBuilder.Transformer接口,在较早的ByteBuddy版本里transform方法只接受4个参数(builder、typeDescription、classLoader、module),后来增加了带ProtectionDomain的重载。如果你在TestableMock 1.2.x上写的处理器,升到1.3.x后突然编译不过,多半是API签名变了。

运行时出现AbstractMethodError则是另一种情况:编译期用的ByteBuddy接口和运行期实际加载的ByteBuddy接口不一致。这时候要排查是不是某个依赖模块里弓藏了不同版本的ByteBuddy,统一版本号即可解决。

升级TestableMock版本后,跑一遍所有测试,特别是有自定义MockProcessor的用例。这类代码离底层太近,光有编译通过不代表运行没问题。

写在最后

这套自定义MockProcessor玩法,我在实际项目里用了差不多半年,最大的收获是真正理解了TestableMock只是把门打开了,具体能改多深还是看你愿不愿意往底层走。它本质上和Mockito是两条完全不同的技术路线,一旦你掌握了AgentBuilder的操作,眼前那些“批量的、动态的、不规则的”mock需求就不再是需求了,只是你处理器里几行匹配条件而已。

最后分享一个落地经验:如果你在多个项目里都用了自定义MockProcessor,建议单独抽一个mock-ext模块,所有MockProcessor实现和SPI文件统一放里面,业务项目只依赖这个模块。我第一次没抽模块,第二个项目要复用时只能复制粘贴,后来发现两个项目里逻辑已经开始分叉,维护成本立刻上去了。抽成独立模块之后,升级、回归、排查都轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询