在Asp.Net Core中集成Refit

  在很多时候我们在不同的服务之间需要通过HttpClient进行及时通讯,在我们的代码中我们会创建自己的HttpClient对象然后去跨领域额进行数据的交互,但是往往由于一个项目有多个人开发所以在开发中没有人经常会因为不同的业务请求去写不同的代码,然后就会造成各种风格的HttpClient的跨域请求,最重要的是由于每个人对HttpClient的理解程度不同所以写出来的代码可能质量上会有参差不齐,即使代码能够达到要求往往也显得非常臃肿,重复高我们在正式介绍Refit这个项目之前,我们来看看我们在项目中常用的调用方式,后面再来介绍这种处理方式的弊端以及后面集成了Refit以后我们代码的质量能够有哪些程度的提高。

  一  常规创建方式

  在常规的方式中我们一般使用IHttpClientFactory来创建HttpClient对象,然后使用这个对象来发送和接收消息,至于为什么要使用这个接口来创建HttpClient对象而不是使用using new HttpClient的原因请点击这里了解更多的信息,我们先来看下面的这个例子。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using Abp.Domain.Services;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

namespace Sunlight.Dms.Parts.Domain.Web {
    /// <summary>
    /// HttpClient的帮助类
    /// </summary>
    public class DcsPartClientService : DomainService {
        private readonly HttpClient _httpClient;
        private readonly ILogger<DcsPartClientService> _loggerHelper;

        public DcsPartClientService(IHttpClientFactory httpClientFactory,
                                    ILogger<DcsPartClientService> loggerHelper) {
            _loggerHelper = loggerHelper;
            _httpClient = httpClientFactory.CreateClient(PartsConsts.DcsPartClientName);
            if (_httpClient.BaseAddress == null) {
                throw new ArgumentNullException(nameof(httpClientFactory), $"没有配置名称为 {PartsConsts.DcsPartClientName} 的HttpClient,或者接口服务的地址为空");
            }
        }

        /// <summary>
        /// Post请求返回实体
        /// </summary>
        /// <param name="relativeUrl">请求相对路径</param>
        /// <param name="postObj">请求数据</param>
        /// <returns>实体T</returns>
        public async Task<List<T>> PostResponse<T>(string relativeUrl, object postObj) where T : class {

            var postData = JsonConvert.SerializeObject(postObj);

            _httpClient.DefaultRequestHeaders.Add("user-agent", "Dcs-Parts");
            _httpClient.CancelPendingRequests();
            _httpClient.DefaultRequestHeaders.Clear();
            HttpContent httpContent = new StringContent(postData);

            httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
            var result = default(List<T>);
            var response = await _httpClient.PostAsync(_httpClient.BaseAddress + relativeUrl, httpContent);
            if (response.StatusCode == HttpStatusCode.NotFound) {
                throw new ValidationException("找不到对应的DcsParts服务");
            }
            var responseContent = await response.Content.ReadAsAsync<ReceiveResponseBody<List<T>>>();
            if (response.IsSuccessStatusCode) {
                result = responseContent?.Payload;
            } else {
                if (!string.IsNullOrWhiteSpace(responseContent?.Message)) {
                    throw new ValidationException(responseContent.Message);
                }

                _loggerHelper.LogDebug($"请求返回结果:{0} 请求内容:{1}", response.StatusCode, postData);
            }

            return await Task.FromResult(result);
        }

        public async Task<List<T>> GetResponse<T>(string relativeUrl, object queryObj) where T : class {
            var queryData = ModelToUriQueryParam(queryObj);
            _httpClient.DefaultRequestHeaders.Add("user-agent", "Dcs-Parts");
            _httpClient.CancelPendingRequests();
            _httpClient.DefaultRequestHeaders.Clear();
            _httpClient.DefaultRequestHeaders.Add("accept", "application/json");

            var result = default(List<T>);
            var response = await _httpClient.GetAsync(_httpClient.BaseAddress + relativeUrl + queryData);
            if (response.StatusCode == HttpStatusCode.NotFound) {
                throw new ValidationException("找不到对应的DcsParts服务");
            }
            var responseContent = await response.Content.ReadAsAsync<ReceiveResponseBody<List<T>>>();
            if (response.IsSuccessStatusCode) {
                result = responseContent?.Payload;
            } else {
                if (!string.IsNullOrWhiteSpace(responseContent?.Message)) {
                    throw new ValidationException(responseContent.Message);
                }
            }

            return await Task.FromResult(result);
        }

        private string ModelToUriQueryParam<T>(T t, string url = "") {
            var properties = t.GetType().GetProperties();
            var sb = new StringBuilder();
            sb.Append(url);
            sb.Append("?");
            foreach (var p in properties) {
                var v = p.GetValue(t, null);
                if (v == null)
                    continue;

                sb.Append(p.Name);
                sb.Append("=");
                sb.Append(HttpUtility.UrlEncode(v.ToString()));
                sb.Append("&");
            }

            sb.Remove(sb.Length - 1, 1);

            return sb.ToString();
        }
    }

    public class ReceiveResponseBody<T> where T : class {
        public string Message { get; set; }

        public T Payload { get; set; }
    }

    public class ReceiveResponseBody {
        public string Message { get; set; }
    }


}

  1.1 注入IHttpClientFactory对象

  在这个过程中我们通过构造函数来注入IHttpClientFactory接口,然后用这个接口的CreateClient方法来创建一个唯一的HttpClient对象,在这里我们一般都会同步注入ILogger接口来记录日志信息从而便于我们排查线上问题,这里我们在CreateClient方法中传入了一个字符串类型的参数用于标记自己创建的HttpClient对象的唯一性。这里我们可以看到在构造函数中我们会去判断当前创建的HttpClient的BaseAddress,如果没有这个基地址那么程序会直接抛出错误提示,那么问题来了我们的HttpClient的BaseAddress到底在哪里配置呢?熟悉Asp.Net Core机制的朋友肯定一下子就会想到在Startup类中配置,那么我们来看看需要怎么配置。

  1.2 配置HttpClient的BaseAddress  

  public IServiceProvider ConfigureServices(IServiceCollection services) {
            //dcs.part服务
            services.AddHttpClient(PartsConsts.DcsPartClientName, config => {
                config.BaseAddress = new Uri(_appConfiguration["DependencyServices:DcsParts"]);
                config.Timeout = TimeSpan.FromSeconds(60);
            });      
        }  

  这里我只是简要截取了一小段内容,这里我们看到AddHttpClient的第一个参数也是一个字符串常量,这个常量应该是和IHttpClientFactory的CreateClient的方法中的那个常量保持绝对的一致,只有这样我们才能够标识唯一的标识一个HttpClient对象,创建完了之后我们就能够在这个里面去配置这个HttpClient的各种参数了,另外在上面的这段代码中_appConfiguration这个对象是通过Startup的构造函数注入的,具体的代码请参考下面。

public Startup(IHostingEnvironment env) {
            _appConfiguration = env.GetAppConfiguration();
            Clock.Provider = ClockProviders.Local;
            Environment = env;
            Console.OutputEncoding = System.Text.Encoding.UTF8;
        }  

  另外我们还需要配置一些HttpClient所必须的属性包括基地址、超时时间......等等,当然这个基地址我们是配置在appsetting.json中的,具体的配置如下所示。

 "DependencyServices": {
    "BlobStorage": "http://blob-storage/",
    "DcsParts": "http://dcs-parts/",
    "DmsAfterSales": "http://dms-after-sales/"
  }

  有了这些我们就能够具备创建一个HttpClient对象的条件了,后面我们来看看我们怎么使用这个HttpClient进行发送和接收数据。

  1.3 HttpClient进行数据的发送和接收

 /// <summary>
        /// Post请求返回实体
        /// </summary>
        /// <param name="relativeUrl">请求相对路径</param>
        /// <param name="postObj">请求数据</param>
        /// <returns>实体T</returns>
        public async Task<List<T>> PostResponse<T>(string relativeUrl, object postObj) where T : class {

            var postData = JsonConvert.SerializeObject(postObj);

            _httpClient.DefaultRequestHeaders.Add("user-agent", "Dcs-Parts");
            _httpClient.CancelPendingRequests();
            _httpClient.DefaultRequestHeaders.Clear();
            HttpContent httpContent = new StringContent(postData);

            httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
            var result = default(List<T>);
            var response = await _httpClient.PostAsync(_httpClient.BaseAddress + relativeUrl, httpContent);
            if (response.StatusCode == HttpStatusCode.NotFound) {
                throw new ValidationException("找不到对应的DcsParts服务");
            }
            var responseContent = await response.Content.ReadAsAsync<ReceiveResponseBody<List<T>>>();
            if (response.IsSuccessStatusCode) {
                result = responseContent?.Payload;
            } else {
                if (!string.IsNullOrWhiteSpace(responseContent?.Message)) {
                    throw new ValidationException(responseContent.Message);
                }

                _loggerHelper.LogDebug($"请求返回结果:{0} 请求内容:{1}", response.StatusCode, postData);
            }

            return await Task.FromResult(result);
        }

  在上面的代码中我们模拟了一个Post请求,请求完成以后我们再使用ReadAsAsync的方法来异步接收另外一个域中的数据,然后我们根据返回的StatusCode来抛出不同的错误提示,并记录相关的日志信息并返回最终Post请求的结果,进而完成整个过程,在这个中间我们发送请求的时候需要注意一下内容:1 最终的完整版地址=BaseAddress+RelativeAddress,基地址是在appsetting.json中进行配置的,RelativeAddress是我们请求不同域的时候的相对地址,这个需要我们根据实际的业务来进行配置。2 请求的对象是我们将数据对象序列化成json后的结果,这两点需要特别注意。

  1.4 总结

  通过上面的讲述我们知道了如何完整的创建HttpClient以及通过创建的HttpClient如何收发数据,但同时我们也发现了通过上面的方式我们的缺点:如果一个业务中有大量的这种跨域请求整个代码显得非常臃肿并且由于不同开发人员的认知不同最终导致很容易出问题,那么我们是否有办法能够去解决上面的问题呢?Refit库的出现正好解决了这个问题,Refit通过这种申明式的方式能够很大程度上让代码更加简练明了而且提供了更加丰富的功能。

  二  使用Refit来创建HttpClient对象

  2.1 引入Refit包

  在我们的项目中我们可以通过   <PackageReference Include="Refit" Version="XXX" />来快速引用Refit包,引用的方式这里便不再赘述。

  2.2 定义接口

  我们将我们业务中涉及到的方法定义在一个接口中,就像下面这样。

 public interface IDmsAfterSalesApi {

        [Headers("User-Agent: Dms-Parts")]
        [Post("/internal/api/v1/customerAccounts/update")]
        Task<ResponseBody> UpdateCustomerAmount([Body]PartRetailSettlementModel input);

        [Headers("User-Agent: Dms-Parts")]
        [Post("/internal/api/v1/repairShortagePart/checkCustomerAccount")]
        Task<RepairShortagePartResponseBody> RepairShortagePartCheckCustomerAccount([Body]RepairShortagePartModel input);

        [Headers("User-Agent: Dms-Parts")]
        [Post("/internal/api/v1/vehiclesAndMemberCode/forCoupons")]
        Task<GetMemberCodeBrandCodeForVehicleBody> GetMemberCodeBrandCodeForVehicle(Guid vehicleId);
    }

  2.3 注入接口并使用接口中的方法

    public class DmsAfterSalesClientService : DomainService {
        private readonly IDmsAfterSalesApi _api;
        private readonly ILogger<DcsPartClientService> _logger;
        private const string From = "Dms After Sales";

        public DmsAfterSalesClientService(IDmsAfterSalesApi api, ILogger<DcsPartClientService> logger) {
            _api = api;
            _logger = logger;
        }

        private async Task<Exception> WrapException(ApiException exception) {
            if (exception.StatusCode == System.Net.HttpStatusCode.BadRequest) {
                var receivedBody = await exception.GetContentAsAsync<ResponseBody>();
                return new ValidationException($"业务校验失败,{receivedBody.Message} ({From})", exception);
            } else {
                _logger.LogWarning(exception, "Call Dms After Sales API failed");
                return new ApplicationException($"内部调用失败,{exception.Message} ({exception.StatusCode}) ({From})", exception);
            }
        }

        private Exception WrapException(HttpRequestException exception) {
            _logger.LogWarning(exception, "Call Dms After Sales API failed");
            return new ApplicationException($"内部调用失败,{exception.Message} ({From})", exception);
        }

        public async Task UpdateCustomerAmount([Body] PartRetailSettlementModel input) {
            try {
                await _api.UpdateCustomerAmount(input);
            } catch (ApiException ex) {
                throw await WrapException(ex);
            } catch (HttpRequestException ex) {
                throw WrapException(ex);
            }
        }

        public async Task<decimal> RepairShortagePartCheckCustomerAccount([Body] RepairShortagePartModel input) {
            try {
                var result = await _api.RepairShortagePartCheckCustomerAccount(input);
                return result.Payload.BalanceAmount;
            } catch (ApiException ex) {
                throw await WrapException(ex);
            } catch (HttpRequestException ex) {
                throw WrapException(ex);
            }
        }

        public async Task<GetMemberCodeBrandCodeForVehicleOutput> GetMemberCodeBrandCodeForVehicle([Body]Guid vehicleId) {
            try {
                var result = await _api.GetMemberCodeBrandCodeForVehicle(vehicleId);
                return result.Payload;
            } catch (ApiException ex) {
                throw await WrapException(ex);
            } catch (HttpRequestException ex) {
                throw WrapException(ex);
            }
        }
    }

  在上面接口中定义好这个方法以后我们就可以直接在我们的领域类中引入这个接口IDmsAfterSalesApi ,然后就直接使用这个接口中的方法,讲到这里便有疑问,这个接口的实现到底在哪里?这里当我们定义好接口然后点击里面的方法转到实现的时候我们发现里面会转到一个叫做RefitStubs.g.cs的类中,然后自动的生成下面的方法。

    /// <inheritdoc />
    [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
    [global::System.Diagnostics.DebuggerNonUserCode]
    [Preserve]
    [global::System.Reflection.Obfuscation(Exclude=true)]
    partial class AutoGeneratedIDmsAfterSalesApi : IDmsAfterSalesApi
    {
        /// <inheritdoc />
        public HttpClient Client { get; protected set; }
        readonly IRequestBuilder requestBuilder;

        /// <inheritdoc />
        public AutoGeneratedIDmsAfterSalesApi(HttpClient client, IRequestBuilder requestBuilder)
        {
            Client = client;
            this.requestBuilder = requestBuilder;
        }

        /// <inheritdoc />
        Task<ResponseBody> IDmsAfterSalesApi.UpdateCustomerAmount(PartRetailSettlementModel input)
        {
            var arguments = new object[] { input };
            var func = requestBuilder.BuildRestResultFuncForMethod("UpdateCustomerAmount", new Type[] { typeof(PartRetailSettlementModel) });
            return (Task<ResponseBody>)func(Client, arguments);
        }

        /// <inheritdoc />
        Task<RepairShortagePartResponseBody> IDmsAfterSalesApi.RepairShortagePartCheckCustomerAccount(RepairShortagePartModel input)
        {
            var arguments = new object[] { input };
            var func = requestBuilder.BuildRestResultFuncForMethod("RepairShortagePartCheckCustomerAccount", new Type[] { typeof(RepairShortagePartModel) });
            return (Task<RepairShortagePartResponseBody>)func(Client, arguments);
        }

        /// <inheritdoc />
        Task<GetMemberCodeBrandCodeForVehicleBody> IDmsAfterSalesApi.GetMemberCodeBrandCodeForVehicle(Guid vehicleId)
        {
            var arguments = new object[] { vehicleId };
            var func = requestBuilder.BuildRestResultFuncForMethod("GetMemberCodeBrandCodeForVehicle", new Type[] { typeof(Guid) });
            return (Task<GetMemberCodeBrandCodeForVehicleBody>)func(Client, arguments);
        }
    }  

  这里面的核心是调用一个BuildRestResultFuncForMethod的方法,后面我们再来分析这里面到底是怎么实现的,这里我们首先把这整个使用流程说完,之前我们说过Refit的很多配置都是通过标签的方式来注入进去的,这里包括请求类型、相对请求地址,那么我们的默认超时时间和BaseAddress到底是怎样来配置的呢?下面我们就来重点讲述。

  2.4  在Startup中配置基础配置信息

  public IServiceProvider ConfigureServices(IServiceCollection services) {
            //refit dms after sales服务
            services.AddRefitClient<IDmsAfterSalesApi>()
                .ConfigureHttpClient(c => {
                    c.BaseAddress = new Uri(_appConfiguration["DependencyServices:DmsAfterSales"]);
                    c.Timeout = TimeSpan.FromMilliseconds(_appConfiguration.GetValue<int>("AppSettings:ServiceTimeOutMs"));
                });
        }

  这里我们看到通过一个AddRefitClient方法我们就能够去配置我们的基础信息,讲到这里我们是不是对整个过程都有一个清楚的认识呢?通过上下两种方式的对比,相信你对整个Refit的使用都有自己的理解。

  2.5 注意事项

  由于我们的Headers经常需要我们去配置一组数据,那么我们应该怎么配置多个项呢?

 [Headers("User-Agent: Dms-Parts", "Content-Type: application/json")]

  通过上面的方式我们能够配置一组Headers,另外在很多的时候如果Headers里面没有配置Content-Type那么很有可能会返回StatusCode=415 Unsupport Media Type这个类型的错误信息,这个在使用的时候需要注意。