一、背景
公司的一个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左右,但是因为本机性能和没有调优的原因响应会比较慢,但是没有请求失败的情况,这说明复用之后可以有效的提高性能。
图1.1000并发压测结果
2) 解决不同请求时复用的问题
静态化可以解决HttpClient的复用问题,但是如果这个项目中需要请求多个不同的接口地址,那么只是一个静态是解决不了的,因为HttpClient静态实例化之后,参数不能再次修改,怎么解决这个问题呢?
如果是在.Net Core 2.1及以上的项目中,可以直接使用 HttpClientFactory
来实现,具体 HttpClientFactory
的使用方法后面再讲。但是在.Net Framework项目中不支持 HttpClientFactory
,而且不直接支持注入,这里我用到另一个方法来实现类似的功能。
3) 解决依赖注入
如果我们把每个不同的请求都定义一个静态实例,那就完全没了写代码的乐趣。要解决这个问题,就需要依赖注入来实现。在.Net Framework中微软自身没有提供依赖注入的方法,我们可以借用第三方库来完成,我这里使用Autofac来实现,前提是项目为.Net Framework 4.6.1及以上,如果低于该版本,需要先对项目进行升级。
使用nuget分别安装 Autofac
、 Autofac.Integration.Mvc
和 Autofac.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+,同样没有请求失败的情况。
图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左右,不过同样稳定,没有请求失败的情况,性能同样很好。
图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 可以分别返回对应的接口的数据,说明我们这个方法成功了。继续压测一下,效果没有什么差别。
图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" />
我们运行项目,可以看到实现了注入,两项设置都成功加载了
图5.加载配置文件后进行注入
以上我们就完成了整个的优化,代码我放到了github上,其中 Biz126.HttpService
是封装后的HttpClient方法, Biz126.WebDemo
是前面的每一步的演示代码, Biz126.WebUI
是最终的调用代码。
Github:https://github.com/hongbai/HttpClientFactory
本文作者:老徐
本文链接:https://bigger.ee/archives/672.html
转载时须注明出处及本声明