如何自動轉發接收的請求報頭?

2023-05-31 09:00:40

瞭解OpenTelemetry的朋友應該知道,為了將率屬於同一個請求的多個操作(Span)串起來,上游應用會生成一個唯一的TraceId。在進行跨應用的Web呼叫時,這個TraceId和代表跟蹤操作標識的SpanID一併行給目標應用,W3C還專門指定了一份名為Trace Context的標準,該標準確定了一個名為trace-parent的請求報頭來傳遞TraceId、(Parent)SpanID以及其他兩個跟蹤屬性。其實我們的應用也可能會使用到分散式跟蹤這種類似的功能,我們需要在某個應用中新增一些「埋點」,當它呼叫另一個應用時,這些埋點會自動新增到請求的報頭集合中,從而實現在整個呼叫鏈中自動傳遞。為了實現這個功能,我建立了一個名為HeaderForwarder(Github)的框架。本文不會介紹HeaderForwarder的設計,僅僅介紹它的使用方式,有興趣的朋友可以檢視原始碼。

一、 請求報頭的自動轉發
二、 遮蔽自動轉發功能
三、 為請求新增請求報頭
四、 同名報頭的處理
五、 遮蔽「外部」新增的請求報頭

一、 請求報頭的自動轉發

我們建立App1、App2和App3三個應用,ASP.NET Core應用App2和App3以路由的形式提供一個簡單的API,App1則是一個簡單的控制檯應用。App1利用HttpClient呼叫App2承載的API,後者進一步呼叫App3。我們讓處於中間的App2新增針對HeaderForwarder這個NuGet包的參照。如下所示的是控制檯應用App1的定義。我們利用建立的HttpClient呼叫App2承載的API,傳送的請求中人為新增了名為 「foo」 、「bar」 和 「baz」 的三個報頭。

var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000/test");
request.Headers.Add("foo", "123");
request.Headers.Add("bar", "456");
request.Headers.Add("baz", "789");
using (var httpClient = new HttpClient())
{
    await httpClient.SendAsync(request);
}

App2定義如下。HeaderForwarder設計的服務通過呼叫IServiceCollection介面的AddHeaderForwarder進行註冊,該方法中同時指定了需要自動轉發的報頭名稱 「foo」 和 「bar」 (不區分大小寫)。後面呼叫AddHttpClient擴充套件方法是為了使用注入的IHttpClientFactory物件所需的HttpClient物件。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHeaderForwarder("foo", "bar").AddHttpClient();
var app = builder.Build();
app.MapGet("/test", async (HttpRequest request, IHttpClientFactory httpClientFactory) =>
{
    foreach (var kv in request.Headers)
    {
        Console.WriteLine($"{kv.Key}:{kv.Value}");
    }
    await httpClientFactory.CreateClient().GetAsync("http://localhost:5001/test");
});
app.Run("http://localhost:5000");

App1呼叫的API體現為針對路徑 「/test」 註冊的路由。路由處理程式會再控制檯上輸出接收到的所有請求報頭,並在此之後利用IHttpClientFactory物件建立的HttpClient完成針對App3的呼叫。App3提供的API僅僅按照如下的方式將接收到的請求報頭輸出到控制檯上。

var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/test",  (HttpRequest request) =>
{
    foreach (var kv in request.Headers)
    {
        Console.WriteLine($"{kv.Key}:{kv.Value}");
    }
});
app.Run("http://localhost:5001");

三個應用先後啟動後,App1呼叫App2新增的三個請求報頭(「foo」 、 「bar」 和 「baz」)會出現在App2的控制檯上。HeaderForwarder只會自動轉發指定的請求報頭「foo」 和「bar」 ,所有隻有這兩個報頭會出現在App3的控制檯上。從圖中還可以看到,預設由HttpClientFactory建立的HttpClient的呼叫新增和轉發用於分散式跟蹤的traceparent報頭。

clip_image002

二、 遮蔽自動轉發功能

HeaderForwarder能夠獲得當前的HttpContext上下文,並提取並轉發所需的請求報頭。如果App2在呼叫App3的時候並不希望將報頭轉發出去,可以按照如下的方式注入IOutgoingHeaderProcessor物件,並呼叫其SuppressHeaderForwarder方法將報頭自動轉發功能遮蔽掉。

using HeaderForwarder;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHeaderForwarder("foo", "bar").AddHttpClient();
var app = builder.Build();
app.MapGet("/test", async (IHttpClientFactory httpClientFactory,IOutgoingHeaderProcessor processor ) =>
{
    using (processor.SuppressHeaderForwarder())
    {
        await httpClientFactory.CreateClient().GetAsync("http://localhost:5001/test");
    }
});
app.Run("http://localhost:5000");

SuppressHeaderForwarder利用返回的IDisposable物件代表「遮蔽上下文」,意味著該建立的「屏障」會在其Dispose方法後失效,所以App2在此上下文中完成針對App3的呼叫,它接收的請求報頭「foo」 和「bar」並不會被轉發出去。

clip_image004

三、 為請求新增請求報頭

當我們利用HttpClient進行Web呼叫時,如果需要認為地新增報頭,典型的做法就是按照App1異常建立一個HttpRequestMessage物件,並將需要的報頭以鍵值對的形式新增到它的Headers屬性中。HeaderForwarder提供了一種更加快捷易用的程式設計模式。

var processor = OutgoingHeaderProcessor.Create();
using(var httpClient = new HttpClient())
using (processor.AddHeaders(("foo", "123"), ("bar", "456"), ("baz", "789")))
await httpClient.GetAsync("http://localhost:5000/test");

如上面的程式碼片段所示,我們呼叫OutgoingHeaderProcessor型別的靜態方法Create建立了一個IOutgoingHeaderProcessor物件,並呼叫其AddHeaders完成了三個請求報頭的新增。這個方法同樣返回一個通過IDisposable物件表示的執行上下文,在此上下文中針對HttpClient的呼叫生成的請求均會自動附加這三個報頭。

四、 同名報頭的處理

由於IOutgoingHeaderProcessor介面的AddHeaders方法返回的時一個IDisposable物件表示的上下文,意味著上下文之間可能出現巢狀的關係。在預設情況下,如果HttpClient在這樣一個巢狀的上下文中被使用,這些上下文攜帶的請求報頭都將被轉發。一般來說,這種情況正是我們希望的,但是如果我們在一個具有巢狀關係的多個上下文中新增了多個同名的報頭,就有可能出現我們不願看到的結果。

using HeaderForwarder;

var processor = OutgoingHeaderProcessor.Create();
using(var httpClient = new HttpClient())
await FooAsync(httpClient);

async Task FooAsync(HttpClient httpClient)
{
    using (processor.AddHeaders(("foobarbaz", "abc")))
    await BarAsync(httpClient);
}
async Task BarAsync(HttpClient httpClient)
{
    using (processor.AddHeaders(("foobarbaz", "abc")))
    await BazAsync(httpClient);
}
async Task BazAsync(HttpClient httpClient)
{
    using (processor.AddHeaders(("foobarbaz", "abc")))
    await httpClient.GetAsync("http://localhost:5000/test");
}

如上面的程式碼所示,三個巢狀呼叫的方法FooAsync、BarAsync和BazAsync採用相同的方式呼叫IOutgoingHeaderProcessor物件的AddHeaders方法新增相同的請求報頭「foobarbaz」。意味著在BazAsync方法針對HttpClient的呼叫會在三個巢狀的上下文中進行,這意味著App2會接收到三個同名的請求報頭。

clip_image006

如果不希望出現這種情況下,可以將針對AddHeaders方法的呼叫按照如下的方式替換成ReplaceHeaders。

async Task FooAsync(HttpClient httpClient)
{
    using (processor.ReplaceHeaders(("foobarbaz", "abc")))
    await BarAsync(httpClient);
}
async Task BarAsync(HttpClient httpClient)
{
    using (processor.ReplaceHeaders(("foobarbaz", "abc")))
    await BazAsync(httpClient);
}
async Task BazAsync(HttpClient httpClient)
{
    using (processor.ReplaceHeaders(("foobarbaz", "abc")))
    await httpClient.GetAsync("http://localhost:5000/test");
}

五、 遮蔽「外部」新增的請求報頭

如果不願意收到巢狀的「外部」上下文的干擾,我們可以呼叫IOutgoingHeaderProcessor介面的AddHeadersAfterClear方法。顧名思義,這個方法在新增指定請求報頭之前,會先將現有的報頭清除。

var processor = OutgoingHeaderProcessor.Create();
using(var httpClient = new HttpClient())
await FooAsync(httpClient);

async Task FooAsync(HttpClient httpClient)
{
    using (processor.AddHeadersAfterClear(("foo", "123")))
    await BarAsync(httpClient);
}
async Task BarAsync(HttpClient httpClient)
{
    using (processor.AddHeadersAfterClear(("barbaz", "456")))
    await BazAsync(httpClient);
}
async Task BazAsync(HttpClient httpClient)
{
    using (processor.AddHeadersAfterClear(("barbaz", "789")))
    await httpClient.GetAsync("http://localhost:5000/test");
}

如上面的程式碼片段所示,FooAsync呼叫AddHeadersAfterClear方法新增了一個名為「foo」的報頭,BarAsync和BazAsync則採用相同的方式新增了兩個同名的請求報頭「Barbaz」。App2只會接收到由BazAsync設定的報頭。

clip_image008

AddHeadersAfterClear針對現有報頭的清除只會體現在它建立的上下文中,當前上下文並不會收到影響。因為該方法根本沒有做任何清除工作,而是建立一個全新的上下文。AddHeaders和ReplaceHeaders方法其實重用了外部的上下文。