news 2026/8/28 10:37:39

JMeter自定义函数开发指南:从原理到实战,打造专属性能测试工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JMeter自定义函数开发指南:从原理到实战,打造专属性能测试工具

1. 从“拿来主义”到“自给自足”:为什么我们需要自定义JMeter函数

在性能测试领域,JMeter几乎是绕不开的工具。无论是测试工程师、开发人员还是运维,都或多或少用它来模拟用户请求,给系统“施压”。用久了,你会发现JMeter内置的函数助手(Function Helper)非常强大,从生成随机数、处理时间戳到解析JSON,应有尽有。但不知道你有没有遇到过这样的场景:你需要一个特定的业务ID生成规则,它由“日期+流水号+特定业务编码”组成,内置函数组合起来异常繁琐;或者,你需要从某个复杂的响应头里,提取一个经过特定加密算法处理过的值,内置的__regexFunction__XPathFunction根本无从下手。

这时候,大多数人的第一反应是:写个BeanShell脚本或者Groovy脚本,在JSR223 Sampler里硬编码实现。这当然能解决问题,但代价是脚本逻辑散落在各个Sampler中,难以复用和维护。一旦生成规则或加密算法需要调整,你得满世界找这些脚本,修改、测试,效率低下且容易出错。

自定义JMeter函数,就是为了解决这个痛点。它允许你将一段特定的、可复用的逻辑,封装成一个像__time__Random一样可以直接在测试计划的任何地方调用的“黑盒”。你不再需要关心内部实现,只需要知道函数名和参数,就能获得计算结果。这不仅仅是代码的封装,更是测试资产(Test Asset)的沉淀。一个设计良好的自定义函数,可以成为团队甚至整个公司的性能测试基础设施的一部分,极大地提升脚本的标准化程度和执行效率。

从更深层次看,掌握自定义函数开发,意味着你从JMeter的“使用者”进阶为“扩展者”。你能更深入地理解JMeter的插件体系、类加载机制和函数执行上下文。下次当你再遇到“JMeter为什么报这个奇怪的ClassNotFound错误”或者“我的变量怎么在这个线程里取不到值”时,你对问题的排查会更有章法,因为你已经窥见了工具内部的一角。

2. 解剖一个JMeter函数:核心接口与执行机制

在动手写代码之前,我们必须先搞清楚JMeter函数到底是什么。很多人以为它就是个简单的工具方法,其实不然。在JMeter的世界里,函数是一个实现了特定接口、能被JMeter引擎在运行时识别和调用的组件。

2.1 心脏:org.apache.jmeter.functions.AbstractFunction

所有自定义函数的起点,都是继承这个抽象类。它定义了函数必须实现的几个核心方法,理解它们的作用至关重要:

  • getArgumentDesc(): 返回一个FunctionParameter数组,用于描述函数的参数。这是函数对外公布的“说明书”,JMeter的GUI界面(比如在函数助手中)会读取这个信息,生成对应的参数输入框。你需要为每个参数指定名称、描述、是否必填、默认值(如果有)等。
  • getReferenceKey(): 返回函数的“关键字”。这就是你在测试计划中调用时使用的名字,例如__time函数的关键字就是time。通常,我们会用__(双下划线)作为前缀来调用,但关键字本身不带这个前缀。命名最好具有业务含义,比如__genOrderId
  • setParameters(Collection<CompoundVariable> parameters): JMeter引擎在解析测试计划时,会把用户输入的参数值传递进来。这个方法就是用来接收和存储这些参数的。参数是以CompoundVariable对象的形式传入的,你需要在这里将它们解析并存储到类的成员变量中,供后续执行时使用。
  • execute(SampleResult previousResult, Sampler currentSampler): 这是函数的“执行引擎”。当JMeter运行到需要计算该函数的地方时,就会调用这个方法。previousResult是前一个采样器的结果(可能为null),currentSampler是当前正在执行的采样器。这个方法返回的String,就是函数的计算结果,会直接替换掉测试计划中${__yourFunction(...)}这样的占位符。
  • getClassDoc()getInstanceDoc(): 用于生成函数的帮助文档。虽然不是运行必需,但对于团队协作和后期维护非常友好。

2.2 血液:CompoundVariable与参数解析

用户传递给函数的参数,在JMeter内部被封装成CompoundVariable对象。这个类很关键,因为它能处理JMeter的变量嵌套。比如,用户可能传递${userName}_${date}这样的字符串,其中userNamedate本身也是JMeter变量。CompoundVariableexecute()方法可以自动解析这种嵌套,返回最终的计算值。

在你的setParameters方法中,典型的处理逻辑是这样的:

@Override public void setParameters(Collection<CompoundVariable> parameters) throws InvalidVariableException { // 检查参数个数,可以在这里做基本校验 checkParameterCount(parameters, 2, 3); // 例如,期望2到3个参数 // 将参数集合转换为数组,方便按索引获取 CompoundVariable[] params = parameters.toArray(new CompoundVariable[0]); // 解析第一个参数(例如:前缀) this.prefix = params[0].execute(); // 解析第二个参数(例如:序列号长度) // 注意:用户输入的是字符串,我们需要转换成整数 try { this.length = Integer.parseInt(params[1].execute()); } catch (NumberFormatException e) { throw new InvalidVariableException("第二个参数必须是一个有效的整数", e); } // 如果有第三个可选参数... if (params.length > 2) { this.suffix = params[2].execute(); } }

注意execute()方法在这里被调用,是为了立即解析参数中的变量引用。但有时你可能希望将CompoundVariable对象原样保存,在execute()方法中再根据运行时上下文解析,这取决于你的业务逻辑是否需要最新的变量值。

2.3 舞台:SampleResultSampler上下文

execute方法接收的两个参数提供了宝贵的运行时信息:

  • SampleResult previousResult: 包含了前一个采样器(Sampler)的执行结果,比如响应数据、响应时间、状态码等。如果你的函数需要基于上一个请求的响应来生成值(例如,提取上一个登录接口返回的token),这个对象就是金矿。
  • Sampler currentSampler: 当前正在执行的采样器对象。通过它可以获取到当前线程的上下文信息,比如线程组变量、属性等。虽然更常见的做法是通过JMeterContextService.getContext()来获取全局的JMeterContext,但currentSampler提供了一个直接的入口。

理解这个执行上下文,是写出健壮函数的关键。例如,你的函数如果依赖某个JMeter属性(Property),你需要确保它在函数执行时是可用的,并且要考虑属性作用域(全局 vs 线程组)的问题。

3. 实战:手把手开发一个业务订单ID生成函数

理论说得再多,不如一行代码。假设我们有一个经典需求:生成一个符合公司内部规范的订单ID,格式为业务前缀 + 年月日 + 6位随机数。例如,电商订单可能是EC20231015_583492

3.1 项目环境搭建与依赖配置

首先,我们创建一个标准的Maven项目。JMeter自定义函数本质上是一个Java项目,最终需要打包成一个JAR文件,放到JMeter的lib/ext目录下。

1. 创建Maven项目:使用你喜欢的IDE(如IntelliJ IDEA或Eclipse)创建一个新的Maven项目,groupIdartifactId可以自定义,例如com.yourcompany.jmetercustom-functions

2. 关键依赖:pom.xml中,唯一必须的依赖就是JMeter的核心库。这里有一个至关重要的坑:你必须使用与你运行的JMeter版本完全一致的依赖版本,否则极有可能出现类兼容性问题。

<dependencies> <!-- 核心依赖:版本号必须与你的JMeter版本一致 --> <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_core</artifactId> <version>5.6.2</version> <!-- 请替换为你的JMeter版本 --> <scope>provided</scope> <!-- 设为provided,因为JMeter运行时已经提供了 --> </dependency> <!-- 如果你需要用到JMeter的其他模块,如http、jdbc等,也需要相应添加 --> <!-- <dependency> <groupId>org.apache.jmeter</groupId> <artifactId>ApacheJMeter_http</artifactId> <version>5.6.2</version> <scope>provided</scope> </dependency> --> </dependencies>

scope设置为provided,意味着Maven在打包时不会将这个依赖打进最终的JAR里,因为JMeter在启动时已经加载了这些类。这可以避免JAR包冲突和臃肿。

3. 打包配置:我们需要将编译好的类和资源文件打包成一个JAR。

<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>8</source> <!-- 根据你的JMeter版本选择Java版本,5.x+通常需要Java 8+ --> <target>8</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.2.0</version> <configuration> <archive> <!-- 添加META-INF/services配置,这是让JMeter自动发现函数的关键! --> <manifestEntries> <Implementation-Title>Custom JMeter Functions</Implementation-Title> <Implementation-Version>1.0.0</Implementation-Version> </manifestEntries> </archive> </configuration> </plugin> </plugins> </build>

3.2 核心代码实现:OrderIdFunction

现在,我们来编写函数的核心类。

package com.yourcompany.jmeter.functions; import org.apache.jmeter.engine.util.CompoundVariable; import org.apache.jmeter.functions.AbstractFunction; import org.apache.jmeter.functions.InvalidVariableException; import org.apache.jmeter.samplers.SampleResult; import org.apache.jmeter.samplers.Sampler; import org.apache.jmeter.threads.JMeterContext; import org.apache.jmeter.threads.JMeterContextService; import java.text.SimpleDateFormat; import java.util.*; /** * 自定义函数:生成业务订单ID * 格式:{前缀}{年月日}_{6位随机数} * 示例:__genOrderId(EC) -> EC20231015_583492 */ public class OrderIdFunction extends AbstractFunction { // 1. 定义函数关键字 private static final String KEY = "genOrderId"; private static final List<String> DESC = Arrays.asList( "生成格式为 {前缀}{年月日}_{6位随机数} 的业务订单ID" ); // 2. 定义参数描述 private static final int MIN_PARAMETER_COUNT = 1; private static final int MAX_PARAMETER_COUNT = 2; private static final FunctionParameter[] PARAM_DESC = new FunctionParameter[] { new FunctionParameter("业务前缀", "订单的业务类型前缀,如 EC(电商), OA(办公)", true, null), new FunctionParameter("随机数长度", "随机数字部分的长度,默认为6", false, "6") }; // 3. 成员变量,用于存储解析后的参数值 private String businessPrefix; private int randomLength = 6; // 默认值 private final Random random = new Random(); // 4. 实现抽象方法 @Override public String getReferenceKey() { return KEY; } @Override public List<String> getArgumentDesc() { return DESC; } @Override public void setParameters(Collection<CompoundVariable> parameters) throws InvalidVariableException { // 检查参数个数 checkParameterCount(parameters, MIN_PARAMETER_COUNT, MAX_PARAMETER_COUNT); CompoundVariable[] params = parameters.toArray(new CompoundVariable[0]); // 解析第一个参数:业务前缀 this.businessPrefix = params[0].execute(); // 立即执行,获取前缀值 // 解析第二个参数:随机数长度(可选) if (params.length > 1) { try { this.randomLength = Integer.parseInt(params[1].execute()); if (this.randomLength <= 0) { throw new InvalidVariableException("随机数长度必须为正整数"); } } catch (NumberFormatException e) { throw new InvalidVariableException("第二个参数(随机数长度)必须是一个有效的整数", e); } } } @Override public String execute(SampleResult previousResult, Sampler currentSampler) throws InvalidVariableException { // 生成日期部分 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); String dateStr = dateFormat.format(new Date()); // 生成指定位数的随机数字字符串 StringBuilder randomNumBuilder = new StringBuilder(); int maxBound = (int) Math.pow(10, randomLength); // 例如,长度为6,上限是1000000 int randomInt = random.nextInt(maxBound); String formatStr = "%0" + randomLength + "d"; // 格式化为指定位数,不足补零 String randomNumStr = String.format(formatStr, randomInt); // 拼接最终订单ID return businessPrefix + dateStr + "_" + randomNumStr; } // 5. 辅助方法:检查参数个数 private void checkParameterCount(Collection<CompoundVariable> parameters, int min, int max) throws InvalidVariableException { int size = parameters.size(); if (size < min || size > max) { throw new InvalidVariableException("函数 " + KEY + " 期望接收 " + min + " 到 " + max + " 个参数,但收到了 " + size + " 个。"); } } }

代码要点解析:

  1. 参数校验:在setParameters中,我们不仅检查了参数数量,还对第二个参数进行了类型转换和有效性校验(必须为正整数)。这是生产级代码必备的健壮性考虑。
  2. 立即执行 vs 延迟执行:对于businessPrefix,我们调用了params[0].execute()立即解析。这意味着如果用户传入的是${PREFIX},那么这里存储的就是PREFIX这个属性或变量当前的值。如果希望前缀在每次函数执行时都重新解析(比如前缀本身也是一个变量),可以存储CompoundVariable对象,在execute方法中再调用其execute()
  3. 随机数生成:我们使用java.util.Random,并利用String.format进行前导零补全,确保生成的随机数长度固定。这在需要定长ID的场景下很重要。
  4. 线程安全Random实例是类的成员变量。在JMeter多线程环境下,Random是线程安全的吗?java.util.Random的实例方法(如nextInt)是线程安全的,但可能因为内部种子竞争导致性能下降。对于高并发压测场景,可以考虑使用ThreadLocalRandom.current(),它为每个线程维护独立的随机数生成器,性能更优。

3.3 让JMeter自动发现你的函数:Services机制

这是最关键也是最容易出错的一步。JMeter使用Java的SPI(Service Provider Interface)机制来动态加载函数。你需要创建一个特定的配置文件。

  1. 在项目的src/main/resources目录下,新建一个文件夹:META-INF/services
  2. 在该文件夹内,新建一个文件,名为:org.apache.jmeter.functions.Function
  3. 在这个文件里,写上你的函数类的全限定名,每行一个。
    com.yourcompany.jmeter.functions.OrderIdFunction
    如果你有多个函数,就写多行:
    com.yourcompany.jmeter.functions.OrderIdFunction com.yourcompany.jmeter.functions.EncryptFunction com.yourcompany.jmeter.functions.DecryptFunction

这个文件的作用是告诉JMeter:“嘿,我这里有这些实现了Function接口的类,启动的时候记得加载一下。”如果没有这个文件,或者文件名、文件内容写错了,你的函数在JMeter的界面上就根本找不到。

3.4 打包、部署与验证

  1. 打包:在项目根目录运行mvn clean package。成功后,在target目录下会生成一个类似custom-functions-1.0.0.jar的文件。
  2. 部署:将这个JAR文件复制到你的JMeter安装目录下的lib/ext文件夹中。注意:是lib/ext,不是libext目录是JMeter专门用于加载扩展插件的地方。
  3. 重启JMeter:必须重启JMeter,新的函数才会被加载。
  4. 验证
    • 打开JMeter,在测试计划中,添加一个Debug Sampler
    • Debug Sampler的某个字段(比如URL)中,输入${__genOrderId(EC)}
    • 添加一个View Results Tree监听器,运行测试计划。
    • 查看Debug Sampler的结果,你应该能看到生成的订单ID,如EC20231015_583492
    • 你还可以在“函数助手对话框”(Options->Function Helper Dialog)中,找到你的genOrderId函数,并通过GUI界面来使用它,输入参数并生成函数调用字符串。

4. 进阶:函数开发中的那些“坑”与最佳实践

第一个函数跑通,只是万里长征第一步。在实际项目中,你会遇到更多复杂情况和陷阱。

4.1 类加载冲突与依赖管理

这是自定义开发中最常见、最头疼的问题。现象通常是NoClassDefFoundErrorNoSuchMethodError

根本原因:你的自定义函数JAR包,可能包含了JMeter核心库已经存在的类(比如某个特定版本的commons-lang3),或者包含了与JMeter其他插件不兼容的依赖版本。

避坑指南

  1. 坚持providedscope:对于所有JMeter本身已提供的依赖(如ApacheJMeter_core,ApacheJMeter_http),在pom.xml中一律使用<scope>provided</scope>。确保Maven打包时不会将它们打入你的JAR。
  2. 最小化依赖:只引入你绝对必需的第三方库。如果必须引入(比如你需要一个特定的加密库),尽量选择与JMeter内置库兼容的版本,或者使用maven-shade-plugin进行重命名(Shading),但这会增大JAR包且是最后的手段。
  3. 隔离类加载器:JMeter的lib/ext目录下的JAR,是由一个独立的类加载器加载的,优先级很高。如果你把有冲突的JAR放在这里,很容易出问题。对于非必须的通用依赖,可以尝试放在lib目录下,但这不是官方推荐的做法。
  4. 实战排查:如果遇到类冲突,可以:
    • 使用jar tf your-custom.jar命令查看你的JAR包里到底有哪些类。
    • 使用java -verbose:class ...启动JMeter,观察类加载顺序,但输出信息量巨大。
    • 更实用的方法是,写一个简单的测试类,打印出某个冲突类的ClassLoaderLocation,帮助定位是哪个JAR包加载的。

4.2 线程安全与性能考量

JMeter是高性能的压测工具,会并发执行大量线程。你的函数必须保证线程安全。

  • 无状态设计是首选:尽可能让函数成为无状态的工具类。就像我们的OrderIdFunction,虽然有一个Random成员,但Random本身是线程安全的。更优的做法是使用ThreadLocalRandom
  • 警惕静态变量:除非你知道自己在做什么,否则避免使用可变的静态变量。如果多个线程同时修改一个静态HashMap,后果不堪设想。如果必须共享状态,考虑使用JMeterUtils提供的线程安全容器,或者利用JMeter的Properties(全局属性)或Variables(线程变量)。
  • 性能开销execute方法会被频繁调用。避免在其中进行昂贵的操作,比如建立数据库连接、读取大文件、进行复杂的网络请求。如果必须,考虑增加缓存机制。例如,一个从配置文件读取密钥的函数,可以在第一次调用时读取并缓存,而不是每次都读文件。

4.3 复杂参数与动态解析

我们的第一个例子参数很简单。但现实需求可能更复杂。

场景一:参数本身是动态表达式用户可能想这样调用:${__myFunc(${__time(,)}-${__Random(1,100)})}。这时,传入setParametersCompoundVariable对象,其原始字符串就是${__time(,)}-${__Random(1,100)}。如果你在setParameters里直接execute(),得到的是当时解析的结果。但你可能希望这个表达式在每次execute时都重新计算。这时,你应该保存CompoundVariable对象本身,在execute方法中再调用它的execute()方法。

场景二:处理集合或MapJMeter函数参数是平铺的。如果你想传递一个复杂对象,通常需要约定一种格式,比如用特定分隔符的字符串,然后在函数内部解析。例如,__myFunc(key1:value1,key2:value2)。在execute方法中,你需要自己写字符串分割和解析的逻辑。

4.4 调试与日志输出

调试自定义函数不像调试普通Java应用那么方便。

  1. 使用标准输出:在函数代码中插入System.out.println。输出会显示在JMeter启动的控制台(或日志文件)中。注意:在高并发下,大量控制台输出会严重影响性能,调试完成后务必移除。
  2. 使用JMeter日志框架:更推荐的方式是使用JMeter自带的日志。
    import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; private static final Logger log = LogManager.getLogger(OrderIdFunction.class); // 在代码中 log.debug("正在生成订单ID,前缀: {}, 随机数长度: {}", businessPrefix, randomLength);
    你可以在JMeter的log4j2.xml配置文件中,为你自定义的Logger设置级别(如DEBUG),将日志输出到指定文件。
  3. 单元测试:为你的函数类编写独立的JUnit测试。这能确保核心逻辑的正确性,而不依赖于JMeter环境。测试execute方法的不同输入输出情况。

5. 超越基础:从函数到完整插件

掌握了单个函数的开发,你已经具备了扩展JMeter的基础能力。但JMeter的插件生态远不止函数。你可以将一系列相关的函数、采样器、配置元件、监听器等打包,形成一个功能完整的插件包,方便分发和团队使用。

关键步骤:

  1. 统一包名与目录结构:将你的所有扩展类(函数、采样器等)放在一个统一的包下,例如com.yourcompany.jmeter.plugins.order
  2. 创建jpgc-前缀的JAR:JMeter社区约定俗成,将插件JAR命名为以jpgc-(JMeter Plugins)开头,如jpgc-order-generator-1.0.0.jar
  3. 完善META-INF/services:如果你除了函数,还开发了AbstractSamplerGui(采样器GUI)或AbstractConfigGui(配置元件GUI),需要在META-INF/services下创建对应的文件,如org.apache.jmeter.gui.action.Action(虽然不常见),更主要的是通过jmeter.propertiessearch_pathsplugin_dependency_paths的配置来管理,但社区插件通常将GUI类直接打包,JMeter会自动扫描。
  4. 提供默认配置:可以在JAR包的bin目录下提供自定义的jmeter.properties片段,指导用户如何配置。
  5. 编写文档:一个好的插件必须有清晰的README,说明功能、安装方法、使用示例和参数详解。

从开发一个自用的小函数,到打包一个团队共享的插件,这个过程会让你对JMeter的架构有更深刻的理解。你会接触到JMeterPluginNewDriver等更底层的类,以及如何管理插件间的依赖。这无疑是性能测试工程师技术栈的一次重要升级。

当你成功部署了自己开发的函数,并在复杂的测试场景中游刃有余地调用它时,那种对测试工具“掌控感”的提升是巨大的。你不再被工具的限制所束缚,而是能够让它完美适配你的业务逻辑。自定义函数开发,是JMeter高级使用的标志,也是通往性能测试专家之路上一块坚实的垫脚石。记住,从解决一个具体的、重复的业务痛点开始,你的第一个函数,可能就是整个团队效率提升的开始。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/28 10:32:40

人形机器人产业链:核心零部件比整机厂更赚钱的底层逻辑

人形机器人整机最近热度很高&#xff0c;但一条更反直觉的产业链逻辑正在浮出水面&#xff1a;真正赚钱的&#xff0c;可能不是做整机的&#xff0c;而是给整机供核心零部件的厂商。拿灵巧手来说&#xff0c;产业里讨论的“单手几十万”并不是噱头&#xff0c;在早期小批量交付…

作者头像 李华
网站建设 2026/8/28 10:31:41

BP神经网络原理与实现:从数学建模到代码实战

1. 从“抱佛脚”到“懂原理”&#xff1a;为什么BP神经网络是数学建模的“万金油”“数学建模前抱一下腿”——这个标题太真实了&#xff0c;几乎是每个参加过建模竞赛的同学都曾有过的内心写照。面对一个全新的、数据驱动的赛题&#xff0c;时间紧迫&#xff0c;从头推导算法不…

作者头像 李华
网站建设 2026/8/28 10:31:28

航拍图像实例分割实战:从数据处理到YOLOv8模型训练与部署

简介&#xff1a;实例分割是计算机视觉中的一项核心技术&#xff0c;它要求模型不仅能识别图像中的物体&#xff0c;还要精确分割出每个独立实例的像素级轮廓。其原理通常基于深度学习框架&#xff0c;通过编码器-解码器结构或类似Mask R-CNN的架构&#xff0c;在目标检测的基础…

作者头像 李华
网站建设 2026/8/28 10:31:12

ChatGPT定时任务集成Slack:触发与分享全链路解析

在实际使用 ChatGPT 完成工作自动化时&#xff0c;定时任务是一个很常见的需求&#xff1a;每天早上生成日报、周期性整理周报、定时检查某个页面变化、准点把摘要发到团队群。但很多把这类逻辑搭起来的人都会遇到同一个卡点&#xff1a;任务的创建入口太不方便。要让团队真正用…

作者头像 李华
网站建设 2026/8/28 10:30:13

JT/T 1078视频转播服务器:从协议解析到流媒体输出的实战指南

简介&#xff1a;在音视频传输领域&#xff0c;标准化协议是实现设备互联互通、构建大规模监控与直播系统的基石。其核心原理在于定义一套统一的通信规范&#xff0c;使不同厂商的前端设备与后端平台能够无缝对接&#xff0c;从而打破信息孤岛。这项技术的核心价值在于保障了视…

作者头像 李华
网站建设 2026/8/28 10:30:03

免费屏幕共享工具完整指南:零门槛搞定远程协作

免费屏幕共享工具完整指南&#xff1a;零门槛搞定远程协作 【免费下载链接】free-for-dev A list of SaaS, PaaS and IaaS offerings that have free tiers of interest to devops and infradev 项目地址: https://gitcode.com/GitHub_Trending/fr/free-for-dev 客户凌晨…

作者头像 李华