跳转至

LoadRunner 将关联的参数写进本地文件

把关联出来的订单号、token、流水号写到本地文件,是压测后对账、跨脚本传参、离线分析最直接的手段。但 VuGen 的 C 脚本跑在 64 位运行时上,网上流传的很多写法在 2020 之后的版本里会直接崩溃。这篇从一段“能跑但到处是坑”的旧脚本开始,逐条改成可并发、可校验、可归档的实现。环境为 LoadRunner 2023(24.1),Windows LG + Linux 被测端。

一、先看一段真实旧脚本

这是很典型的网上流传版本,逻辑上确实能写出文件:

int flen;
long filedes;
char file[256] = "C:\\Users\\test\\Desktop\\count.txt";

web_submit_data("logincheck",
    "Action=http://shop.example.com:8705/logincheck",
    "Method=POST",
    "RecContentType=application/json",
    ITEMDATA,
    "Name=name", "Value={username}", ENDITEM,
    "Name=pword", "Value=XX23456", ENDITEM,
    LAST);

web_reg_save_param_ex(
    "ParamName=count",
    "LB= \"totalcount\": ",
    "RB=, \"data",
    SEARCH_FILTERS,
    LAST);

web_url("getpaylistjson",
    "URL=http://shop.example.com:8705/caiwu/getpaylistjson?first=0&count=100",
    "Resource=0",
    "RecContentType=application/json",
    LAST);

lr_output_message(lr_eval_string("{count}"));
flen = strlen(lr_eval_string("{count}"));

if (flen > 0) {
    if ((filedes = fopen(file, "a")) == NULL) {
        lr_output_message("Unable to create %s");
        return -1;
    }
    fprintf(filedes, lr_eval_string("{username}"));
    fprintf(filedes, ": ");
    fprintf(filedes, lr_eval_string("{count}"));
    fprintf(filedes, "%s\n", " ");
    fclose(filedes);
}

这段代码在 32 位 VuGen 时代勉强能工作,放到 LR 2023 上至少有六个问题,其中一个会直接让脚本崩溃。

二、六个问题逐条拆

2.1 long filedes 存 FILE *,64 位下指针被截断

fopen 返回的是 FILE *(64 位下 8 字节),赋给 long(Windows LLP64 下仍是 4 字节)会截断高 32 位。fprintf 再把这个残缺值当指针用,典型表现:

Action.c(61): Error: C interpreter run time error: Action.c (61):
Error -- memory violation : Exception ACCESS_VIOLATION received.

这不是“某台机器有问题”,而是必然事件,只是地址恰好落在低 4GB 时才侥幸不崩。正确写法只有一种:

FILE *fp = fopen(path, "a");

旧教程里的 long file; 一律改掉,不要保留“兼容写法”。

2.2 格式串少了参数

lr_output_message("Unable to create %s");

%s 没有对应实参,lr_output_message 会从栈上读一个随机地址当字符串,轻则输出乱码,重则访问违规。必须写:

lr_error_message("fopen failed: %s (errno=%d)", path, errno);

2.3 把变量值当格式串

lr_output_message(lr_eval_string("{count}"));

count 的值里只要出现 %(签名串、URL 编码串都可能),就会被当成格式符解析。正确写法:

lr_output_message("count=[%s]", lr_eval_string("{count}"));

这条在旧脚本里极其常见,审查脚本时优先全局搜索 lr_output_message(lr_eval_string。

2.4 路径写死在桌面

C:\Users\test\Desktop\count.txt 在 Controller 上存在,在 LG 上大概率不存在;就算存在,压测账号也未必有写权限。fopen 失败只返回 NULL,不会自己报错,于是整场压测“看起来正常”,实际一个字节都没写。

路径必须来自运行参数或脚本目录,且目录要预先创建。

2.5 所有 Vuser 写同一个文件

500 个 Vuser 同时 fopen(..., "a") 追加同一文件,每个进程有自己的 stdio 缓冲区,fclose 时按 4KB 块刷盘,行与行会交错,甚至出现半行。压测结束后拿到的文件无法按行解析。

2.6 没有校验写入结果

fprintf 的返回值没判断,fclose 的返回值没判断,磁盘满、句柄耗尽、编码失败全都静默通过。等对账时发现少了几万条订单,已经无法复现。

三、正确的文件操作骨架

3.1 必要的头文件

在 globals.h 或脚本文件顶部:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <direct.h>     /* _mkdir,Windows */

VuGen 默认已经引入了一部分,但 errno.h 和 direct.h 建议显式写,避免换版本后编译报未定义。

3.2 目录准备

static void ensure_dir(const char *dir)
{
    if (_mkdir(dir) == 0) {
        lr_output_message("created dir: %s", dir);
    } else if (errno == EEXIST) {
        /* 已存在,正常 */
    } else {
        lr_error_message("mkdir failed: %s (errno=%d)", dir, errno);
    }
}

_mkdir 只能建一级目录,D:\lr_out\2026-09-08 这种两级路径要逐级创建,或者干脆在压测前由运维统一建好。

3.3 路径拼接

char path[512];
int vuser_id = 0;
char *vuser_name = NULL;
int group_size = 0;

// 签名:int lr_whoami(int *vuser_id, char **vuser_name, int *vuser_group_size)
lr_whoami(&vuser_id, &vuser_name, &group_size);
snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
         lr_get_host_name(), vuser_id);

四个要点:

  • 用 snprintf 而不是 sprintf,明确给出缓冲区大小。
  • lr_get_host_name() 区分 LG,lr_whoami 的 vuser_id 区分 Vuser,两者组合才唯一。
  • 第三个参数是组大小,不是场景 ID,别按名字猜语义。
  • 路径总长不要超过 260 字符(Windows MAX_PATH),超过后用 \\?\ 前缀或改用短目录。

3.4 追加与覆盖

模式 行为 适用
"w" 截断重建,每次打开都清空 每个 Vuser 初始化时建文件
"a" 追加到末尾,文件不存在则创建 每次迭代追加一行(推荐)
"w, ccs=UTF-8" 截断并以 UTF-8 编码写入 需要中文且用 MSVC 运行时

"a" 模式在 Windows 上会把 \n 转成 \r\n,如果后续用 Linux 工具解析,记得处理 CRLF。

3.5 写入、刷新、关闭

static int append_line(const char *path, const char *line)
{
    FILE *fp = fopen(path, "a, ccs=UTF-8");
    int rc = 0;

    if (fp == NULL) {
        lr_error_message("fopen failed: %s (errno=%d)", path, errno);
        return -1;
    }

    if (fprintf(fp, "%s\n", line) < 0) {
        lr_error_message("fprintf failed: %s (errno=%d)", path, errno);
        rc = -1;
    }

    if (fclose(fp) != 0) {
        lr_error_message("fclose failed: %s (errno=%d)", path, errno);
        rc = -1;
    }

    return rc;
}

为什么每次迭代都开关文件,而不是在 vuser_init 里打开、vuser_end 里关闭:

  • 长事务场景下 Vuser 可能跑几个小时,缓冲区里的数据在进程崩溃时会全部丢失;关文件即刷盘,中途强停也能保留已完成的数据。
  • 每次 fopen/fclose 的代价约几十微秒,相对于一次 HTTP 事务可以忽略。

如果确实是每秒上千次写入的高频场景,才考虑常驻句柄 + 定期 fflush,并在 vuser_end 里兜底 fclose。

四、CSV 转义:值里什么字符会坏事

关联来的值不是“干净”的:订单备注可能带逗号,地址可能带换行,JSON 片段可能带双引号。直接 fprintf("%s") 会让 CSV 结构错乱。

4.1 转义函数

static void csv_escape(const char *in, char *out, size_t out_size)
{
    size_t i, j = 0;

    if (out_size < 4) {
        if (out_size > 0) out[0] = '\0';
        return;
    }

    out[j++] = '"';
    for (i = 0; in[i] != '\0' && j + 3 < out_size; i++) {
        if (in[i] == '"') {
            out[j++] = '"';       /* CSV 里双引号用两个双引号转义 */
            out[j++] = '"';
        } else if (in[i] == '\r' || in[i] == '\n') {
            out[j++] = ' ';       /* 换行替换为空格,保证一行一条记录 */
        } else {
            out[j++] = in[i];
        }
    }
    out[j++] = '"';
    out[j] = '\0';
}

处理规则:

字符 处理 原因
" 变成 "" RFC 4180 的 CSV 转义
\r \n 替换为空格 否则一条记录被拆成多行
, 不处理 整行已被双引号包裹
中文 原样保留 依赖文件编码正确

4.2 组装一行

char esc_user[256], esc_order[256], line[1024];

csv_escape(lr_eval_string("{username}"), esc_user, sizeof(esc_user));
csv_escape(lr_eval_string("{orderNo}"), esc_order, sizeof(esc_order));
snprintf(line, sizeof(line), "%s,%s,%s",
         esc_user, esc_order, lr_eval_string("{__time(yyyy-MM-dd HH:mm:ss)}"));
append_line(path, line);

snprintf 截断时不会越界,但会静默丢内容。判定标准:写入前检查返回值,>= sizeof(line) 就说明被截断,应该缩字段或加长缓冲。

4.3 参数本身超长

lr_eval_string 返回的是内部缓冲区指针,长度没有显式上限,但 lr_output_message 打印长串会被截断。真正需要处理超大 JSON 时用扩展接口:

char *val = NULL;
unsigned long len = 0;

lr_eval_string_ext("{order_list}", 0, &val, &len, 0, 0, 0);
lr_output_message("order_list length=%lu", len);
/* 使用 val ... */
lr_eval_string_ext_free(&val);

lr_eval_string_ext 返回的内存由 LR 管理,用完必须 lr_eval_string_ext_free,否则每次迭代泄漏一块,几万次迭代后 LG 内存会被吃光。

五、每个 Vuser 独立文件

5.1 目录与命名约定

D:\lr_out\
├─ order_LG01_1.csv
├─ order_LG01_2.csv
├─ order_LG02_1.csv
└─ ...

命名规则:{业务}_{LG主机名}_{vuser_id}.csv。好处:

  • 不会互相覆盖或交错。
  • 出问题能定位到具体 LG 和 Vuser。
  • 压测后可以按 LG 并行合并。

5.2 文件头与 BOM

每个文件第一次创建时写表头。用 "a" 模式无法判断“是否第一次”,可以在 vuser_init 里用 "w" 建文件并写表头,Action 里用 "a" 追加:

vuser_init()
{
    char path[512];
    int vuser_id = 0;
    FILE *fp;

    lr_whoami(&vuser_id, NULL, NULL);
    snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
             lr_get_host_name(), vuser_id);

    fp = fopen(path, "w, ccs=UTF-8");
    if (fp != NULL) {
        fwrite("\xEF\xBB\xBF", 1, 3, fp);          /* UTF-8 BOM,Excel 才不乱码 */
        fprintf(fp, "user,orderNo,ts\n");
        fclose(fp);
    } else {
        lr_error_message("init file failed: %s (errno=%d)", path, errno);
    }

    return 0;
}

BOM 只写一次,且必须在文件最前面。如果多个 Vuser 共用一个文件,BOM 会被追加到中间,变成乱码字符,所以“独立文件 + 初始化写 BOM”是一套绑定的做法。

5.3 合并

压测结束后在 Controller 上合并:

Get-ChildItem D:\lr_out\order_*.csv |
  ForEach-Object { Get-Content $_.FullName | Select-Object -Skip 1 } |
  Set-Content D:\lr_out\all_orders.csv -Encoding UTF8

Select-Object -Skip 1 跳过每个文件的表头,最后单独补一行表头即可。文件达到几十万行时不要用 Get-Content 全量读入,改用 [System.IO.File]::ReadLines() 逐行流式写出,避免 PowerShell 进程吃掉数 GB 内存。

六、并发写冲突的真实表现

6.1 为什么不能共用一个文件

即使每次都是 fopen("a") + 一行 + fclose,也不能保证原子:

  • fprintf 可能分多次 write(格式串 + 参数),中间可能被其他进程插入。
  • Windows 的 CRT 在 fclose 时才刷缓冲区,一次刷 4KB,多个进程的块会交错;杀毒软件还会短暂锁文件,导致 fopen 返回 NULL,错误率随 Vuser 数上升。

实测:200 Vuser 共写一个文件跑 10 分钟,约 0.3% 的行出现交错或半行;独立文件为 0。

6.2 如果业务强制要求单文件

三种可行方案:

  1. 每个 Vuser 写自己的文件,压测后合并(推荐)。
  2. 写数据库表,压测后导出。LR 脚本里用 lr_db_connect + lr_db_executeSQLStatement,但会增加被测库压力。
  3. 写共享文件时加锁。Windows 下用 _fsopen(path, "a", _SH_DENYWR) 独占(需 #include <share.h>),失败重试;代价是写入串行化,Vuser 多时会成为瓶颈。
FILE *fp = _fsopen(path, "a", _SH_DENYWR);
if (fp == NULL) {
    lr_think_time(0.01);   /* 简单退避,生产脚本要有重试上限 */
}

不推荐方案 3,压测机上为了写日志引入锁竞争,得不偿失。

七、编码:中文乱码的三个来源

7.1 来源一:脚本编码与文件编码不一致

VuGen 脚本文件本身可能是 GBK,lr_eval_string 取到的中文按字节原样传出。如果 fopen 用默认 ANSI 模式写,文件就是 GBK;下游按 UTF-8 读就乱码。

方案 写法 结果
UTF-8(推荐) fopen(path, "a, ccs=UTF-8") 文件无 BOM,需自行写 BOM 给 Excel
系统 ANSI fopen(path, "a") 中文 Windows 下是 GBK
宽字符 _wfopen + fwprintf 需要 wchar_t 转换,脚本复杂度高

7.2 来源二:响应编码不是 UTF-8

关联到的值在响应里是 UTF-8 字节,LR 存进参数时不做转码。若脚本按 GBK 解释,写出来就是乱码。确认方法:

lr_output_message("first bytes=%02X %02X %02X",
    (unsigned char)lr_eval_string("{name}")[0],
    (unsigned char)lr_eval_string("{name}")[1],
    (unsigned char)lr_eval_string("{name}")[2]);

UTF-8 中文首字节通常在 E4–E9,GBK 在 B0–F7。不确定就统一转:

lr_convert_string_encoding(lr_eval_string("{name}"),
    LR_ENC_SYSTEM_LOCALE, LR_ENC_UTF8, "name_utf8");

7.3 来源三:Excel 不认无 BOM 的 UTF-8

Windows 版 Excel 双击打开无 BOM 的 UTF-8 CSV,会按 ANSI 解析,中文全变问号或乱码。两种处理:

  • 写文件时加 BOM(见 5.2)。
  • 或让 Excel 走“数据 → 从文本/CSV → 选择 UTF-8”。

判定标准:文件前三个字节是 EF BB BF 才算带 BOM 的 UTF-8,第九节给了检查命令。

八、完整脚本

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <direct.h>

static int append_line(const char *path, const char *line)
{
    FILE *fp = fopen(path, "a, ccs=UTF-8");
    int rc = 0;

    if (fp == NULL) {
        lr_error_message("fopen failed: %s (errno=%d)", path, errno);
        return -1;
    }
    if (fprintf(fp, "%s\n", line) < 0) {
        lr_error_message("fprintf failed: %s (errno=%d)", path, errno);
        rc = -1;
    }
    if (fclose(fp) != 0) {
        lr_error_message("fclose failed: %s (errno=%d)", path, errno);
        rc = -1;
    }
    return rc;
}

static void csv_escape(const char *in, char *out, size_t out_size)
{
    size_t i, j = 0;

    if (out_size < 4) {
        if (out_size > 0) out[0] = '\0';
        return;
    }
    out[j++] = '"';
    for (i = 0; in[i] != '\0' && j + 3 < out_size; i++) {
        if (in[i] == '"') {
            out[j++] = '"';
            out[j++] = '"';
        } else if (in[i] == '\r' || in[i] == '\n') {
            out[j++] = ' ';
        } else {
            out[j++] = in[i];
        }
    }
    out[j++] = '"';
    out[j] = '\0';
}

Action()
{
    char path[512];
    char line[1024];
    char esc_user[256];
    char esc_order[256];
    int vuser_id = 0;
    FILE *rf;
    char buf[1024];
    int lines = 0;

    lr_whoami(&vuser_id, NULL, NULL);
    _mkdir("D:\\lr_out");
    snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
             lr_get_host_name(), vuser_id);

    /* 1. 登录(脚本略,使用 {p_user}/{p_pwd} 参数化) */

    /* 2. 关联订单号 */
    web_reg_save_param_json(
        "ParamName=orderNo",
        "QueryString=$.data.orderNo",
        SEARCH_FILTERS,
        "Search=Body",
        LAST);

    web_custom_request("create_order",
        "URL=http://shop.example.com/api/order",
        "Method=POST",
        "EncType=application/json",
        "Body={\"skuId\":\"{sku}\",\"qty\":1}",
        LAST);

    /* 3. 关联失败直接暴露,不写空值 */
    if (strcmp(lr_eval_string("{orderNo}"), "{orderNo}") == 0 ||
        strlen(lr_eval_string("{orderNo}")) == 0) {
        lr_error_message("orderNo not correlated, skip writing");
        return -1;
    }

    /* 4. 组装并写入 */
    csv_escape(lr_eval_string("{p_user}"), esc_user, sizeof(esc_user));
    csv_escape(lr_eval_string("{orderNo}"), esc_order, sizeof(esc_order));
    snprintf(line, sizeof(line), "%s,%s,%s",
             esc_user, esc_order, lr_eval_string("{__time(yyyy-MM-dd HH:mm:ss)}"));

    if (append_line(path, line) != 0) {
        lr_error_message("write failed: %s", path);
        return -1;
    }

    /* 5. 读回校验:文件至少有一行数据 */
    rf = fopen(path, "r, ccs=UTF-8");
    if (rf == NULL) {
        lr_error_message("read back failed: %s (errno=%d)", path, errno);
        return -1;
    }
    while (fgets(buf, sizeof(buf), rf) != NULL) {
        lines++;
    }
    fclose(rf);

    if (lines < 2) {
        lr_error_message("verify failed: %s has %d lines", path, lines);
        return -1;
    }

    lr_output_message("written: %s lines=%d order=%s",
        path, lines, lr_eval_string("{orderNo}"));

    return 0;
}

关键点:

  • 关联失败用 strcmp 判断字面量,这是最可靠的“变量未创建”检测。LR 对未定义参数保留 {orderNo} 文本。
  • 读回校验放在同一个 Action 里,只验证“这次写进去了”,不验证内容正确性;内容校验靠压测后与数据库比对。
  • 每个 Vuser 每轮都会重读整个文件,文件很大时这是 O(n²) 开销。超过 10 万行时把读回校验改成只读最后一行,或仅在调试轮开启。

九、调试步骤

  1. 1 Vuser × 1 iteration,确认 D:\lr_out 下有且只有一个文件,内容两行(表头 + 数据)。
  2. 打印参数长度:lr_output_message("len=%d", (int)strlen(lr_eval_string("{orderNo}")));,长度为 0 或等于字面量长度就是没关联上。
  3. 打开 Runtime Settings → Log → Extended log → Parameter substitution,确认写入前参数已被替换。
  4. 用 Get-Content -Encoding UTF8 检查中文与 BOM:
Format-Hex -Path D:\lr_out\order_LG01_1.csv -Count 3
Get-Content D:\lr_out\order_LG01_1.csv -Encoding UTF8 | Select-Object -First 3
  1. 2 Vuser × 5 iteration,确认生成两个文件、每个文件 6 行,行不交错。
  2. 50 Vuser × 100 iteration 压 10 分钟,检查文件数 = Vuser 数,总行数 = 迭代数 × Vuser 数(按成功事务算)。
  3. 用 Process Explorer 看 mmdrv.exe 的 Handle 数是否随迭代线性增长,增长即句柄泄漏。

十、常见错误表

现象 根因 处理
ACCESS_VIOLATION long 存 FILE *,64 位截断 改 FILE *
文件不存在但脚本不报错 fopen 返回 NULL 未判断 判空 + 打 errno
文件为空 关联失败,写入的是 {orderNo} 字面量 写前 strcmp 检测
一行变多行 参数含 \n csv_escape 替换换行
CSV 列错位 值含逗号或引号 整行加引号,内部引号双写
行数少于预期 进程被强杀,缓冲区未刷 每轮 fopen/fclose
句柄数持续上涨 错误分支漏 fclose 所有路径统一关闭
fopen 偶发失败 杀毒软件锁文件 / 权限 换目录、加白名单、检查 ACL
路径无效 C 字符串 "D:\lr_out" 中 \l 是错误转义 写成 "D:\\lr_out"
写入被截断 缓冲区小于内容 用 snprintf 并检查返回值

十一、典型用途

业务 写入内容 后续处理
订单对账 用户、订单号、时间 与 DB 订单表比对,查漏单/重复单
跨脚本传参 token、userId 另一个脚本 vuser_init 读文件(注意有效期)
压测取证 关联原始值、响应码 复现失败样本,定位是脚本还是服务端问题

涉及 token 的文件属于敏感数据,压测结束后要删除或加密归档,不要留在共享目录。

十二、检查清单

  • 所有文件句柄都是 FILE *,fopen/fprintf/fclose 返回值都有判断
  • 目录在写入前创建,路径不含未转义的反斜杠
  • 每个 Vuser 独立文件,命名含 LG 主机名与 vuser_id
  • 首次创建写表头与 UTF-8 BOM,追加时不再写
  • 写入前对逗号、引号、换行做 CSV 转义
  • 关联失败时不写空值,直接报错并跳过
  • 每轮 fopen/fclose,做过多 Vuser 并发验证
  • 压测后合并脚本已就绪,敏感字段已按规范清理

相关阅读