面对混合协议压测场景,很多人第一反应是分开写好几个脚本,各跑各的。但实际项目里,一个系统往往同时暴露HTTP接口、WebSocket长连接和gRPC服务,尤其是微服务架构和实时通信系统。如果能把它们放进同一个性能测试脚本里,一方面能模拟更真实的用户行为,另一方面也方便统一调度和报告。今天我们就聊聊,怎么用一个k6脚本文件把这些协议组织起来,并且让代码看着不乱、跑起来不慌。

一、为什么需要单文件组织?

先说说痛点。假设你负责一个在线客服系统,用户打开网页先通过HTTP登录,然后建立WebSocket连接接收消息,同时后端服务之间用gRPC互相调用。如果你分别对这三种协议写三个测试脚本,那测出来的结果其实是“断开的”——HTTP压测时没有WebSocket连接,gRPC压测时没有用户会话。真实场景下,这些流量是同时发生的,互相影响资源消耗和响应速度。所以我们需要一个脚本,在同一个压测运行里,让三种协议各自按预设的虚拟用户数并发跑,这样得到的指标才真正有参考价值。

单文件组织还有一个好处:好分发、好维护。你只需要一个test.js文件,塞给同事或者放到CI流水线里就能跑。不用搞一堆依赖文件、目录结构。对于大部分中小团队来说,一个文件就是一份完整的压测方案。当然,如果项目特别复杂,肯定要拆成多文件模块,但那是后话。这篇文章只聊单文件怎么组织。

二、核心思路:一个脚本,三种协议

k6本身内置了对HTTP协议的原生支持,理论上你不写任何import就能发起HTTP请求。但是WebSocket和gRPC需要引入额外的模块。好消息是,这两个模块都是k6官方提供的,不需要安装第三方包。在单文件里,你只需要在文件顶部用import把它们引进来,然后定义三个不同的“任务函数”,分别对应三种协议的测试逻辑。接下来,在options里配置多个场景,每个场景指定运行哪个函数、有多少虚拟用户、怎么增长并发。这样就把三种协议“塞”进了一个脚本里。

2.1 场景配置是关键

k6的scenarios配置是你的核心工具。你可以给每个场景起个名字,指定executor类型,比如constant-vus(固定虚拟用户数)、ramping-vus(动态增减),还可以通过exec参数告诉k6这个场景执行哪个导出函数。注意:当你指定exec时,默认的export default function就不会执行了。也就是说,你可以把HTTP、WebSocket、gRPC分别写成三个导出函数,然后让三个场景各干各的活。

这里有个小技巧:如果你想在同一个场景里同时跑多种协议,那也可以,但会非常复杂,因为k6的虚拟用户默认是顺序执行代码的。更好的做法是用不同的场景隔离,每种协议有独立的虚拟用户池,这样互不干扰,也方便观察每种协议自己的指标。

2.2 HTTP测试代码怎么写?

HTTP部分最简单,直接用http.gethttp.post就行。你可以把它的测试逻辑写成一个普通的导出函数。例如,先登录拿token,再请求业务接口。但要注意,单文件里为了让多个场景共用某个函数,这个函数必须被export导出。如果你不希望它被当作默认入口,就直接定义成export function httpTest这样的命名导出。

2.3 WebSocket测试代码怎么写?

WebSocket在k6里有点特殊,它是通过k6/ws模块里的connect函数操作的。与HTTP的“请求-响应”模式不同,WebSocket是长连接,你需要在一个回调里监听服务端发来的消息。而且k6的WebSocket处理是同步的——connect会阻塞当前虚拟用户,直到连接关闭或超时。所以你在写WebSocket测试时,要考虑连接保持多久、什么时候发送消息、什么时候关闭连接。常见做法是:在连接建立后发送一条消息,然后设置一个定时器或等待条件,最后主动关闭。如果压测场景是长期连接,你还可以在connect里什么都不做,只保持连接,然后等外部信号关闭。

2.4 gRPC测试代码怎么写?

gRPC部分要稍微费点力气。你需要用到k6/net/grpc模块,并且要准备一个.proto文件。k6的gRPC客户端支持加载本地的proto文件,然后调用远程方法。要注意的是,gRPC模块在k6里仍然是实验性的,API可能随版本变化,而且它不支持流式调用的所有模式,一般用一元调用比较多。在你的测试函数里,先创建一个grpc.Client实例,调用load方法加载proto文件,再调用connect方法与服务端建立连接,最后使用invoke发起远程调用。每次压测结束后,最好调用close()方法释放连接。在实际项目中,很可能你的proto文件里面定义了十几二十个方法,但压测时只关心其中的一两个,所以挑核心方法测就好。

三、单文件脚本详细示例

为了让你直观感受,我给出一个完整可运行的示例。技术栈:JavaScript(k6脚本)。这个示例模拟了一个在线支付系统的压测:客户端先通过HTTP登录获取令牌,然后通过WebSocket接收交易推送,最后通过gRPC查询交易状态。注意:这只是一个演示,实际proto文件和接口地址需要自己替换。

// 技术栈:JavaScript (k6脚本)
// 单文件混合协议压测:HTTP + WebSocket + gRPC

import http from 'k6/http';
import { check, sleep } from 'k6';
import ws from 'k6/ws';
import grpc from 'k6/net/grpc';
import { SharedArray } from 'k6/data';

// 使用SharedArray共享用户凭证,避免每个虚拟用户重复读取相同数据
const credentials = new SharedArray('userCredentials', function () {
  return [__ENV.USERNAME || 'test_user', __ENV.PASSWORD || 'test_pwd'];
});

// 加载gRPC客户端
const client = new grpc.Client();

// 压测配置:三个场景,每个场景独立虚拟用户
export const options = {
  scenarios: {
    // HTTP场景:模拟登录和查询接口
    http_only: {
      executor: 'constant-vus',
      vus: 50,             // 50个虚拟用户
      duration: '2m',      // 持续2分钟
      exec: 'http_test',   // 执行http_test函数
    },
    // WebSocket场景:模拟长连接消息接收
    ws_only: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 20 },  // 30秒内增长到20个WebSocket用户
        { duration: '1m', target: 20 },   // 保持20个用户1分钟
        { duration: '30s', target: 0 },   // 最后30秒降到0
      ],
      exec: 'ws_test',
    },
    // gRPC场景:模拟服务间调用
    grpc_only: {
      executor: 'constant-vus',
      vus: 30,
      duration: '2m',
      exec: 'grpc_test',
    },
  },
  thresholds: {
    // 全局阈值:HTTP请求失败率低于5%,gRPC 99%响应低于200ms
    http_req_failed: ['rate<0.05'],
    grpc_duration: ['p(99)<200'],
  },
};

/**
 * HTTP测试函数
 * 模拟用户登录并查询余额
 */
export function http_test() {
  // 第一步:POST登录,提交JSON格式的用户名密码
  const loginRes = http.post(
    'https://api.example.com/login',
    JSON.stringify({
      username: credentials[0],
      password: credentials[1],
    }),
    { headers: { 'Content-Type': 'application/json' } }
  );

  // 断言:登录响应状态必须是200,且body里包含token字段
  const isLoginOk = check(loginRes, {
    'login status is 200': (res) => res.status === 200,
    'login has token': (res) => JSON.parse(res.body).token !== undefined,
  });

  if (!isLoginOk) {
    console.error('登录失败,终止当前用户');
    return; // 失败就直接退出,避免后续请求无效
  }

  // 解析token,用于下一个请求的鉴权头
  const token = JSON.parse(loginRes.body).token;
  const authHeaders = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  };

  // 第二步:调用余额查询接口
  const balanceRes = http.get(
    'https://api.example.com/balance',
    { headers: authHeaders }
  );

  // 断言:余额查询返回200,且余额字段大于等于0
  check(balanceRes, {
    'balance status is 200': (res) => res.status === 200,
    'balance is non-negative': (res) => JSON.parse(res.body).balance >= 0,
  });

  // 模拟用户思考时间,防止请求过于密集
  sleep(1);
}

/**
 * WebSocket测试函数
 * 模拟客户端连接推送通道,接收行情数据
 */
export function ws_test() {
  const url = `wss://ws.example.com/market?token=test`;
  const params = { tags: { type: 'websocket' } };

  ws.connect(url, params, function (socket) {
    // 连接建立后输出日志
    socket.on('open', function () {
      console.log('WebSocket连接成功');

      // 发送一条订阅消息,告诉服务端要接收哪些数据
      socket.send(JSON.stringify({ action: 'subscribe', channel: 'trade' }));
    });

    // 监听服务端消息
    socket.on('message', function (data) {
      // 把收到的数据解析成对象,检查是否包含价格字段
      const message = JSON.parse(data);
      check(message, {
        'message has price': (obj) => obj.price !== undefined,
      });

      // 如果收到价格波动超过10%,关闭连接(模拟用户退出)
      if (Math.abs(message.price - 100) > 10) {
        socket.close();
      }
    });

    // 连接出错时记录
    socket.on('error', function (e) {
      console.error('WebSocket错误:', JSON.stringify(e));
    });

    // 连接关闭后,模拟用户思考
    socket.on('close', function () {
      sleep(2);
    });
  });
}

/**
 * gRPC测试函数
 * 模拟后端服务查询交易详情
 */
export function grpc_test() {
  // 加载proto文件,路径相对于脚本执行目录
  client.load(['definitions'], 'transaction.proto');

  // 连接gRPC服务端,不需要加密(实际生产一般会用TLS)
  client.connect('grpc.example.com:50051', { plaintext: true });

  // 构造请求参数,对应proto里的TransactionRequest
  const request = {
    transaction_id: `txn_${Date.now()}`,
    user_id: credentials[0],
  };

  // 调用gRPC方法:package.TransactionService/GetTransaction
  const response = client.invoke('payment.TransactionService/GetTransaction', request);

  // 校验响应状态和返回内容
  check(response, {
    'gRPC status is OK': (res) => res.status === grpc.StatusOK,
    'transaction id matches': (res) => res.message.transaction_id === request.transaction_id,
  });

  // 打印响应时间到控制台(可以用--console-output收集)
  console.log(`gRPC耗时:${response.timings.duration}ms`);

  // 关闭连接,释放资源
  client.close();

  // 模拟下一次调用的间隔
  sleep(0.5);
}

这个示例把三个协议的测试逻辑拆成了三个导出函数,互不干扰。options.scenarios里指定了每个场景执行哪个函数。虽然它们跑在同一个进程里,但各自的虚拟用户是独立的。这样看起来就很清爽:想调HTTP的参数就改http_only,想调WebSocket并发就改ws_only,完全不用动其他部分。注意在gRPC示例里,client.loadclient.connect每次调用都写在函数里,这其实是低效的。更好的做法是在init阶段提前加载,但k6的init阶段不允许做这些操作,所以我们只能在每个虚拟用户每次迭代里建立连接。对于压测来说,这会造成一定开销,不过它也能模拟真实的连接建立过程。如果连接成本很高,你可以考虑在k6/net/grpcClient实例上使用client.connect后不关闭,但这样多个虚拟用户共享同一个连接可能有问题,所以还是按官方推荐的方式写更稳妥。

四、应用场景与优缺点

4.1 应用场景

单文件混合协议压测最适合下面几类场景:

  • API网关压测:网关通常同时处理RESTful API和gRPC服务,还会转发WebSocket升级请求。你可以在一个脚本里,让虚拟用户分别模拟移动端、浏览器端和后端服务,对网关施加混合流量。
  • 实时通信系统:比如聊天室、股票行情、物联网设备上行数据。这类系统里,用户先通过HTTP登录,接着建立WebSocket连接,服务端之间用gRPC同步状态。用单文件脚本能真实还原这条链路。
  • 微服务依赖压测:当一个核心服务同时被多个上游服务调用,而部分上游走HTTP、部分走gRPC时,你需要让两种协议的流量按比例同时打过来,观察服务的资源瓶颈。单文件脚本可以定义两个场景,分别设置不同的虚拟用户数和持续时长,来模拟不同的调用占比。
  • 压测CI集成:如果你希望每次代码合并都自动跑一轮冒烟压测,那么单文件脚本是最好放进Docker镜像里的。只需要一行k6 run test.js,不需要额外复制多个文件。

4.2 优点

单文件组织最明显的好处是简单直接。不需要建一堆目录,也不需要定义复杂的构建流程。你打开文件,从头看到尾,就能了解整个压测方案的全貌。其次,配置集中:所有场景的并发数、持续时间、阈值都写在options里,调整起来非常快。再次,结果统一:k6会把三种协议产生的指标汇总到一份报告中,比如HTTP的http_req_duration、WebSocket的ws_msgs_received、gRPC的grpc_duration。你不需要去对多个脚本的输出做合并。最后,便于团队共享:一个文件发给任何人都能跑,只要他有k6环境和必要的proto文件。对于临时需要复现问题的场景,单文件简直是救星。

4.3 缺点

不过单文件组织也有天生短板。代码膨胀是你最先会遇到的问题。三种协议的测试逻辑、公共函数、配置、断言全堆在一起,如果每个协议的用例再一多,文件很快就会涨到几百行,阅读和维护成本直线上升。协议间耦合也比较烦人。虽然我们用scenarios隔离了执行函数,但共享变量、公共资源(比如gRPC客户端实例)如果没设计好,容易出现竞态问题。比如上面的示例中,client是模块级的全局变量,如果多个虚拟用户同时调用grpc_test,而k6的虚拟用户是并发运行的,对同一个client实例进行connectinvoke可能会导致状态混乱。这个需要特别注意。此外,WebSocket和gRPC的生命周期差异大。WebSocket连接可以挂很长时间,而HTTP请求是短连接,gRPC调用也是即用即走。把它们放在一个脚本里,容易让人忽略连接管理的细节,比如忘记关闭客户端、没有处理超时,造成资源泄漏。

五、注意事项与调试技巧

5.1 模块导入和k6版本

使用k6/net/grpck6/ws时,务必确认你的k6版本支持这些模块。随着k6更新,API可能变化。建议在脚本开头打印一下版本信息,或者用k6 version命令检查。在导入时,不要自作聪明去改模块名,k6/wsk6/net/grpc就是官方模块路径,用import引入即可。

5.2 场景隔离与全局变量

不同场景的虚拟用户是在隔离的JavaScript运行时里执行的,这意味着你无法通过普通全局变量在不同场景之间共享数据。如果你想让HTTP测试登录后拿到的token,给WebSocket或gRPC场景使用,那是不可能的——除非借助外部数据文件或者k6的“__VU”和“__ITER”做映射。一个常见做法是把登录获取的token写入本地的JSON文件,再由其他场景读取,但这样会引入磁盘IO,影响压测性能。更好的办法是简化设计:让每个场景独立准备自己的鉴权信息,甚至可以复用同一个环境变量里的用户名和密码。在我上面的示例中,credentials是一个SharedArray,它在所有虚拟用户之间只读共享,这是安全的。如果你打算使用冷门的模块级可变变量,要格外小心并发问题。

5.3 WebSocket连接管理

WebSocket压测最怕连接泄漏。记得在ws.connect的回调里,通过socket.close()显式关闭连接。如果你只是监听消息而不关闭,连接会一直保持,直到场景结束被强制销毁。这会使得“虚拟用户处于连接状态”这一行为不够真实,也会堆积大量未完成的连接。另外,k6的WebSocket是同步阻塞的,意味着一个虚拟用户在同一时间只能维持一个连接。如果你需要模拟一个用户同时建立多个WebSocket连接,只能增加虚拟用户数,或者在同一个虚拟用户里用Promise并发?实际上k6不支持异步并发WebSocket,所以还是用更多VU来解决。

5.4 gRPC的proto文件路径

client.load的路径是相对于当前工作目录的。如果你把脚本放在scripts目录,而proto文件在protos目录,你需要写出相对路径,比如client.load(['../protos'], 'transaction.proto')。k6不会自动递归查找,所以你要确保路径正确。另外,proto文件中的import语句也需要能被k6解析,所以得把所有的proto依赖都放到同一个目录或者传递给load的目录列表中。一个省事的办法是:把所有proto文件放到一个目录,然后只加载入口proto,k6会尝试从指定的目录列表中解析依赖。

5.5 使用环境变量做参数化

在压测环境切换时,你肯定不希望硬编码服务器地址和端口。用__ENV读取环境变量是最优雅的方式。比如:

const serverBaseUrl = __ENV.BASE_URL || 'https://api.example.com';
const wsUrl = __ENV.WS_URL || 'wss://ws.example.com';
const grpcAddr = __ENV.GRPC_ADDR || 'grpc.example.com:50051';

然后你在函数里引用这些变量。这样同一个脚本,在开发环境、测试环境、生产环境都能跑,只需要在执行时传入不同的环境变量。比如:

k6 run -e BASE_URL=https://staging.example.com -e WS_URL=wss://staging.example.com -e GRPC_ADDR=staging.example.com:50051 test.js

5.6 阈值(Thresholds)的写法

上面的示例中,我写了一个grpc_duration阈值,但你要知道k6内置的指标里默认没有grpc_duration。这个指标来自gRPC模块自定义指标?实际上k6官方并不直接提供grpc_duration指标名,你需要自己在check里记录所有gRPC调用的耗时,然后配合Trend类型自定义指标。简单一点的做法是使用k6内置的http_req_duration来覆盖HTTP部分,对于WebSocket可以用ws_msgs_received之类的指标,但对于gRPC,建议用自定义Trend。例如:

import { Trend } from 'k6/metrics';

const grpcTrend = new Trend('grpc_request_duration');

然后在调用后grpcTrend.add(response.timings.duration)。再用thresholds: { grpc_request_duration: ['p(99)<200'] }。不过要注意,自定义指标必须在options里声明吗?不需要,直接在import后定义即可。但阈值引用必须使用完整的指标名称。上面的示例中我写了grpc_duration,可能误导,这里纠正一下。实际应用中,用自定义Trend更可靠。

5.7 断言与检查的使用

check是k6中最常用的断言工具。我建议你为每个协议的关键步骤都加上check,这样在报告中能清楚看到成功率和失败原因。但要注意,不要过度使用check检查那些非必要的细节,否则报告会变得臃肿。对于HTTP,检查状态码和关键字段;对于WebSocket,检查消息类型和内容;对于gRPC,检查响应状态和业务字段。每个check都对应一个标签,你可以在结果中按标签筛选。

六、总结

单文件组织混合协议压测,本质上是在考验你对k6场景配置和模块生命周期的理解。它带来的好处是集中、简单、易分发,代价是代码复杂度和潜在的状态管理风险。对于中小型压测任务,一个文件完全够用。当你发现单文件变得难以维护时,考虑拆分为多文件模块,但那是另一个话题了。最后给你一个实用建议:在着手写脚本之前,先画出三种协议的交互时序图,明确每个协议的关键动作顺序,然后再写代码。别一上来就堆代码,那样很容易被各种回调搞晕。希望今天聊的这些,能让你面对混合协议压测时,心里更有底。