一、背景

公司的一个asp.net项目,需要请求Http接口,并发比较高,会经常出现接口服务无响应同时IIS连接数非常高的现象,但是在服务端观察请求只有几十个,而IIS上的总连接数达到了几万,影响了业务的正常使用。

二、排查

因为服务端是用C++自己写的一个Web服务,先是怀疑服务端的承载能力不够,用压测工具直接压服务端接口,吞吐量可以达到600QPS,可以满足目前的业务需求,说明瓶颈不在服务端,而在客户端。
分别尝试了HttpWebRequest的同步方法和异步方法,以及HttpClient的异步方法,压测的结果显示这三种方法的性能从高到低顺序是:HttpWebRequest同步方法> HttpWebRequest异步方法>HttpClient异步方法。
使用4000并发压测,HttpWebRequest同步方法吞吐量在120QPS左右,HttpWebRequest异步方法在100左右,HttpClient异步方法只有40个左右,相差非常大。

三、解决

1.分析

要提高Http请求的效率,一般有两种方法,一是维护一个连接池,二是连接复用。HttpWebRequest自带连接池,所以在前面的测试结果中,HttpWebRequest的吞吐量比较高;而HttpWebRequest的异步方法很可能是伪异步,所以效果不是特别明显;HttpClient也只有异步方法,但是却是表现最差的,原因就在每次请求都会重新实例化,请求结束后释放资源,在实例化和释放资源上损耗大量性能。

2.方案

有两种方案,一是使用HttpWebRequest的同步方法,维护好连接池并调优参数,但是这对耐力是一个很大的考验,而且没有把握可以做到让人满意的结果;第二个方案就是复用HttpClient,但是对原有项目的改动比较大,但是更有把握成功。我这里选择的是复用HttpClient。

3.过程

1) 静态化HttpClient

首先尝试静态化HttpClient来实现复用,测试效果,代码如下:

    public class DemoController : ApiController
    {
        private static readonly HttpClient httpClient = new HttpClient() { BaseAddress = new Uri("http://localhost") };
        public async Task<string> Post()
        {
            using (var responseData = await httpClient.PostAsync("/", new StringContent("")))
            {
                return await responseData.Content.ReadAsStringAsync();
            }
        }
    }

测试发现返回的HashCode是一致的,说明实现了复用;并发1000压测之后会发现吞吐量可以达到700左右,但是因为本机性能和没有调优的原因响应会比较慢,但是没有请求失败的情况,这说明复用之后可以有效的提高性能。

HttpClient简单复用.png
图1.1000并发压测结果

2) 解决不同请求时复用的问题

静态化可以解决HttpClient的复用问题,但是如果这个项目中需要请求多个不同的接口地址,那么只是一个静态是解决不了的,因为HttpClient静态实例化之后,参数不能再次修改,怎么解决这个问题呢?
如果是在.Net Core 2.1及以上的项目中,可以直接使用 HttpClientFactory 来实现,具体 HttpClientFactory 的使用方法后面再讲。但是在.Net Framework项目中不支持 HttpClientFactory ,而且不直接支持注入,这里我用到另一个方法来实现类似的功能。

3) 解决依赖注入

如果我们把每个不同的请求都定义一个静态实例,那就完全没了写代码的乐趣。要解决这个问题,就需要依赖注入来实现。在.Net Framework中微软自身没有提供依赖注入的方法,我们可以借用第三方库来完成,我这里使用Autofac来实现,前提是项目为.Net Framework 4.6.1及以上,如果低于该版本,需要先对项目进行升级。
使用nuget分别安装 AutofacAutofac.Integration.MvcAutofac.Integration.WebApi 这三个扩展,个别情况还需要安装 System.ValueTuple ,因环境而异。
创建类 IocConfig ,代码如下:

    public class IocConfig
    {
        public static void RegisterDependencies()
        {
            ContainerBuilder builder = new ContainerBuilder();
            HttpConfiguration config = GlobalConfiguration.Configuration;
            builder.RegisterApiControllers(Assembly.GetExecutingAssembly());
            HttpClient httpClient = new HttpClient
            {
                BaseAddress = new Uri("http://localhost")
            };
            builder.Register(x=>httpClient).As<HttpClient>().SingleInstance();

            var container = builder.Build();
            config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
        }
    }

在Global.asax中的 Application_Start 方法内,增加:

   //依赖注入
   IocConfig.RegisterDependencies();

我们去看一下注入有没有成功,在控制器的构造函数中增加要注入的内容:

        private static HttpClient _httpClient;

        public DemoController(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }

之后调用:

        public async Task<string> Post()
        {
            using (var responseData = await _httpClient.PostAsync("/", new StringContent("")))
            {
                return  await responseData.Content.ReadAsStringAsync();
            }
        }

之后我们测试一下,可以发现httpClient实例的HashCode保持一致,说明实现了复用,同样使用1000并发2分钟进行压测,压测结果与1) 的相差不大甚至略好于第一次,吞吐量稳定在700+,同样没有请求失败的情况。

HttpClient依赖注入复用.png

图2.依赖注入后1000并发压测结果

这里成功的通过依赖注入实现了HttpClient的复用,下面我们要继续改造,对HttpClient进行封装。

4) 进一步优化,对HttpClient进行封装

新建类库项目 HttpService ,添加接口 IHttpService ,接口中增加一个异步Post方法:

    public interface IHttpService
    {
        /// <summary>
        /// 异步POST方法
        /// </summary>
        /// <param name="path">请求路径</param>
        /// <param name="paramsData">参数</param>
        /// <param name="charset">编码</param>
        /// <param name="contentType">类型</param>
        /// <returns></returns>
        Task<string> PostAsync(string path, string paramsData, string charset, string contentType);
    }

创建 HttpService 类,用来实现 IHttpService 接口:

    public class HttpService:IHttpService
    {
        private readonly HttpClient _client;

        public HttpService(HttpClient httpClient)
        {  
            _client = httpClient;
        }

        /// <summary>
        /// 异步Post提交
        /// </summary>
        /// <param name="path"></param>
        /// <param name="paramsData"></param>
        /// <param name="charset"></param>
        /// <param name="contentType"></param>
        /// <returns></returns>
        public async Task<string> PostAsync(string path,string paramsData,string charset,string contentType)
        {                    
            try
            { 
                using (var responseData = await _client.PostAsync(path, new StringContent(paramsData, Encoding.GetEncoding(charset), contentType)))
                {
                    return await responseData.Content.ReadAsStringAsync();
                }
            }
            catch (System.Threading.ThreadAbortException e)
            {
                System.Threading.Thread.ResetAbort();
            }
            catch (WebException e)
            {
            }
            catch (Exception e)
            {
                return "";
            }
            finally
            {                
            }            
        }
    }

修改Autofac的依赖注入配置文件中的代码:

    public class IocConfig
    {
        public static void RegisterDependencies()
        {
            ContainerBuilder builder = new ContainerBuilder();
            HttpConfiguration config = GlobalConfiguration.Configuration;
            builder.RegisterApiControllers(Assembly.GetExecutingAssembly());

            HttpClient httpClient = new HttpClient
            {
                BaseAddress = new Uri("http://localhost")
            };
            builder.Register(x => new HttpService.HttpService(httpClient)).As<HttpService.IHttpService>().SingleInstance();

            var container = builder.Build();
            config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
        }
    }

修改注入处的构造函数和调用方法,这里是直接把我们封装好的HttpService注入了进来:

        private readonly HttpService.IHttpService _httpService;

        public DemoController(HttpService.IHttpService httpService)
        {
            _httpService = httpService;
        }

        public async Task<string> Post()
        {
            return await _httpService.PostAsync("/", "", "utf-8", "text/html");
        }

同样1000并发2分钟压测,吞吐量比前面的两个少了一点,稳定在680~690左右,不过同样稳定,没有请求失败的情况,性能同样很好。

HttpClient简单封装后依赖注入.png

图3.对HttpClient进行简单封装后依赖注入,1000并发压测结果

这样我们就可以复用我们封装好了的Http请求的方法了,但是如果要提前不同的接口,这里就不行了,下面继续优化。

5) 多个HttpClient实例注入

如果我们的项目需要请求多个不同的第三方接口,只需要改造一下 IocConfig 和注入的参数就可以了,IocConfig的修改:

    public class IocConfig
    {
        public static void RegisterDependencies()
        {
            ContainerBuilder builder = new ContainerBuilder();
            HttpConfiguration config = GlobalConfiguration.Configuration;
            builder.RegisterApiControllers(Assembly.GetExecutingAssembly());

            var httpServices = new Dictionary<string, HttpService.IHttpService>
            {
                {
                    "demo1",
                    new HttpService.HttpService(new HttpClient(){ BaseAddress=new Uri("http://localhost")})
                },
                {
                    "demo2",
                    new HttpService.HttpService(new HttpClient(){ BaseAddress=new Uri("http://localhost:8001")})
                }
            };
            builder.RegisterInstance(httpServices).As<Dictionary<string, HttpService.IHttpService>>().SingleInstance();

            var container = builder.Build();
            config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
        }
    }

这里是分别实例化多个 HttpService.HttpService 方法,放到 Dictionary<string,HttpService.IHttpService> 中,之后把 Dictionary<string,HttpService.IHttpService> 通过控制器的构造函数注入进来,相应的我们的控制器的构造函数作如下的改动:

        private readonly Dictionary<string, HttpService.IHttpService> _httpServices;

        public DemoController(Dictionary<string, HttpService.IHttpService> httpService)
        {
            _httpServices = httpService;
        }

        public async Task<string> Post([FromUri]string key="demo1")
        {
            return await _httpServices[key].PostAsync("/", "", "utf-8", "text/html");
        }

测试一下,参数key分别传demo1和demo2 可以分别返回对应的接口的数据,说明我们这个方法成功了。继续压测一下,效果没有什么差别。

多个不同Http服务接口的HttpClient依赖注入复用.png

图4.多HttpClient实例注入压测结果

6) 进一步优化

如果每增加一个需要调用的第三方API就改一次IocConfig,那就太不方便了,我们可以再进一步优化,把这些放到配置文件中,只需要维护配置文件就可以了。
继续在第5)步的基础上进行封装,HttpService 项目中增加一个Config实体类,包含这些属性:

    /// <summary>
    /// 配置
    /// </summary>
    public class Config
    {
        /// <summary>
        /// 标识
        /// </summary>
        public string Key { get; set; }

        /// <summary>
        /// 中文名称
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// Http请求服务器
        /// </summary>
        public string BaseAddress { get; set; }

        /// <summary>
        /// 超时时间
        /// </summary>
        public int Timeout { get; set; } = 0;

        /// <summary>
        /// Http请求头
        /// </summary>
        public Dictionary<string, string> Headers { get; set; }
    }

创建配置文件,我这里使用json文件,放到了 App_Data 文件夹中,命名为 httpConfig.json,内容如下:

[
  {
    "Key": "demo1",
    "Name": "演示1接口",
    "BaseAddress": "http://localhost",
    "Headers": {
      "Accept": "application/json",
      "User-Agent": "Biz126-HttpClient",
      "KeepAlive": "true"
    },
    "Timeout": 10
  },
  {
    "Key": "demo2",
    "Name": "演示2接口",
    "BaseAddress": "http://localhost:8001",
    "Headers": {
      "Accept": "application/json",
      "User-Agent": "Biz126-HttpClient",
      "KeepAlive": "true"
    },
    "Timeout": 10
  }
]

新建类 HttpConfig 用来处理配置文件,代码如下:

   /// <summary>
    /// 处理配置
    /// </summary>
    public class HttpConfig
    {
        string configPath;
        public HttpConfig()
        {
            configPath = string.Format("{0}{1}", HttpRuntime.AppDomainAppPath, System.Web.Configuration.WebConfigurationManager.AppSettings["httpConfig"]);
        }

        public HttpConfig(string path)
        {
            configPath = string.Format("{0}{1}", HttpRuntime.AppDomainAppPath, path);
        }

        /// <summary>
        /// 取链接配置
        /// </summary>
        /// <returns></returns>
        public List<Config> GetConfig()
        {
            if (File.Exists(configPath))
            {
                using (StreamReader f2 = new StreamReader(configPath, System.Text.Encoding.GetEncoding("gb2312")))
                {
                    return JsonConvert.DeserializeObject<List<Config>>(f2.ReadToEnd());
                }                   
            }
            return new List<Config>();
        }

        /// <summary>
        /// 指定链接的配置
        /// </summary>
        /// <param name="UrlAddress"></param>
        /// <returns></returns>
        public Config GetConfig(string key)
        {
            var list = GetConfig();
            if (list.Count > 0)
            {
                return list.Where(x => (x.Key.Equals(key, StringComparison.OrdinalIgnoreCase))).FirstOrDefault();
            }
            return new Config();
        }

        /// <summary>
        /// 生成HttpService列表
        /// </summary>
        /// <returns></returns>
        public Dictionary<string, IHttpService> HttpServiceList()
        {
            var dict=new Dictionary<string, IHttpService>();
            GetConfig().ForEach(x =>
            {
                var httpClient = new HttpClient();
                dict.Add(x.Key, new HttpService(httpClient, x));
            });
            return dict;
        }
    }

修改 HttpService 类,在构造函数中增加配置相关的设置:

        /// <summary>
        /// 构建Http方法
        /// </summary>
        /// <param name="httpClient">HttpClient实例</param>
        /// <param name="config">配置</param>
        public HttpService(HttpClient httpClient,Config config)
        {            
            httpClient.BaseAddress = new Uri(config.BaseAddress);
            foreach (var header in config.Headers)
            {
                httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
            }
            
            httpClient.Timeout = new TimeSpan(config.Timeout*10000000);
            httpClient.DefaultRequestHeaders.ExpectContinue = false;  //关闭Expect:[100-continue],默认为开启状态   很多旧的HTTP/1.0和HTTP/1.1应用不支持Expect头部
            _client = httpClient;

            //忽略https证书有效性检查
            if (_client.BaseAddress.Scheme.StartsWith("https", StringComparison.OrdinalIgnoreCase))
            {
                ServicePointManager.ServerCertificateValidationCallback =
                        new RemoteCertificateValidationCallback(CheckValidationResult);
            }
        }

        /// <summary>
        /// 忽略证书
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="certificate"></param>
        /// <param name="chain"></param>
        /// <param name="errors"></param>
        /// <returns></returns>
        public static bool CheckValidationResult(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
        {
            //直接确认,否则打不开    
            return true;
        }

修改 IocConfig 中的注入相关方法:

builder.RegisterInstance(new HttpService.HttpConfig().HttpServiceList()).As<Dictionary<string, HttpService.IHttpService>>().SingleInstance();

注入点的构造函数和调用方法不变,如下:

        private readonly Dictionary<string, HttpService.IHttpService> _httpServices;

        public DemoController(Dictionary<string, HttpService.IHttpService> httpService)
        {
            _httpServices = httpService;
        }

        public async Task<string> Post([FromUri]string key="demo1")
        {
            return await _httpServices[key].PostAsync("/", "", "utf-8", "text/html");
        }

最后在Web.config中的AppSettings节点下,增加一项配置:

<add key="httpConfig" value="/App_Data/httpConfig.json" />

我们运行项目,可以看到实现了注入,两项设置都成功加载了
配置文件自动加载.png
图5.加载配置文件后进行注入

以上我们就完成了整个的优化,代码我放到了github上,其中 Biz126.HttpService 是封装后的HttpClient方法, Biz126.WebDemo 是前面的每一步的演示代码, Biz126.WebUI 是最终的调用代码。

Github:https://github.com/hongbai/HttpClientFactory

Last modification:July 25, 2020
如果觉得我的文章对你有用,请随意赞赏