Use Attributes & Middleware in ASP.NET Core for Logging, Telemetry, and Anything Else (转发)
原文:
https://michaelscodingspot.com/attributes-and-middleware-in-asp-net-core/
Use Attributes & Middleware in ASP.NET Core for Logging, Telemetry, and Anything Else
Every once in a while you need to add meta functionality without actually changing the business logic code. This might be reporting telemetry, logging, or adding metrics. While necessary, writing this code along with the business logic feels kind of wrong. There's no separation of concerns, it makes the business logic harder to read, and it's prone to bugs.
If you're using ASP.NET Core, you can use attributes and the middleware system to add this kind of logic. This makes the code looks great and separates concerns. But it's not a trivial problem. How do you pass data to the middleware for the telemetry or logging? What about dynamic data that you have during runtime? How to get values from the HTTP request? You're going to see how to do all that and more in this article.
Let's say that you want to add telemetry to your ASP.NET controllers. In other words, you want to report statistics of user actions. Suppose the SignUp
and SignIn
are two actions you're interested in. One way to go about it is to call a method from within the action, like this:
1
2
3
4
5
6
7
8
9
10
11
12
|
public async Task<IActionResult> SignUp([FromBody]SignUpViewModel vm)
{
_telemtryService.RegisterEvent(TelemetryEvent.SignUp);
// ...
}
public async Task<IActionResult> SignIn([FromBody]SignInViewModel vm)
{
_telemtryService.RegisterEvent(TelemetryEvent.SignIn);
// ...
}
|
This is fine, I won't judge you, but wouldn't it be better to have code like this?
1
2
3
4
5
6
7
8
9
10
11
12
|
[TelemetryAttribute(TelemetryEvent.SignUp)]
public async Task<IActionResult> SignUp([FromBody]SignUpViewModel vm)
{
// ...
}
[TelemetryAttribute(TelemetryEvent.SignIn)]
public async Task<IActionResult> SignIn([FromBody]SignInViewModel vm)
{
// ...
}
|
This way you separate the business logic concerns from telemetry concerns. And the code is more readable.
If you're using ASP.NET Core, this is pretty simple using a custom middleware.
Simple Custom Middleware
First, let's create our attribute:
1
2
3
4
5
6
7
8
9
10
11
12
|
public class TelemetryAttribute : Attribute
{
public TelemetryEvent Event { get; set; }
public TelemetryAttribute(TelemetryEvent ev)
{
Event = ev;
}
}
public enum TelemetryEvent { SignUp, SignIn}
|
Then, we need a middleware class, like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public class TelemetryMiddleware
{
private RequestDelegate _next;
public TelemetryMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await _next(context); // Here the action in the controller is called
// a try/catch can be added here because you don't want middleware failures to interfere with normal functionality
var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<TelemetryAttribute>();
if (attribute != null)
{
var ev = attribute.Event;
// add telemetry or logging here
}
}
}
|
There are some creative choices to be made here. In the above code, the action in the controller will be executed first and the telemetry functionality after. You might want it the other way around. Or maybe in parallel. Then, what do you want to do if the action in the controller failed with an exception? Should we still execute the telemetry? Do nothing? Or report a telemetry event that indicates failure?
Anyway, the next step is to register the middleware into ASP.NET Core's pipeline. That's done in Startup.cs
in Configure
like this:
1
2
3
4
5
6
7
|
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// ...
app.UseMiddleware<TelemetryMiddleware>();
// ...
}
|
That's about it, the middleware should now work.
Using Request Data in the Middleware
Let's complicate the problem a little. Suppose that in SignUp
you want to add the user's email to the telemetry. That's a reasonable request, right? So how exactly can you pass this kind of dynamic information through an attribute and a middleware? It's a bit tricky, but there are a few ways to do this. One way is to get the attribute object and change it in the business logic code.
In the Controller:
1
2
3
4
5
6
7
8
9
|
[HttpPost]
[TelemetryAttribute(TelemetryEvent.SignUp)]
public async Task<IActionResult> SignUp([FromBody]SignUpViewModel vm)
{
var attribute = HttpContextHelper.GetEndPointAttribute<TelemetryAttribute>(Request.HttpContext);
attribute.Data = vm.Email;
// ...
}
|
In the Attribute:
1
2
3
4
5
6
7
8
9
10
11
|
public class TelemetryAttribute : Attribute
{
public TelemetryEvent Event { get; set; }
public string Data { get; set; } //added
public TelemetryAttribute(TelemetryEvent ev)
{
Event = ev;
}
}
|
In the middleware:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public class TelemetryMiddleware
{
private RequestDelegate _next;
public TelemetryMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await _next(context); // Should be called BEFORE the following for the Data to be populated
var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<TelemetryAttribute>();
if (attribute != null && attribute.Event == TelemetryEvent.SignUp)
{
var email = attribute.Data;
// add telemetry or logging here
}
}
}
|
I know what you're thinking—this isn't pretty. You have both the attribute and lines of code inside the business logic. Might as well remove the attribute and call the telemetry directly. And I'll be the first to agree with you. Except that maybe you have 20 such attributes in code already, and you want to add just one telemetry event that needs additional data.
You can use the above method, I won't judge you, but there are a couple of possibly better ways to do this.
Capturing data from the request body in the middleware
If you really want to separate the telemetry/logging functionality from the business logic, there's a way to get that Email field in the middleware itself. You're going to need to read and parse the request body in the middleware. Here's how to do it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
public class TelemetryMiddleware
{
private RequestDelegate _next;
public TelemetryMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
//This is important to call BEFORE invoking action to be able to read the body again afterwards
context.Request.EnableBuffering(); // Use .EnableRewind() for ASP.NET Core 2.x
await _next(context); // The action in the controller will be called here
var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<TelemetryAttribute>();
if (attribute != null && attribute.Event == TelemetryEvent.SignUp)
{
var bodyStr = await GetRequestBody(context);
var vm = JsonConvert.DeserializeObject<SignUpViewModel>(bodyStr);
var email = vm.Email;
// add telemetry or logging here
}
}
private static async Task<string> GetRequestBody(HttpContext context)
{
var req = context.Request;
req.Body.Position = 0; //Rewind to initial position
// The `leaveOpen` should be `true` if there's another middleware using this after or if the action going to be invoked AFTER this middleware
using (StreamReader reader
= new StreamReader(req.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true))
{
return await reader.ReadToEndAsync();
}
}
}
|
The above code reads the body directly from the HTTP request, deserializes it from JSON, and reads the email. The thing is, that in order to do that, you need to change the standard behavior of ASP.NET. By default, the request body stream will be disposed as soon as it's read. In our case, it's being read twice, so we have to call the EnableBuffering
method and then rewind the stream to position 0.
This code potentially hurts performance. We're reading and deserializing the body's JSON twice, which is a waste. But there's also an impact on memory pressure. When you keep the request body in memory for a longer time, there's a higher chance it will be promoted to a higher garbage collection generation. This means more Gen 1 and Gen 2 collections, which means more execution time taken by the GC, and worse performance. The rule of thumb in healthy memory management is to have objects collected as fast as possible. Collections from higher generations are more expensive. Read more on that it in my article 8 Techniques to Avoid GC Pressure and Improve Performance in C# .NET.
Using dynamic properties from the request parameters
The above method works well enough (performance issues aside), but it's not very generic. For different telemetry actions, we might want different fields. For sign up it might be Email
, for sign-in it might be FullName
, and for BuyItem
it might be ItemId
. Here's a pretty neat way to turn your middleware into a generic mechanism.
In the controller (note the queryParam
addition):
1
2
3
4
5
6
7
|
[HttpPost]
[TelemetryAttribute(TelemetryEvent.SignUp, queryParam: "email")]
public async Task<IActionResult> SignUp(string email, string firstName, string lastName)
{
// ...
}
|
In the attribute:
1
2
3
4
5
6
7
8
9
10
11
12
|
public class TelemetryAttribute : Attribute
{
public TelemetryEvent Event { get; set; }
public string QueryParam { get; set; } // Added
public TelemetryAttribute(TelemetryEvent ev, string queryParam)
{
Event = ev;
QueryParam = queryParam;
}
}
|
In the middleware:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public class TelemetryMiddleware
{
private RequestDelegate _next;
public TelemetryMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await _next(context); // The action in the controller will be called here
var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
var attribute = endpoint?.Metadata.GetMetadata<TelemetryAttribute>();
if (attribute != null)
{
var data = context.Request.Query[attribute.QueryParam];// this will be the email
// add telemetry or logging here
}
}
}
|
I really love this approach. This way, you don't have to have a giant switch/case in the middleware for each type of event. The middleware code never changes, and the only thing needed is to change the value of queryParam
in the attribute.
Of course, this relies on having the items as query parameters and not in the request body. But you can implement the same kind of logic to the body as well using something like JSONPath (similar to XPath for JSON). I'll leave that part to you.
Summary
We saw how to use ASP.NET Core's middleware system to separate telemetry or logging code from the business logic code. One of the reasons why I love ASP.NET Core is that it's versatile that way. Although I admit that getting the body from the request was a bit of a pain. Still, the ability to easily create a mechanism that allows adding telemetry just by adding attributes is pretty cool. Let me know what you think in the comments. Cheers.