در این مقاله می خواهیم با هم پیاده سازی یک سناریو وب هوک (Webhook) را توسط الگوی Pub/Sub در Asp.net Core 3.1 انجام دهیم.
وب هوک ها برای تعامل سیستم های بیرونی به صورت رویداد محور هستند، مثل انتشار قطعه کد در یک ریپازیتوری، انتشار یک مقاله در یک بلاگ و یا دریافت یک ایمیل. زمانی این رویداد ها رخ می دهند، سرویس مبدا رویداد با ایجاد درخواست HTTP اطلاعاتی را به آدرس وب هوک ارسال می نماید. کاربر می تواند اطلاعات و رفتار وب هوک را برای ارسال اطلاعات به URL های خود تنظیم پیکربندی نماید.
(https://en.wikipedia.org/wiki/Webhook)
یک مکانیزم Webhook را به کار برده ایم که از الگوی publish-subscribe در پروژه های ASP.NET Boilerplate و ASP.NET ZERO ما استفاده می کند. کاربران می توانند در یک رویداد Webhook عضو شوند سپس هنگامی که آن رویداد اتفاق می افتد ، برنامه یک Webhook را برای endpoints مشترک منتشر می کند.
توجه: در این مقاله ، هیچ کد عملیاتی اصلی CRUD را نشان نمی دهم. من فقط بخش هایی را که فکر می کنم مهم هستند به اشتراک می گذارم. اگر می خواهید همه کد ها و موارد دیگر را بررسی کنید به اینجا مراجعه کنید.
موجودیت WebhookSubscription: که ما اشتراک Webhook را ذخیره خواهیم کرد.
public class WebhookSubscription { /// <summary> /// Subscription webhook endpoint /// </summary> public string WebhookUri { get; set; } /// <summary> /// Webhook secret /// </summary> public string Secret { get; set; } /// <summary> /// Is subscription active /// </summary> public bool IsActive { get; set; } /// <summary> /// Subscribed webhook definitions unique names. <see cref="WebhookDefinition.Name"/> /// </summary> public List<string> Webhooks { get; set; } /// <summary> /// Gets a set of additional HTTP headers. That headers will be sent with the webhook. /// </summary> public IDictionary<string, string> Headers { get; set; } public WebhookSubscription() { IsActive = true; Headers = new Dictionary<string, string>(); Webhooks = new List<string>(); } }
نکته مهم کد امنیتی (Secret) اشتراک است. شما باید هنگام ایجاد اشتراک Webhook آن را تنظیم کنید و هرگز آن را تغییر ندهید. (مگر اینکه منطق سفارشی شما حاوی چیزی مانند تغییر Webhook کد امنیتی (Secret) اشتراک باشد).
public class WebhookSubscriptionManager { public const string WebhookSubscriptionSecretPrefix = "whs_" IRepository<WebhookSubscription> _webhookSubscriptionRepository; ... public async Task AddSubscriptionAsync(WebhookSubscription webhookSubscription) { webhookSubscription.Secret = WebhookSubscriptionSecretPrefix + Guid.NewGuid().ToString("N"); await _webhookSubscriptionRepository.InsertAsync(webhookSubscription); } }
قبل از ارسال آن به مشتری ، Webhook payload خود را با استفاده از کد امنیتی (Secret) اشتراک درست می کنیم. سپس هنگامی که مشتری Webhook را دریافت کرد ، بررسی می کند که آیا Webhook payload به درستی امضا شده است یا خیر. مشتری می تواند تأیید کند که داده ها از منبع صحیح تهیه شده اند. بنابراین ، کد امنیتی (Secret) اشتراک مهم است.
می توانید با عملیات اصلی CRUD آن را مدیریت کنید. به طور مثال WebhookSubscriptionManager
موجودیت WebhookEvent: اطلاعات Webhook را در موجودیتی ذخیره می کنیم. رویدادهای Webhook برای تعداد زیادی از اشتراک ها به صورت همزمان قابل ارسال است. از آنجا که ما Webhook را در یک کار پس زمینه جداگانه دریافت می کنیم ، نیاز است که داده های Webhook را برای استفاده مجدد بازیابی کنیم.
public class WebhookEvent { /// <summary> /// Webhook unique name /// </summary> [Required] public string WebhookName { get; set; } /// <summary> /// Webhook data as JSON string. /// </summary> public string Data { get; set; } }
می توانید با عملیات اصلی CRUD آن را مدیریت کنید. به طور مثال WebhookEventStore
موجودیت WebhookSendAttempt: موجودی که ما تمام اطلاعات مربوط به پروسه ارسال Webhook را ذخیره خواهیم کرد.
public class WebhookSendAttempt { /// <summary> /// <see cref="WebhookEvent"/> foreign id /// </summary> [Required] public Guid WebhookEventId { get; set; } /// <summary> /// <see cref="WebhookSubscription"/> foreign id /// </summary> [Required] public Guid WebhookSubscriptionId { get; set; } /// <summary> /// Webhook response content that webhook endpoint send back /// </summary> public string Response { get; set; } /// <summary> /// Webhook response status code that webhook endpoint send back /// </summary> public HttpStatusCode? ResponseStatusCode { get; set; } public DateTime CreationTime { get; set; } public DateTime? LastModificationTime { get; set; } /// <summary> /// WebhookEvent of this send attempt. /// </summary> [ForeignKey("WebhookEventId")] public virtual WebhookEvent WebhookEvent { get; set; } }
می توانید با عملیات اصلی CRUD آن را مدیریت کنید. به طور مثال WebhookSendAttemptStore
موجودیت WebhookDefinition: تعاریفی از Webhook که سیستم دارد.
public class WebhookDefinition { /// <summary> /// Unique name of the webhook. /// </summary> public string Name { get; } /// <summary> /// Display name of the webhook. /// Optional. /// </summary> public string DisplayName { get; set; } /// <summary> /// Description for the webhook. /// Optional. /// </summary> public string Description { get; set; } public WebhookDefinition(string name, string displayName = null, string description = null) { if (name.IsNullOrWhiteSpace()) { throw new ArgumentNullException(nameof(name)); } Name = name.Trim(); DisplayName = displayName; Description = description; } }
می توانید با عملیات اصلی CRUD آن را مدیریت کنید. به طور مثال WebhookDefinitionManager
موجودیت WebhookPayload: الگوی Webhook Payload که ما برای ارسال Webhooks خود استفاده می کنیم.
public class WebhookPayload { public string Id { get; set; } public string Event { get; set; } public int Attempt { get; set; } public dynamic Data { get; set; } public DateTime CreationTimeUtc { get; set; } }
انتشار WebhookPublisher: کلاسی که از آن برای انتشار Webhooks برای همه اشتراک ها استفاده خواهیم کرد. مسئولیت این کلاس اشتراک را بررسی نموده و یک کار پس زمینه ایجاد و Webhooks را ارسال کند.
فرآیند انتشار:
کد:
public class WebhookPublisher : IWebhookPublisher { private readonly IWebhookEventStore _webhookEventStore; private readonly IBackgroundJobManager _backgroundJobManager; private readonly IWebhookSubscriptionManager _webhookSubscriptionManager; public WebhookPublisher( IWebhookSubscriptionManager webhookSubscriptionManager, IBackgroundJobManager backgroundJobManager, IWebhookEventStore webhookEventStore) { _backgroundJobManager = backgroundJobManager; _webhookSubscriptionManager = webhookSubscriptionManager; _webhookEventStore = webhookEventStore; } /// <summary> /// Publishes webhooks /// </summary> /// <param name="webhookName">unique name of webhook. For example invoice.payment_failed</param> private async Task PublishAsync(string webhookName, object data) { var webhookSubscriptions = await _webhookSubscriptionManager.GetAllSubscriptions(webhookName);//get all subscriptions of webhook. //You can list for the current user or all users. It depends on what you need. I pass this because this article contains only basic implementation if (webhookSubscriptions.IsNullOrEmpty()) { return; } var webhookEvent = await SaveAndGetWebhookEventAsync(webhookName, data);//store webhook data, we will use it for monitoring etc (you can also need it if anything goes wrong). foreach (var webhookSubscription in webhookSubscriptions) { //create a background job to send that webhook //we use aspnetboiler plate background jobs here. see: https://aspnetboilerplate.com/Pages/Documents/Background-Jobs-And-Workers await _backgroundJobManager.EnqueueAsync<WebhookSenderJob, WebhookSenderArgs>(new WebhookSenderArgs { WebhookEventId = webhookEvent.Id, Data = webhookEvent.Data, WebhookName = webhookEvent.WebhookName, WebhookSubscriptionId = webhookSubscription.Id, Headers = webhookSubscription.Headers, Secret = webhookSubscription.Secret, WebhookUri = webhookSubscription.WebhookUri }); } } protected virtual async Task<WebhookEvent> SaveAndGetWebhookEventAsync(string webhookName, object data) { var webhookEvent = new WebhookEvent { Id = Guid.NewGuid(), WebhookName = webhookName, Data = Newtonsoft.Json.JsonConvert.SerializeObject(data) }; await _webhookEventStore.InsertAsync(webhookEvent); return webhookEvent; } }
برای اطلاعات بیشتر می توانید اینجا را بررسی کنید
کلاس WebhookSenderJob:
فرآیند:
کد:
using System; using System.Transactions; using Abp.BackgroundJobs; using Abp.Dependency; using Abp.Webhooks; public class WebhookSenderJob : BackgroundJob<WebhookSenderArgs>, ITransientDependency { private readonly IWebhooksConfiguration _webhooksConfiguration; private readonly IWebhookSendAttemptStore _webhookSendAttemptStore; private readonly IWebhookSender _webhookSender; public WebhookSenderJob( IWebhooksConfiguration webhooksConfiguration, IWebhookSendAttemptStore webhookSendAttemptStore, IWebhookSender webhookSender) { _webhooksConfiguration = webhooksConfiguration; _webhookSendAttemptStore = webhookSendAttemptStore; _webhookSender = webhookSender; } public override void Execute(WebhookSenderArgs args) { SendWebhook(args); } private void SendWebhook(WebhookSenderArgs args) { if (args.WebhookEventId == default) { return; } if (args.WebhookSubscriptionId == default) { return; } var sendAttemptCount = _webhookSendAttemptStore.GetSendAttemptCount(args.WebhookEventId, args.WebhookSubscriptionId); //get how many times we tried to send that exact webhook to that subscription if (sendAttemptCount > _webhooksConfiguration.MaxSendAttemptCount) { return; } try { _webhookSender.SendWebhook(args); } catch (Exception e) { _webhookSendAttemptStore.IncreaseSendAttemptCount(args.WebhookEventId, args.WebhookSubscriptionId); throw; //Throws exception to re-try sending webhook. aspnetboilerplate's background job manager creates new jobs with same parameters if it is not succeed } } }
برای اطلاعات بیشتر می توانید اینجا را بررسی کنید
کلاس WebhookSender:
فرآیند:
برای اطلاعات بیشتر می توانید اینجا را بررسی کنید
کلاس SendWebhook:
کد:
public async Task SendWebhookAsync(WebhookSenderArgs webhookSenderArgs) { if (webhookSenderArgs.WebhookEventId == default) { throw new ArgumentNullException(nameof(webhookSenderArgs.WebhookEventId)); } if (webhookSenderArgs.WebhookSubscriptionId == default) { throw new ArgumentNullException(nameof(webhookSenderArgs.WebhookSubscriptionId)); } var webhookSendAttemptId = await InsertWebhookSendAttemptAndGetIdAsync(webhookSenderArgs); //store webhook sending information var request = return new HttpRequestMessage(HttpMethod.Post, webhookSenderArgs.WebhookUri);//create webhook request using parameters var serializedBody = await GetSerializedBodyAsync(webhookSenderArgs);//get the request body as string request.Content = new StringContent(serializedBody, Encoding.UTF8, "application/json"); SignWebhookRequest(request, serializedBody, webhookSenderArgs.Secret);//add serializedBody to request content and sing it with using subscription's secret AddAdditionalHeaders(request, webhookSenderArgs);//add additional headers that subscription wants (it is stored in subscription) bool isSucceed = false; HttpStatusCode? responseStatusCode = null; string responseContent = "Webhook Send Request Failed"// try { var response = await SendHttpRequest(request); isSucceed = response.isSucceed; responseStatusCode = response.statusCode; responseContent = response.content; } catch (TaskCanceledException)//since we run it background and never send cancellation token TaskCanceledException means request timeout { responseStatusCode = HttpStatusCode.RequestTimeout; responseContent = "Request Timeout" } catch (HttpRequestException e)//something wrong happened on request. we can show them to users. { responseContent = e.Message; } catch (Exception e)// an internal error occurred. do not show it to users. just log it. { Logger.Error("An error occured while sending a webhook request", e); } finally { await StoreResponseOnWebhookSendAttemptAsync(webhookSendAttemptId, webhookSenderArgs.TenantId, responseStatusCode, responseContent);//finally store the response on webhook send attempt.(for monitoring it and see what was happen) } if (!isSucceed) { throw new Exception($"Webhook sending attempt failed. WebhookSendAttempt id: {webhookSendAttemptId}"); } }
متد GetSerializedBodyAsync:
protected virtual async Task<string> GetSerializedBodyAsync(WebhookSenderArgs webhookSenderArgs) { //webhookSenderArgs.Data is a string. If we use it in payload and serialize payload, payload's data will be string, not JSON object. //We convert it back to the dynamic to make it JSON object dynamic data = Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(webhookSenderArgs.Data); //get how many times we tried to send it to the client var attemptNumber = await WebhookSendAttemptStore.GetSendAttemptCountAsync(webhookSenderArgs.WebhookEventId, webhookSenderArgs.WebhookSubscriptionId) + 1; /*The webhook payload contains id property which stores WebhookEvent's id. We send it in the payload so that the client can check id to be sure it's the first time they get that data Sometimes a request can get timeout error or some other errors because of the client but the client might still progress it. In those cases, we think that an error occurred and we send it again. Clients can check that id for sensitive webhooks not to make duplicate operations. */ var payload = new WebhookPayload(webhookSenderArgs.WebhookEventId.ToString(), webhookSenderArgs.WebhookName, attemptNumber) { Data = data }; return Newtonsoft.Json.JsonConvert.SerializeObject(payload); }
متد SignWebhookRequest:
protected const string SignatureHeaderKey = "sha256"//the encryption algorithm name protected const string SignatureHeaderValueTemplate = SignatureHeaderKey + "={0}" protected const string SignatureHeaderName = "abp-webhook-signature" //key of request's header that our signature will be contained protected virtual void SignWebhookRequest(HttpRequestMessage request, string serializedBody, string secret) { if (request == null) { throw new ArgumentNullException(nameof(request)); } if (string.IsNullOrWhiteSpace(serializedBody)) { throw new ArgumentNullException(nameof(serializedBody)); } var secretBytes = Encoding.UTF8.GetBytes(secret); //we use sha256 to create a signature using (var hasher = new HMACSHA256(secretBytes)) { var data = Encoding.UTF8.GetBytes(serializedBody); var signatureBytes = hasher.ComputeHash(data);//create a signature using sha256 hash algorithm. var headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, BitConverter.ToString(signatureBytes));//get string value for header request.Headers.Add(SignatureHeaderName, headerValue);//add signature string to header } /* *You can create exact same signature with using that body only if you have that secret. Client will be able to verify that the data comes from the correct source. Client will do same process, create signature and check whether they are equal. If they are equal, it is certain that data comes from us.(or someone else stole their secret :) ) */ }
متد SendHttpRequest:
protected virtual async Task<(bool isSucceed, HttpStatusCode statusCode, string content)> SendHttpRequest(HttpRequestMessage request) { using (var client = new HttpClient { Timeout = _webhooksConfiguration.TimeoutDuration//make sure you define timeout }) { var response = await client.SendAsync(request); var isSucceed = response.IsSuccessStatusCode; var statusCode = response.StatusCode; var content = await response.Content.ReadAsStringAsync(); return (isSucceed, statusCode, content); } }
توجه: اگر از ASP.Net Core استفاده می کنید ، استفاده از IHttpClientFactory روش پیشنهادی برای ایجاد HttpClient است. به عنوان مثال ، می توانید اینجا را بررسی کنید
برای اطلاعات بیشتر می توانید لینک های زیر را بررسی کنید
تمام شد! اکنون می توانیم از ناشر Webhook استفاده کنیم.
در مدل Server-side:
اضافه کردن یک اشتراک subscription
private readonly IWebhookSubscriptionManager _webHookSubscriptionManager; public WebhookAppService(IWebhookSubscriptionManager webHookSubscriptionManager) { _webHookSubscriptionManager = webHookSubscriptionManager; } public async Task<string> AddTestSubscription() { var webhookSubscription = new WebhookSubscription() { WebhookUri = "http://localhost:21021/MyWebHookEndpoint", Webhooks = new List<string>() { "invoice.payment_succeed" }, Headers = new Dictionary<string, string>() { { "MyTestHeaderKey", "MyTestHeaderValue" } } }; await _webHookSubscriptionManager.AddOrUpdateSubscriptionAsync(webhookSubscription); return webhookSubscription.Secret; }
ارسال یک وب هوک به اشتراک
public class AppWebhookPublisher { private readonly IWebhookPublisher _webHookPublisher; public AppWebhookPublisher(IWebhookPublisher webHookPublisher) { _webHookPublisher = webHookPublisher; } public async Task SendTestPaymentSucceedWebhook(PaymentSucceedWebhookData dto) { await _webHookPublisher.PublishAsync("invoice.payment_succeed", dto); } }
انتشار انجام شده است و برای همه مشتری های مشترک ارسال می شود.
مثال: سمت مشتری به زبان سی شارپ
[HttpPost] public async Task MyWebHookEndpoint() { using (StreamReader reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) { var body = await reader.ReadToEndAsync(); if (!IsSignatureCompatible("whs_YOURWEBHOOKSECRET", body)) { throw new Exception("Unexpected Signature"); } //It is certain that Webhook has not been modified. } } private bool IsSignatureCompatible(string secret, string body) { if (!HttpContext.Request.Headers.ContainsKey("abp-webhook-signature")) { return false; } var receivedSignature = HttpContext.Request.Headers["abp-webhook-signature"].ToString().Split("=");//will be something like "sha256=whs_XXXXXXXXXXXXXX" //It starts with hash method name (currently "sha256") then continue with signature. You can also check if your hash method is true. string computedSignature; switch (receivedSignature[0]) { case "sha256": var secretBytes = Encoding.UTF8.GetBytes(secret); using (var hasher = new HMACSHA256(secretBytes)) { var data = Encoding.UTF8.GetBytes(body); computedSignature = BitConverter.ToString(hasher.ComputeHash(data)); } break; default: throw new NotImplementedException(); } return computedSignature == receivedSignature[1]; }