雜談WebApiClient的性能優化

前言

WebApiClient的netcoreapp版本的開發已接近尾聲,最後的進攻方向是性能的壓榨,我把我所作性能優化的過程介紹給你們,你們能夠依葫蘆畫瓢,應用到本身的實際項目中,提升程序的性能。react

整體成果展現

使用MockResponseHandler消除真實http請求,原生HttpClient、WebApiClientCore和Refit的性能參考:git

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.18362.836 (1903/May2019Update/19H1)
Intel Core i3-4150 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 2 physical cores
.NET Core SDK=3.1.202
  [Host]     : .NET Core 3.1.4 (CoreCLR 4.700.20.20201, CoreFX 4.700.20.22101), X64 RyuJIT
  DefaultJob : .NET Core 3.1.4 (CoreCLR 4.700.20.20201, CoreFX 4.700.20.22101), X64 RyuJIT
Method Mean Error StdDev
HttpClient_GetAsync 3.945 μs 0.2050 μs 0.5850 μs
WebApiClientCore_GetAsync 13.320 μs 0.2604 μs 0.3199 μs
Refit_GetAsync 43.503 μs 0.8489 μs 1.0426 μs
Method Mean Error StdDev
HttpClient_PostAsync 4.876 μs 0.0972 μs 0.2092 μs
WebApiClientCore_PostAsync 14.018 μs 0.1829 μs 0.2246 μs
Refit_PostAsync 46.512 μs 0.7885 μs 0.7376 μs

優化以後的WebApiClientCore,性能靠近原生HttpClient,並領先於Refit。github

Benchmark過程

性能基準測試能夠幫助咱們比較多個方法的性能,在沒有性能基準測試工具的狀況下,咱們僅憑肉眼如何區分性能的變化。web

BenchmarkDotNet是一款強力的.NET性能基準測試庫,其爲每一個被測試的方法提供了孤立的環境,使用BenchmarkDotnet,咱們很容易的編寫各類性能測試方法,並能夠避免許多常見的坑。json

請求總時間對比

拿到BenchmarkDotNet,我就火燒眉毛地寫了WebApiClient的老版本、原生HttpClient和WebApiClientCore三個請求對比,看看新的Core版本有沒有預期的性能有所提升,以及他們與原生HttpClient有多少性能損耗。api

Method Mean Error StdDev
WebApiClient_GetAsync 279.479 us 22.5466 us 64.3268 us
WebApiClientCore_GetAsync 25.298 us 0.4953 us 0.7999 us
HttpClient_GetAsync 2.849 us 0.0568 us 0.1393 us
WebApiClient_PostAsync 25.942 us 0.3817 us 0.3188 us
WebApiClientCore_PostAsync 13.462 us 0.2551 us 0.6258 us
HttpClient_PostAsync 4.515 us 0.0866 us 0.0926 us

粗略地看了一下結果,我開懷一笑,Core版本比原版本性能好一倍,且接近原生。
細看讓我大吃一驚,老版本的Get請求怎麼這麼慢,想一想多是老版本使用Json.net,以前吃過Json.net頻繁建立ContractResolver性能急劇降低的虧,就算是單例ContractResolver第一次建立也很佔用時間。因此改進爲在對比以前,作一次請求預熱,這樣比較接近實際使用場景,預熱以後的老版本WebApiClient,Get請求從279us下降到39us性能優化

WebApiClientCore的Get與Post對比

從上面的數據來看,WebApiClientCore在Get請求時明顯落後於其Post請求,個人接口是以下定義的:網絡

public interface IWebApiClientCoreApi
{
    [HttpGet("/benchmarks/{id}")]
    Task<Model> GetAsyc([PathQuery]string id);

    [HttpPost("/benchmarks")]
    Task<Model> PostAsync([JsonContent] Model model);
}

Get只須要處理參數id,作爲請求uri,而Post須要json序列化model爲json,證實代碼裏面的處理參數的[PathQuery]特性性能低下,[PathQuery]依賴於UriEditor工具類,執行流程爲先嚐試Replace(),不成功則調用AddQUery(),UriEditor的原型以下:app

class UriEditor
{ 
    public bool Replace(string name, string? value);
    public void AddQuery(string name, string? value);
}

考慮到請求uri爲[HttpGet("/benchmarks/{id}")],這裏流程上是不會調用到AddQuery()方法的,因此鎖定性能低的方法就是Replace()方法,接下來就是想辦法改造Replace方法了,下面爲改造前的Replace()實現:async

/// <summary>
/// 替換帶有花括號的參數的值
/// </summary>
/// <param name="name">參數名稱,不帶花括號</param>
/// <param name="value">參數的值</param>
/// <returns>替換成功則返回true</returns>
public bool Replace(string name, string? value)
{
    if (this.Uri.OriginalString.Contains('{') == false)
    {
        return false;
    }

    var replaced = false;
    var regex = new Regex($"{{{name}}}", RegexOptions.IgnoreCase);
    var url = regex.Replace(this.Uri.OriginalString, m =>
    {
        replaced = true;
        return HttpUtility.UrlEncode(value, this.Encoding);
    });

    if (replaced == true)
    {
        this.Uri = new Uri(url);
    }
    return replaced;
}

Repace的改進方案性能對比

在上面代碼中,有點經驗一眼就知道是Regex拖的後腿,由於業務須要不區分大小寫的字符串替換,而現成中能用的,有且僅有Regex能用了,Regex有兩種使用方式,一種是建立Regex實例,一種是使用Regex的靜態方法。

Regex實例與靜態方法
Method Mean Error StdDev
ReplaceByRegexStatic 480.9 ns 5.50 ns 5.15 ns
ReplaceByRegexNew 2,615.8 ns 41.33 ns 36.63 ns

這一跑就知道緣由了,把new Regex替換爲靜態的Regex調用,性能立刻提升5倍!

Regex靜態方法與自實現Replace函數

感受Regex靜態方法的性能還不是很高,本身實現一個Replace函數對比試試,萬一比Regex靜態方法還更快呢。因而我花一個晚上的時間寫了這個Replace函數,對,就是整整一個晚上,來爲它作性能測試,爲它作單元測試,爲它作內存分配優化。

/// <summary>
/// 不區分大小寫替換字符串
/// </summary>
/// <param name="str"></param>
/// <param name="oldValue">原始值</param>
/// <param name="newValue">新值</param>
/// <param name="replacedString">替換後的字符中</param>
/// <exception cref="ArgumentNullException"></exception>
/// <returns></returns>
public static bool RepaceIgnoreCase(this string str, string oldValue, string? newValue, out string replacedString)
{
    if (string.IsNullOrEmpty(str) == true)
    {
        replacedString = str;
        return false;
    }

    if (string.IsNullOrEmpty(oldValue) == true)
    {
        throw new ArgumentNullException(nameof(oldValue));
    }

    var strSpan = str.AsSpan();
    using var owner = ArrayPool.Rent<char>(strSpan.Length);
    var strLowerSpan = owner.Array.AsSpan();
    var length = strSpan.ToLowerInvariant(strLowerSpan);
    strLowerSpan = strLowerSpan.Slice(0, length);

    var oldValueLowerSpan = oldValue.ToLowerInvariant().AsSpan();
    var newValueSpan = newValue.AsSpan();

    var replaced = false;
    using var writer = new BufferWriter<char>(strSpan.Length);

    while (strLowerSpan.Length > 0)
    {
        var index = strLowerSpan.IndexOf(oldValueLowerSpan);
        if (index > -1)
        {
            // 左邊未替換的
            var left = strSpan.Slice(0, index);
            writer.Write(left);

            // 替換的值
            writer.Write(newValueSpan);

            // 切割長度
            var sliceLength = index + oldValueLowerSpan.Length;

            // 原始值與小寫值同步切割
            strSpan = strSpan.Slice(sliceLength);
            strLowerSpan = strLowerSpan.Slice(sliceLength);

            replaced = true;
        }
        else
        {
            // 替換過剩下的原始值
            if (replaced == true)
            {
                writer.Write(strSpan);
            }

            // 再也無匹配替換值,退出
            break;
        }
    }

    replacedString = replaced ? writer.GetWrittenSpan().ToString() : str;
    return replaced;
}

這代碼不算長,但爲它寫了好多個Buffers相關類型,因此整體工做量很大。不過總算寫好了,來個長一點文本的Benchmark:

public class Benchmark : IBenchmark
{
    private readonly string str = "WebApiClientCore.Benchmarks.StringReplaces.WebApiClientCore";
    private readonly string pattern = "core";
    private readonly string replacement = "CORE";

    [Benchmark]
    public void ReplaceByRegexNew()
    {
        new Regex(pattern, RegexOptions.IgnoreCase).Replace(str, replacement);           
    }

    [Benchmark]
    public void ReplaceByRegexStatic()
    {
        Regex.Replace(str, pattern, replacement, RegexOptions.IgnoreCase);
    }

    [Benchmark]
    public void ReplaceByCutomSpan()
    {
        str.RepaceIgnoreCase(pattern, replacement, out var _);
    }
}
Method Mean Error StdDev Median
ReplaceByRegexNew 3,323.7 ns 115.82 ns 326.66 ns 3,223.4 ns
ReplaceByRegexStatic 881.9 ns 16.79 ns 43.94 ns 868.3 ns
ReplaceByCutomSpan 524.0 ns 4.78 ns 4.47 ns 524.9 ns

大動干戈一個晚上,沒多少提升,收支不成正比啊。

與Refit對比

在自家裏和老哥哥比沒意思,因此想跳出來和功能很是類似的Refit作比較看看,在比較以前,我是頗有信心的。爲了公平,二者都使用默認配置,都進行預熱,使用相同的接口定義:

配置與預熱

public abstract class BenChmark : IBenchmark
{
    protected IServiceProvider ServiceProvider { get; }

    public BenChmark()
    {
        var services = new ServiceCollection();

        services
            .AddHttpClient(typeof(HttpClient).FullName)
            .AddHttpMessageHandler(() => new MockResponseHandler());

        services
            .AddHttpApi<IWebApiClientCoreApi>()
            .AddHttpMessageHandler(() => new MockResponseHandler())
            .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://webapiclient.com/"));

        services
            .AddRefitClient<IRefitApi>()
            .AddHttpMessageHandler(() => new MockResponseHandler())
            .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://webapiclient.com/"));

        this.ServiceProvider = services.BuildServiceProvider();
        this.PreheatAsync().Wait();
    }

    private async Task PreheatAsync()
    {
        using var scope = this.ServiceProvider.CreateScope();

        var core = scope.ServiceProvider.GetService<IWebApiClientCoreApi>();
        var refit = scope.ServiceProvider.GetService<IRefitApi>();

        await core.GetAsyc("id");
        await core.PostAsync(new Model { });

        await refit.GetAsyc("id");
        await refit.PostAsync(new Model { });
    }
}

等同的接口定義

public interface IRefitApi
{
    [Get("/benchmarks/{id}")]
    Task<Model> GetAsyc(string id);

    [Post("/benchmarks")]
    Task<Model> PostAsync(Model model);
}

public interface IWebApiClientCoreApi
{
    [HttpGet("/benchmarks/{id}")]
    Task<Model> GetAsyc(string id);

    [HttpPost("/benchmarks")]
    Task<Model> PostAsync([JsonContent] Model model);
}

測試函數

/// <summary> 
/// 跳過真實的http請求環節的模擬Get請求
/// </summary>
public class GetBenchmark : BenChmark
{ 
    /// <summary>
    /// 使用原生HttpClient請求
    /// </summary>
    /// <returns></returns>
    [Benchmark]
    public async Task<Model> HttpClient_GetAsync()
    {
        using var scope = this.ServiceProvider.CreateScope();
        var httpClient = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient(typeof(HttpClient).FullName);

        var id = "id";
        var request = new HttpRequestMessage(HttpMethod.Get, $"http://webapiclient.com/{id}");
        var response = await httpClient.SendAsync(request);
        var json = await response.Content.ReadAsByteArrayAsync();
        return JsonSerializer.Deserialize<Model>(json);
    }


    /// <summary>
    /// 使用WebApiClientCore請求
    /// </summary>
    /// <returns></returns>
    [Benchmark]
    public async Task<Model> WebApiClientCore_GetAsync()
    {
        using var scope = this.ServiceProvider.CreateScope();
        var banchmarkApi = scope.ServiceProvider.GetRequiredService<IWebApiClientCoreApi>();
        return await banchmarkApi.GetAsyc(id: "id");
    }


    /// <summary>
    /// Refit的Get請求
    /// </summary>
    /// <returns></returns>
    [Benchmark]
    public async Task<Model> Refit_GetAsync()
    {
        using var scope = this.ServiceProvider.CreateScope();
        var banchmarkApi = scope.ServiceProvider.GetRequiredService<IRefitApi>();
        return await banchmarkApi.GetAsyc(id: "id");
    }
}

測試結果

去掉物理網絡請求時間段,WebApiClient的性能是Refit的3倍,我終於能夠安心的睡個好覺了!

總結

這文章寫得比較亂,是真實的記錄我在作性能調優的過程,實際上的過程當中,走過的大大小小彎路還更亂,要是寫下來文章就無法看了,有須要性能調優的朋友,不防跑一跑banchmark,你會有收穫的。

相關文章
相關標籤/搜索