前言
在日常使用ASP.NET Core的開發或學習中,如果有需要使用鏈路跟蹤系統,大多數情況下會優先選擇SkyAPM。我們之前也說過SkyAPM設計確實比較優秀,巧妙的利用DiagnosticSource診斷跟蹤日誌,可以做到對專案無入侵方式的整合。其實還有一款比較優秀的鏈路跟蹤系統,也可以支援ASP.NET Core,叫Zipkin。它相對於SkyWalking來說相對輕量級,使用相對來說比較偏原生的方式,而且支援Http的形式查詢和提交鏈路資料。因為我們總是希望能擁有多一種的解決方案方便對比和參考,所以接下來我們就來學習一下關於Zipkin的使用方式。
Zipkin簡介
Zipkin是由Twitter開源的一款基於Java語言開發的分散式實時資料追蹤系統(Distributed Tracking System),其主要功能是採集來自各個系統的實時監控資料。該系統讓開發者可通過一個 Web 前端輕鬆的收集和分析資料,例如使用者每次請求服務的處理時間等,可方便的監測系統中存在的瓶頸。它大致可以分為三個核心概念
- 首先是上報端,它主要通過程式碼的形式整合到程式中,用於上報Trace資料到Collector端。
- Collector負責接收客戶端傳送過來的資料,儲存到記憶體或外部儲存系統中,供UI展示。
- 儲存端可以是基於zipkin記憶體完全不依賴外部儲存的In-Memory形式或依賴外部儲存系統的形式,一般採用外部儲存系統儲存鏈路資料,畢竟記憶體有限。它可支援的儲存資料庫有MySQL、Cassandra、Elasticsearch。
- UI負責展示採集的鏈路資料,及系統之間的依賴關係。
相對來說還是比較清晰的,如果用一張圖表示整體架構的話,大致如下圖所示(圖片來源於網路)在學習鏈路跟蹤的過程中會設計到相關概念,我們接下來介紹鏈路跟蹤幾個相關的概念 - TranceId,一般一次全域性的請求會有一個唯一的TraceId,用於代表一次唯一的請求。比如我請求了訂單管理系統,而訂單管理系統內部還呼叫了商品管理系統,而商品管理系統還呼叫了快取系統或資料庫系統。但是對全域性或外部來說這是一次請求,所以會有唯一的一個TraceId。
- SpanId,雖然全域性的來說是一次大的請求,但是在這個鏈路中內部間還會發起別的請求,這種內部間的每次請求會生成一個SpanId。
- 如果將整條鏈路串聯起來的話,我們需要記錄全域性的TraceId,代表當前節點的SpanId和發起對當前節點呼叫的的父級ParentId。
然後基於鏈路跟蹤的核心概念,然後介紹一下Zipkin衍生出來了幾個相關概念 - cs:Clent Sent 客戶端發起請求的時間,比如 dubbo 呼叫端開始執行遠端呼叫之前。
- cr:Client Receive 客戶端收到處理完請求的時間。
- ss:Server Receive 服務端處理完邏輯的時間。
- sr:Server Receive 服務端收到呼叫端請求的時間。
sr - cs = 請求在網路上的耗時
ss - sr = 服務端處理請求的耗時
cr - ss = 回應在網路上的耗時
cr - cs = 一次呼叫的整體耗時
關於zipkin概念相關的就介紹這麼多,接下來我們介紹如何部署Zipkin。
部署ZipKin
關於Zipkin常用的部署方式大概有兩種,一種是通過下載安裝JDK,然後執行zipkin.jar的方式,另一種是基於Docker的方式。為了方便我採用的是基於Docker的方式部署,因為採用原生的方式去部署還需要安裝JDK,而且操作相對比較麻煩。我們們上面說過,雖然Zipkin可以將鏈路資料存放到記憶體中,但是這種操作方式並不實用,實際使用過程中多采用ElasticSearch儲存鏈路資料。所以部署的時候需要依賴Zipkin和ElasticSearch,對於這種部署形式採用docker-compose的方式就再合適不過了,大家可以在Zipkin官方Github中找到docker的部署方式,地址是https://github.com/openzipkin/zipkin/tree/master/docker,官方使用的方式相對比較複雜,下載下來docker-compose相關檔案之後我簡化了它的使用方式,最終修改如下
version: "3.6"
services:
elasticsearch:
# 我使用的是7.5.0版本
image: elasticsearch:7.5.0
container_name: elasticsearch
restart: always
#暴露es埠
ports:
- 9200:9200
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
#es有記憶體要求
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
networks:
default:
aliases:
- elasticsearch
zipkin:
image: openzipkin/zipkin
container_name: zipkin
restart: always
networks:
default:
aliases:
- zipkin
environment:
#儲存型別為es
- STORAGE_TYPE=elasticsearch
#es地址
- ES_HOSTS=elasticsearch:9200
ports:
- 9411:9411
#依賴es所以在es啟動完成後在啟動zipkin
depends_on:
- elasticsearch
通過docker-compose執行編輯後的yaml檔案,一條指令就可以執行起來
docker-compose -f docker-compose-elasticsearch7.yml up
其中-f是指定檔名稱,如果是docker-compose.yml則可以直接忽略檔名稱,當shell中出現如下介面並且在瀏覽器中輸入http://localhost:9411/zipkin/出現如圖所示,則說明Zikpin啟動成功
整合ASP.NET Core
ZipKin啟動成功之後,我們就可以將程式中的資料採集到Zipkin中去了,我新建了兩個ASP.NET Core的程式,一個是OrderApi,另一個是ProductApi方便能體現出呼叫鏈路,其中OrderApi呼叫ProductApi介面,在兩個專案中分別引入Zipkin依賴包
<PackageReference Include="zipkin4net" Version="1.5.0" />
<PackageReference Include="zipkin4net.middleware.aspnetcore" Version="1.5.0" />
其中zipkin4net為核心包,zipkin4net.middleware.aspnetcore是整合ASP.NET Core的程式包。然後我們在Startup檔案中新增如下方法
public void RegisterZipkinTrace(IApplicationBuilder app, ILoggerFactory loggerFactory, IHostApplicationLifetime lifetime)
{
lifetime.ApplicationStarted.Register(() =>
{
//記錄資料密度,1.0代表全部記錄
TraceManager.SamplingRate = 1.0f;
//鏈路日誌
var logger = new TracingLogger(loggerFactory, "zipkin4net");
//zipkin服務地址和內容型別
var httpSender = new HttpZipkinSender("http://localhost:9411/", "application/json");
var tracer = new ZipkinTracer(httpSender, new JSONSpanSerializer(), new Statistics());
var consoleTracer = new zipkin4net.Tracers.ConsoleTracer();
TraceManager.RegisterTracer(tracer);
TraceManager.RegisterTracer(consoleTracer);
TraceManager.Start(logger);
});
//程式停止時停止鏈路跟蹤
lifetime.ApplicationStopped.Register(() => TraceManager.Stop());
//引入zipkin中介軟體,用於跟蹤服務請求,這邊的名字可自定義代表當前服務名稱
app.UseTracing(Configuration["nacos:ServiceName"]);
}
然後我們在Configure方法中呼叫RegisterZipkinTrace方法即可。由於我們要在OrderApi專案中採用HttpClient的方式呼叫ProductAPI,預設zipkin4net是支援採集HttpClient發出請求的鏈路資料(由於在ProductApi中我們並不傳送Http請求,所以可以不用整合一下操作),具體整合形式如下,如果使用的是HttpClientFactory的方式,在ConfigureServices中配置如下
public void ConfigureServices(IServiceCollection services)
{
//由於我使用了Nacos作為服務註冊中心
services.AddNacosAspNetCore(Configuration);
services.AddScoped<NacosDiscoveryDelegatingHandler>();
services.AddHttpClient(ServiceName.ProductService,client=> {
client.BaseAddress = new Uri($"http://{ServiceName.ProductService}");
})
.AddHttpMessageHandler<NacosDiscoveryDelegatingHandler>()
//引入zipkin trace跟蹤httpclient請求,名稱配置當前服務名稱即可
.AddHttpMessageHandler(provider =>TracingHandler.WithoutInnerHandler(Configuration["nacos:ServiceName"]));
services.AddControllers();
}
如果是直接是使用HttpClient的形式呼叫則可以採用以下方式
using (HttpClient client = new HttpClient(new TracingHandler("OrderApi")))
{
}
然後我們在OrderApi中寫一段呼叫ProductApi的程式碼
[Route("orderapi/[controller]")]
public class OrderController : ControllerBase
{
private List<OrderDto> orderDtos = new List<OrderDto>();
private readonly IHttpClientFactory _clientFactory;
public OrderController(IHttpClientFactory clientFactory)
{
orderDtos.Add(new OrderDto { Id = 1, TotalMoney=222,Address="北京市",Addressee="me",From="淘寶",SendAddress="武漢" });
_clientFactory = clientFactory;
}
/// <summary>
/// 獲取訂單詳情介面
/// </summary>
/// <param name="id">訂單id</param>
/// <returns></returns>
[HttpGet("getdetails/{id}")]
public async Task<OrderDto> GetOrderDetailsAsync(long id)
{
OrderDto orderDto = orderDtos.FirstOrDefault(i => i.Id == id);
if (orderDto != null)
{
OrderDetailDto orderDetailDto = new OrderDetailDto
{
Id = orderDto.Id,
TotalMoney = orderDto.TotalMoney,
Address = orderDto.Address,
Addressee = orderDto.Addressee,
From = orderDto.From,
SendAddress = orderDto.SendAddress
};
//呼叫ProductApi服務介面
var client = _clientFactory.CreateClient(ServiceName.ProductService);
var response = await client.GetAsync($"/productapi/product/getall");
var result = await response.Content.ReadAsStringAsync();
orderDetailDto.Products = JsonConvert.DeserializeObject<List<OrderProductDto>>(result);
return orderDetailDto;
}
return orderDto;
}
}
在ProductApi中我們只需要編寫呼叫RegisterZipkinTrace方法即可,和OrderApi一樣,我們就不重複貼上了。因為ProductApi不需要呼叫別的服務,所以可以不必使用整合HttpClient,只需要提供簡單的介面即可
[Route("productapi/[controller]")]
public class ProductController : ControllerBase
{
private List<ProductDto> productDtos = new List<ProductDto>();
public ProductController()
{
productDtos.Add(new ProductDto { Id = 1,Name="酒精",Price=22.5m });
productDtos.Add(new ProductDto { Id = 2, Name = "84消毒液", Price = 19.9m });
}
/// <summary>
/// 獲取所有商品資訊
/// </summary>
/// <returns></returns>
[HttpGet("getall")]
public IEnumerable<ProductDto> GetAll()
{
return productDtos;
}
}
啟動這兩個專案,呼叫OrderApi的getdetails介面,完成後開啟zipkin介面點選進去可檢視鏈路詳情
總結起來核心操作其實就兩個,一個是在傳送請求的地方,使用TracingHandler記錄發起端的鏈路情況,然後在接收請求的服務端使用UseTracing記錄來自於客戶端請求的鏈路情況。
改進整合方式
其實在上面的演示中,我們可以明顯的看到明顯的不足,就是很多時候其實我們沒辦法去設定HttpClient相關的引數的,很多框架雖然也是使用的HttpClient或HttpClientFactory相關,但是在外部我們沒辦法通過自定義的方式去設定他們的相關操作,比如Ocelot其實也是使用HttpClient相關發起的轉發請求,但是對外我們沒辦法通過我們的程式去設定HttpClient的引數。還有就是在.Net Core中WebRequest其實也是對HttpClient的封裝,但是我們同樣沒辦法在我們的程式中給他們傳遞類似TracingHandler的操作。現在我們從TracingHandler原始碼開始解讀看看它的內部到底是如何工作的,zipkin官方提供的.net core外掛zipkin4net的原始碼位於
https://github.com/openzipkin/zipkin4net,我們找到TracingHandler類所在的位置[點選檢視原始碼?],由於TracingHandler本身就是DelegatingHandler的子類,所以我們主要看SendAsync方法,大致抽離出來如下
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
{
Func<HttpRequestMessage, string> _getClientTraceRpc = _getClientTraceRpc = getClientTraceRpc ?? (request => request.Method.ToString());
IInjector<HttpHeaders> _injector = Propagations.B3String.Injector<HttpHeaders>((carrier, key, value) => carrier.Add(key, value));
//記錄發起請求客戶端鏈路資訊的類是ClientTrace
using (var clientTrace = new ClientTrace(_serviceName, _getClientTraceRpc(request)))
{
if (clientTrace.Trace != null)
{
_injector.Inject(clientTrace.Trace.CurrentSpan, request.Headers);
}
var result = await clientTrace.TracedActionAsync(base.SendAsync(request, cancellationToken));
//AddAnnotation是記錄標籤資訊,我們可以在zipkin鏈路詳情中看到這些標籤
if (clientTrace.Trace != null)
{
//記錄請求路徑
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_PATH, result.RequestMessage.RequestUri.LocalPath));
//記錄請求的http方法
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_METHOD, result.RequestMessage.Method.Method));
if (_logHttpHost)
{
//記錄主機
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_HOST, result.RequestMessage.RequestUri.Host));
}
if (!result.IsSuccessStatusCode)
{
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_STATUS_CODE, ((int)result.StatusCode).ToString()));
}
}
return result;
}
}
實現方式比較簡單,就是藉助ClientTrace記錄一些標籤,其他的相關操作都是由zipkin4net提供的。我們在之前的文章.Net Core中的診斷日誌DiagnosticSource講解中層說道HttpClient底層會有發出診斷日誌,我們可以藉助這個思路,來對HttpClient進行鏈路跟蹤埋點。
我們結合Microsoft.Extensions.DiagnosticAdapter擴充套件包定義如下類
public class HttpDiagnosticListener: ITraceDiagnosticListener
{
public string DiagnosticName => "HttpHandlerDiagnosticListener";
private ClientTrace clientTrace;
private readonly IInjector<HttpHeaders> _injector = Propagations.B3String.Injector<HttpHeaders>((carrier, key, value) => carrier.Add(key, value));
[DiagnosticName("System.Net.Http.Request")]
public void HttpRequest(HttpRequestMessage request)
{
clientTrace = new ClientTrace("apigateway", request.Method.Method);
if (clientTrace.Trace != null)
{
_injector.Inject(clientTrace.Trace.CurrentSpan, request.Headers);
}
}
[DiagnosticName("System.Net.Http.Response")]
public void HttpResponse(HttpResponseMessage response)
{
if (clientTrace.Trace != null)
{
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_PATH, response.RequestMessage.RequestUri.LocalPath));
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_METHOD, response.RequestMessage.Method.Method));
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_HOST, response.RequestMessage.RequestUri.Host));
if (!response.IsSuccessStatusCode)
{
clientTrace.AddAnnotation(Annotations.Tag(zipkinCoreConstants.HTTP_STATUS_CODE, ((int)response.StatusCode).ToString()));
}
}
}
[DiagnosticName("System.Net.Http.Exception")]
public void HttpException(HttpRequestMessage request,Exception exception)
{
}
}
ITraceDiagnosticListener是我們方便操作DiagnosticListener定義的介面,介面僅包含DiagnosticName用來表示DiagnosticListener監聽的名稱,有了這個介面接下來的操作我們會方便許多,接下來我們來看訂閱操作的實現。
public class TraceObserver :IObserver<DiagnosticListener>
{
private IEnumerable<ITraceDiagnosticListener> _traceDiagnostics;
public TraceObserver(IEnumerable<ITraceDiagnosticListener> traceDiagnostics)
{
_traceDiagnostics = traceDiagnostics;
}
public void OnCompleted()
{
}
public void OnError(Exception error)
{
}
public void OnNext(DiagnosticListener listener)
{
//這樣的話我們可以更輕鬆的擴充套件其他DiagnosticListener的操作
var traceDiagnostic = _traceDiagnostics.FirstOrDefault(i=>i.DiagnosticName==listener.Name);
if (traceDiagnostic!=null)
{
//適配訂閱
listener.SubscribeWithAdapter(traceDiagnostic);
}
}
}
通過這種操作我們就無需關心如何將自定義的DiagnosticListener訂閱類適配到DiagnosticAdapter中去,方便我們自定義其他DiagnosticListener的訂閱類,這樣的話我們只需註冊自定義的訂閱類即可。
services.AddSingleton<TraceObserver>();
services.AddSingleton<ITraceDiagnosticListener, HttpDiagnosticListener>();
通過這種改進方式,我們可以解決類似HttpClient封裝到框架中,並且我們我們無法通過外部程式去修改設定的時候。比如我們在架構中引入了Ocelot閘道器,我們就可以採用類似這種方式,在閘道器層整合zipkin4net。
自定義埋點
通過上面我們檢視TracingHandler的原始碼我們得知埋點主要是通過ClientTrace進行的,它是在發起請求的客戶端進行埋點。在服務端埋點的方式我們可以通過TracingMiddleware中介軟體中的原始碼檢視到[點選檢視原始碼?]叫ServerTrace。有了ClientTrace和ServerTrace我們可以非常輕鬆的實現一次完整的客戶端和服務端埋點,只需要通過它們打上一些標籤即可。其實它們都是對Trace類的封裝,我們找到它們的原始碼進行檢視
public class ClientTrace : BaseStandardTrace, IDisposable
{
public ClientTrace(string serviceName, string rpc)
{
if (Trace.Current != null)
{
Trace = Trace.Current.Child();
}
Trace.Record(Annotations.ClientSend());
Trace.Record(Annotations.ServiceName(serviceName));
Trace.Record(Annotations.Rpc(rpc));
}
public void Dispose()
{
Trace.Record(Annotations.ClientRecv());
}
}
public class ServerTrace : BaseStandardTrace, IDisposable
{
public override Trace Trace
{
get
{
return Trace.Current;
}
}
public ServerTrace(string serviceName, string rpc)
{
Trace.Record(Annotations.ServerRecv());
Trace.Record(Annotations.ServiceName(serviceName));
Trace.Record(Annotations.Rpc(rpc));
}
public void Dispose()
{
Trace.Record(Annotations.ServerSend());
}
}
因此,如果你想通過更原始的方式去記錄跟蹤日誌可以採用如下方式
var trace = Trace.Create();
trace.Record(Annotations.ServerRecv());
trace.Record(Annotations.ServiceName(serviceName));
trace.Record(Annotations.Rpc("GET"));
trace.Record(Annotations.ServerSend());
trace.Record(Annotations.Tag("http.url", "<url>"));
示例Demo
由於上面說的比較多,而且有一部分關於原始碼的解讀,為了防止由本人文筆有限,給大家帶來理解誤區,另一方面也為了更清晰的展示Zipkin的整合方式,我自己做了一套Demo,目錄結構如下
ApiGateway為閘道器專案可以轉發針對OrderApi的請求,OrderApi和ProductApi用於模擬業務系統,這三個專案都整合了zipkin4net鏈路跟蹤,他們之間是通過Nacos實現服務的註冊和發現。這個演示Demo我本地是可以直接執行成功的,如果有下載下來執行不成功的,可以評論區給我留言。由於部落格園有檔案上傳大小的限制,所以我將Demo上傳到了百度網盤中
下載連結: https://pan.baidu.com/s/1jPHyXKV9DAK_oEYQz3xtzA 提取碼: a7u5
總結
以上就是關於Zipkin以及ASP.NET Core整合Zipkin的全部內容,希望能給大家帶來一定的幫助。如果你有實際需要也可以繼續自行研究。Zipkin相對於我們常用的Skywalking而且,它的使用方式比較原生,許多操作都需要自行通過程式碼操作,而SkyAPM可以做到對程式碼無入侵的方式整合。Skywalking是一款APM(應用效能管理),鏈路跟蹤只是它功能的一部分。而Zipkin是一款專注於鏈路跟蹤的系統,個人感覺就鏈路跟蹤這一塊而言,Zipkin更輕量級(如果使用ES作為儲存資料庫的話,Skywalking預設會生成一堆索引,而Zipkin預設是每天建立一個索引),而且鏈路資訊檢索、詳情展示、鏈路資料上報形式等相對於Skywalking形式也更豐富一些。但是整體而言Skywalking更強大,比如應用監控、呼叫分析、整合方式等。技術並無好壞之分,適合自己的才是更好的,多一個解決方案,就多一個解決問題的思路,我覺得這是對於我們程式開發人員來說都應該具備的認知。