擴充套件ABP的Webhook功能,推播資料到第三方介面(企業微信群、釘釘群等)

2023-09-08 06:00:27

前言

在上一篇文章【基於ASP.NET ZERO,開發SaaS版供應鏈管理系統】中有提到對Webhook功能的擴充套件改造,本文詳細介紹一下具體過程。

Webhook功能操作說明,請參見此檔案連結:Webhook資料推播

Webhook功能釋出日期:

  • ASP.NET Boilerplate(以下簡稱ABP)在v5.2(2020-02-18)版本中釋出了Webhook功能,詳細說明,請參見:官方幫助連結
  • ASP.NET ZERO(以下簡稱ZERO)在v8.2.0(2020-02-20)版本中釋出了Webhook功能;
  • 我們系統是在2021年4月完成了對Webhook功能的改造:內部介面(使用者自行設定介面地址的)、第三方介面(微信內部群、釘釘群、聚水潭API等)。

1、Webhook定義

  • 為了區分內部介面與第三方介面,在第三方介面名稱前統一附加特定字首,如:Third.WX.XXX、Third.DD.XXX等;
  • 新增定義條目時候設定對應的特性(featureDependency),基於特性功能對不同租戶顯示或者隱藏定義的條目。
    public class AppWebhookDefinitionProvider : WebhookDefinitionProvider
    {
        public override void SetWebhooks(IWebhookDefinitionContext context)
        {
            //物料檔案 - 全部可見
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T11071001_Created));
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T11071001_Updated));
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T11071001_Deleted));

            //生產訂單 - 生產管理可見
            var featureC = new SimpleFeatureDependency("SCM.C");
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T13041001_Created, featureDependency: featureC));
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T13041001_Updated, featureDependency: featureC));
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T13041001_Deleted, featureDependency: featureC));
            context.Manager.Add(new WebhookDefinition(name: AppWebHookNames.T13041001_MRP_Data, featureDependency: featureC));

            //...
        }
    }
  • CoreModule中新增Webhook定義,並設定引數選項:
    public class SCMCoreModule : AbpModule
    {
        public override void PreInitialize()
        {
            Configuration.Webhooks.Providers.Add<AppWebhookDefinitionProvider>();
            Configuration.Webhooks.TimeoutDuration = TimeSpan.FromMinutes(1);
            Configuration.Webhooks.IsAutomaticSubscriptionDeactivationEnabled = true;
            Configuration.Webhooks.MaxSendAttemptCount = 3;
            Configuration.Webhooks.MaxConsecutiveFailCountBeforeDeactivateSubscription = 10;

            //...
        }

        //...
    }

2、Webhook訂閱

  • 前端使用者建立Webhook訂閱記錄(WebhookUri、Webhooks、Headers等),之後傳遞到後端API;
  • 後端API通過WebhookSubscriptionManager新增儲存WebhookSubscription(Webhook訂閱):
    [AbpAuthorize(AppPermissions.Pages_Administration_WebhookSubscription)]
    public class WebhookSubscriptionAppService : SCMAppServiceBase, IWebhookSubscriptionAppService
    {
        //...

        [AbpAuthorize(AppPermissions.Pages_Administration_WebhookSubscription_Create)]
        public async Task AddSubscription(WebhookSubscription subscription)
        {
            subscription.TenantId = AbpSession.TenantId;

            await _webHookSubscriptionManager.AddOrUpdateSubscriptionAsync(subscription);
        }

        //...
    }

3、Webhook釋出(資料推播)

監測實體事件(CreatedEvent、UpdatedEvent、DeletedEvent)資料,按租戶使用者建立的Webhook訂閱,推播資料:

    public class T11071001Syncronizer : 
        IEventHandler<EntityCreatedEventData<T11071001>>,
        IEventHandler<EntityUpdatedEventData<T11071001>>,
        IEventHandler<EntityDeletedEventData<T11071001>>,
        ITransientDependency
    {
        private readonly IAppWebhookPublisher _appWebhookPublisher;

        public T11071001Syncronizer(IAppWebhookPublisher appWebhookPublisher) 
        {
            _appWebhookPublisher = appWebhookPublisher;
        }
        public void HandleEvent(EntityCreatedEventData<T11071001> eventData)
        {
            DoWebhook("N", eventData.Entity);
        }

        public void HandleEvent(EntityUpdatedEventData<T11071001> eventData)
        {
            DoWebhook("U", eventData.Entity);
        }

        public void HandleEvent(EntityDeletedEventData<T11071001> eventData)
        {
            int? tenantId = eventData.Entity.TenantId; 
            string whName = AppWebHookNames.T11071001_Deleted;
            var subscriptions = _appWebhookPublisher.GetSubscriptions(tenantId, whName); 
            if (subscriptions == null) { return; }

            _appWebhookPublisher.PublishWebhookUOW(whName, eventData.Entity, tenantId, subscriptions);
        }

    }
  • DoWebhook()方法:基於具體的訂閱(內部介面、第三方介面等)推播對應的內容:
        private void DoWebhook(string nu, T11071001 entity)
        {
            int? tenantId = entity.TenantId;
            var whCache = _appWebhookPublisher.GetWebhookCache(tenantId); if (whCache.Count == 0) { return; }

            string whName = nu == "N" ? AppWebHookNames.T11071001_Created : AppWebHookNames.T11071001_Updated;
            string whNameWX = AppWebHookNames.WX_T11071001_Created;
            string whNameDD = AppWebHookNames.DD_T11071001_Created;

            bool isWH = whCache.Names.ContainsKey(whName);
            bool isWX = whCache.Names.ContainsKey(whNameWX);
            bool isDD = whCache.Names.ContainsKey(whNameDD);

            if (!(isWH || isWX || isDD)) { return; }

            var data = ObjectMapper.Map<T11071001WebhookDto>(entity);

            //內部介面
            if (isWH)
            {
                _appWebhookPublisher.PublishWebhookUOW(whName, data, tenantId, whCache.Names[whName], false);
            }

            //企業微信內部群
            if (isWX)
            {
                var wxData = new WxTCardWebhookDto { template_card = GetWxTCard(data, tenantId, nu) };
                _appWebhookPublisher.PublishWebhookUOW(whNameWX, wxData, tenantId, whCache.Names[whNameWX], true);
            }

            //釘釘內部群
            if (isDD)
            {
                var title = GetNUTitle(nu, L(T));
                var mdText = GetNewMarkdown(data, title);
                var ddData = new DdMarkdownWebhookDto { markdown = new DdMarkdownContentDto { title = title, text = mdText } };
                _appWebhookPublisher.PublishWebhookUOW(whNameDD, ddData, tenantId, whCache.Names[whNameDD], true);
            }
        }
  • GetWebhookCache()方法:實現按租戶快取Webhook訂閱的資料:
        public SCMWebhookCacheItem GetWebhookCache(int? tenantId)
        {
           return SetAndGetCache(tenantId);
        }

        private SCMWebhookCacheItem SetAndGetCache(int? tenantId, string keyName = "SubscriptionCount")
        {
           int tid = tenantId ?? 0; var cacheKey = $"{keyName}-{tid}";

           return _cacheManager.GetSCMWebhookCache().Get(cacheKey, () =>
           {
                int count = 0;
                var names = new Dictionary<string, List<WebhookSubscription>>();

                UnitOfWorkManager.WithUnitOfWork(() =>
                {
                    using (UnitOfWorkManager.Current.SetTenantId(tenantId))
                    {
                        if (_featureChecker.IsEnabled(tid, "SCM.H")) //Feature核查
                        {
                            var items = _webhookSubscriptionRepository.GetAllList(e => e.TenantId == tenantId && e.IsActive == true);
                            count = items.Count;

                            foreach (var item in items)
                            {
                                if (string.IsNullOrWhiteSpace(item.Webhooks)) { continue; }
                                var whNames = JsonHelper.DeserializeObject<string[]>(item.Webhooks); if (whNames == null) { continue; }
                                foreach (string whName in whNames)
                                {
                                    if (names.ContainsKey(whName))
                                    {
                                        names[whName].Add(item.ToWebhookSubscription());
                                    }
                                    else
                                    {
                                        names.Add(whName, new List<WebhookSubscription> { item.ToWebhookSubscription() });
                                    }
                                }
                            }
                        }
                    }
                });

                return new SCMWebhookCacheItem(count, names);
            });
        }
  • PublishWebhookUOW()方法:替換ABP中WebHookPublisher的預設實現,直接按傳入的訂閱,通過WebhookSenderJob推播資料:
        public void PublishWebhookUOW(string webHookName, object data, int? tenantId, List<WebhookSubscription> webhookSubscriptions = null, bool sendExactSameData = false)
        {
            UnitOfWorkManager.WithUnitOfWork(() =>
            {
                using (UnitOfWorkManager.Current.SetTenantId(tenantId))   
                {
                    Publish(webHookName, data, tenantId, webhookSubscriptions, sendExactSameData);
                }
            });
        }

        private void Publish(string webhookName, object data, int? tenantId, List<WebhookSubscription> webhookSubscriptions, bool sendExactSameData = false)
        {
            if (string.IsNullOrWhiteSpace(webhookName)) { return; }

            //若無直接傳入訂閱則按webhookName查詢
            webhookSubscriptions ??= _webhookSubscriptionRepository.GetAllList(subscriptionInfo =>
                    subscriptionInfo.TenantId == tenantId &&
                    subscriptionInfo.IsActive &&
                    subscriptionInfo.Webhooks.Contains("\"" + webhookName + "\"")
                ).Select(subscriptionInfo => subscriptionInfo.ToWebhookSubscription()).ToList();

            if (webhookSubscriptions.IsNullOrEmpty()) { return; }

            var webhookInfo = SaveAndGetWebhookEvent(tenantId, webhookName, data);

            foreach (var webhookSubscription in webhookSubscriptions)
            {
                var jobArgs = new WebhookSenderArgs
                {
                    TenantId = webhookSubscription.TenantId,
                    WebhookEventId = webhookInfo.Id,
                    Data = webhookInfo.Data,
                    WebhookName = webhookInfo.WebhookName,
                    WebhookSubscriptionId = webhookSubscription.Id,
                    Headers = webhookSubscription.Headers,
                    Secret = webhookSubscription.Secret,
                    WebhookUri = webhookSubscription.WebhookUri,
                    SendExactSameData = sendExactSameData
                };

                //指定佇列執行任務,由觸發事件的server執行
                IBackgroundJobClient hangFireClient = new BackgroundJobClient();
                hangFireClient.Create<WebhookSenderJob>(x => x.ExecuteAsync(jobArgs), new EnqueuedState(AppVersionHelper.MachineName));
            }
        }

  • WebhookSenderJob:重寫WebhookManager的SignWebhookRequest方法,對於第三方介面,不新增簽名的Header:
        public override void SignWebhookRequest(HttpRequestMessage request, string serializedBody, string secret)
        {
            if (request == null)
            {
                throw new ArgumentNullException(nameof(request));
            }

            //第三方介面,不新增簽名Header
            if (IsThirdAPI(request))
            {
                return;
            }

            if (string.IsNullOrWhiteSpace(serializedBody))
            {
                throw new ArgumentNullException(nameof(serializedBody));
            }

            var secretBytes = Encoding.UTF8.GetBytes(secret);

            using (var hasher = new HMACSHA256(secretBytes))
            {
                request.Content = new StringContent(serializedBody, Encoding.UTF8, "application/json");

                var data = Encoding.UTF8.GetBytes(serializedBody);
                var sha256 = hasher.ComputeHash(data);

                var headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, BitConverter.ToString(sha256));

                request.Headers.Add(SignatureHeaderName, headerValue);
            }
        }
  • WebhookSenderJob:重寫WebhookSender的CreateWebhookRequestMessage方法,對於第三方介面,進行特殊處理:
        protected override HttpRequestMessage CreateWebhookRequestMessage(WebhookSenderArgs webhookSenderArgs)
        {
            return webhookSenderArgs.WebhookName switch
            {
                AppWebHookNames.JST_supplier_upload => JSTHttpRequestMessage(webhookSenderArgs), //聚水潭 - 供應商上傳
                //...
                _ => new HttpRequestMessage(HttpMethod.Post, webhookSenderArgs.WebhookUri)
            };
        }
  • WebhookSenderJob:重寫WebhookSender的AddAdditionalHeaders方法, 對於第三方介面,不新增Headers:
        protected override void AddAdditionalHeaders(HttpRequestMessage request, WebhookSenderArgs webhookSenderArgs)
        {
            //第三方介面,不新增Header
            if (IsThirdAPI(request))
            {
                return;
            }

            foreach (var header in webhookSenderArgs.Headers)
            {
                if (request.Headers.TryAddWithoutValidation(header.Key, header.Value))
                {
                    continue;
                }

                if (request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value))
                {
                    continue;
                }

                throw new Exception($"Invalid Header. SubscriptionId:{webhookSenderArgs.WebhookSubscriptionId},Header: {header.Key}:{header.Value}");
            }
        }
  • WebhookSenderJob:重寫WebhookSender的SendHttpRequest方法,處理第三方介面的回傳資料:
        protected override async Task<(bool isSucceed, HttpStatusCode statusCode, string content)> SendHttpRequest(HttpRequestMessage request)
        {
            using var client = _httpClientFactory.CreateClient(); //避免使用 new HttpClient()方式
            client.Timeout = _webhooksConfiguration.TimeoutDuration;

            var response = await client.SendAsync(request);

            var isSucceed = response.IsSuccessStatusCode;
            var statusCode = response.StatusCode;
            var content = await response.Content.ReadAsStringAsync();

            //第三方介面,需要處理回傳的資料     
            if (IsThirdAPI(request))
            {
                string method = TryGetHeader(request.Headers, "ThirdAPI1");
                int tenantId = Convert.ToInt32(TryGetHeader(request.Headers, "ThirdAPI2"));
                switch (method)
                {
                    case AppWebHookNames.JST_supplier_upload: await JSTSupplierUploadResponse(method, content, tenantId); break;
                    //...
                    default: break;
                }
            }

            return (isSucceed, statusCode, content);
        }

總結

基於ABP/ZERO的Webhook功能實現,進行一些擴充套件改造,可以實現業務資料按使用者訂閱進行推播,包括推播到第三方介面(企業微信群、釘釘等),在很大程度上提升了業務系統的靈活性與實用性。