dotnet-httpie对压测的支持

Intro

dotnet-httpie 在 0.3 版本中初步增加了对 API 压测的支持,可以对 API 做一些简单的压测,有需要的可以体验一下.

Get Started

目前新增了三个选项来支持压测

  • 请求数量:-n/--iteration
  • 虚拟用户数量:--vu/--vus/--virtual-user
  • 持续时间:-d/--duration

我们可以单独使用其中一个来测试,也可以尝试组合他们来使用,需要注意的是如果同时指定了持续时间和请求数量则只会使用持续时间,请求数量会被忽略,下面是一些使用的示例:

dotnet http :12562/api/health -n=1000
dotnet http :12562/api/health -n=1000 --vu=10
dotnet http :12562/api/health -d=30s
dotnet http :12562/api/health -d=30s --vu=10

dotnet-httpie对压测的支持dotnet-httpie对压测的支持dotnet-httpie对压测的支持dotnet-httpie对压测的支持

简单解释一个上面的输出结果:

Total request 是一共发送了多少个请求,后面括号里的时间是一共花费的时间,后面是一个成功和失败的数量和比例,响应的状态码是 2xx 认为是成功的

下面的参数则是请求花费时间的一些统计:

  • Min:最小值
  • Max:最大值
  • Median: 中位数
  • Average:平均数
  • P99/P95/P90/P75/P50 是百分位数,一般我们更多地会关注 P95/P90,还有哪些你觉得应该要有现在还没有的欢迎反馈

我们也可以输出请求信息,最简单的可以添加一个 -v/--verbose 选项来输出请求信息,也可以使用 --print 来指定输出选项,压测暂时只支持输出请求信息,不支持输出响应信息

dotnet http :12562/api/health -n=1000 --vu=10 -v

dotnet-httpie对压测的支持

除了压测的支持,针对单个请求现在也增加了请求耗时的统计,除了请求耗时还会有一个拿到响应之后的时间,默认会输出在响应头中,可以通过配置 --print 输出参数来禁用

dotnet-httpie对压测的支持

dotnet-httpie对压测的支持

Implement

目前实现的代码也是比较简单的,实现代码如下:

总体流程是解析命令行的参数,判断是否是压测,如果请求持续时间大于0或者或者请求数量大于1或者虚拟用户数量大于1均视为压测,压测和普通的 Http 调用分成了两种情况,目前只有普通的 Http 调用会执行 response 的中间件逻辑,因为压测可能会有很多的请求,可能不太适合执行 Response 中间件的逻辑

var durationValue = requestModel.ParseResult.GetValueForOption(DurationOption);
var duration = TimeSpan.Zero;
if (!string.IsNullOrEmpty(durationValue))
{
    if (!char.IsNumber(durationValue[^1]) && double.TryParse(durationValue[..^1], out var value))
    {
        duration = durationValue[^1].ToLower() switch
        {
                's' => TimeSpan.FromSeconds(value),
                'm' => TimeSpan.FromMinutes(value),
                'h' => TimeSpan.FromHours(value),
                _ => TimeSpan.Zero
        };
    }
    if (duration == TimeSpan.Zero)
    {
        TimeSpan.TryParse(durationValue, out duration);
    }
}

var isLoadTest = duration > TimeSpan.Zero || iteration > 1 || virtualUsers > 1;
httpContext.UpdateFlag(Constants.FlagNames.IsLoadTest, isLoadTest);

if (isLoadTest)
{
    await InvokeLoadTest(client);
}
else
{
    httpContext.Response = await InvokeRequest(client, httpContext);
    await _responsePipeline(httpContext);
}

InvokeRequest 是简单的 Http 调用,压测请求的发起也是复用它的逻辑,实现代码如下:

private async Task<HttpResponseModel> InvokeRequest(HttpClient httpClient, HttpContext httpContext)
{
    var responseModel = new HttpResponseModel();
    try
    {
        using var requestMessage = await _requestMapper.ToRequestMessage(httpContext);
        LogRequestMessage(requestMessage);
        httpContext.Request.Timestamp = DateTimeOffset.Now;
        var startTime = Stopwatch.GetTimestamp();
        using var responseMessage = await httpClient.SendAsync(requestMessage);
        var elapsed = ProfilerHelper.GetElapsedTime(startTime);
        LogResponseMessage(responseMessage);
        responseModel = await _responseMapper.ToResponseModel(responseMessage);
        responseModel.Elapsed = elapsed;
        responseModel.Timestamp = httpContext.Request.Timestamp.Add(elapsed);
        LogRequestDuration(httpContext.Request.Url, httpContext.Request.Method, responseModel.StatusCode, elapsed);
    }
    catch (Exception exception)
    {
        LogException(exception);
    }
    return responseModel;
}

InvokeLoadTest 是压测请求,实现代码如下:

async Task InvokeLoadTest(HttpClient httpClient)
{
    var responseList = new ConcurrentBag<HttpResponseModel>();
    Func<int, CancellationToken, ValueTask> action;
    if (duration > TimeSpan.Zero)
    {
        action = async (_, _) =>
        {
            using var cts = new CancellationTokenSource(duration);
            while (!cts.IsCancellationRequested)
            {
                responseList.Add(
                    await InvokeRequest(httpClient, httpContext)
                );
            }
        };
    }
    else
    {
        action = async (_, _) =>
        {
            do
            {
                responseList.Add(await InvokeRequest(httpClient, httpContext));
            } while (--iteration > 0);
        };
    }
    var startTimestamp = Stopwatch.GetTimestamp();
    if (virtualUsers > 1)
    {
        await Parallel.ForEachAsync(
          Enumerable.Range(1, virtualUsers),
          new ParallelOptions { MaxDegreeOfParallelism = virtualUsers },
          action
        );
    }
    else
    {
        await action(default, default);
    }
    httpContext.Response.Elapsed = ProfilerHelper.GetElapsedTime(startTimestamp);
    httpContext.SetProperty(Constants.ResponseListPropertyName, responseList.ToArray());
}

除了发送请求之外,最后输出结果的时候会根据是否是压测输出不同的结果,这里就不贴代码了,感兴趣的可以到 Github 上查看

More

压测时可以考虑禁用日志以提高压测的准确度,可以利用 .NET runtime 的一些事件来收集更多更准确的时间

目前是直接输出到控制台的结果,后续考虑类似于 k6,将压测的结果导出到 influxdb 中,然后在 Grafana 中来展示压测结果,如果需要也可以支持更多导出方式

References

  • https://github.com/WeihanLi/dotnet-httpie
  • https://www.nuget.org/packages/dotnet-httpie