ASP.NET Core 服务器端分析介绍
一个简单的中间件,为 ASP.NET Core 添加服务器端分析功能
引言
我想追踪访客,了解网站分析的常规信息:访客、来源、国籍、行为等等。
客户端分析并不可靠
- 广告拦截器会干扰它们
- 使用第三方服务需要烦恼地显示那些巨大的 Cookie 同意横幅
- 它们会急剧增加 Web 应用程序的加载时间
- 它们无法记录 API 调用以及任何非 HTML 调用,例如 Web API 调用
因此,我自己开发了一个非常简单的 .NET Core 服务器端分析系统,它正在我的网站上运行。
- 实时演示:https://matteofabbri.org/stat
- GitHub 仓库:https://github.com/matteofabbri/ServerSideAnalytics
- NuGet:https://nuget.net.cn/packages/ServerSideAnalytics
中间件
这个想法是实现一个中间件,它将在每个请求上被调用,无论是否指定了路由。
此中间件将放入任务管道,并仅使用流式方法进行设置。
中间件将在请求处理完成后,将传入的请求写入一个通用存储。
通过在应用程序启动时使用 UserServerSideAnalytics
扩展方法,可以将中间件插入到任务管道中。
此方法需要一个 IAnalyticStore
接口,该接口将是我们接收到的请求的存储位置。
public void Configure(IApplicationBuilder app)
{
app.UseServerSideAnalytics(new MongoAnalyticStore("mongodb://192.168.0.11/matteo"));
}
在扩展内部,我将创建一个 FluidAnalyticBuilder
并通过 Use
方法将其绑定到任务管道。
public static FluidAnalyticBuilder UseServerSideAnalytics
(this IApplicationBuilder app,IAnalyticStore repository)
{
var builder = new FluidAnalyticBuilder(repository);
app.Use(builder.Run);
return builder;
}
FluidAnalyticBuilder
是一个流式类,它将处理我们想要收集的分析配置(例如过滤掉不需要的 URL、IP 地址等),并通过 Run
方法实际实现系统的核心。
在此方法中,ServerSideAnalytics
将使用存储的两个方法
ResolveCountryCodeAsync
:检索(如果存在)远程 IP 地址的国家代码。
如果不存在,则预计为CountryCode.World
。StoreWebRequestAsync
:将收到的请求存储到数据库
internal async Task Run(HttpContext context, Func<Task> next)
{
//Pass the command to the next task in the pipeline
await next.Invoke();
//This request should be filtered out ?
if (_exclude?.Any(x => x(context)) ?? false)
{
return;
}
//Let's build our structure with collected data
var req = new WebRequest
{
//When
Timestamp = DateTime.Now,
//Who
Identity = context.UserIdentity(),
RemoteIpAddress = context.Connection.RemoteIpAddress,
//What
Method = context.Request.Method,
UserAgent = context.Request.Headers["User-Agent"],
Path = context.Request.Path.Value,
IsWebSocket = context.WebSockets.IsWebSocketRequest,
//From where
//Ask the store to resolve the geo code of given ip address
CountryCode = await _store.ResolveCountryCodeAsync(context.Connection.RemoteIpAddress)
};
//Store the request into the store
await _store.StoreWebRequestAsync(req);
}
(也许,我应该为收集到的请求添加其他字段?请告诉我。😊)
通过 List<Func<HttpContext, bool>> _exclude
,它还提供了方便的方法来过滤掉我们不关心的请求。
//Startup.cs
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseDeveloperExceptionPage();
app.UseBrowserLink();
app.UseDatabaseErrorPage();
app.UseAuthentication();
//Let's create our middleware using Mongo DB to store data
app.UseServerSideAnalytics(new MongoAnalyticStore("mongodb:///matteo"))
// Request into those url spaces will be not recorded
.ExcludePath("/js", "/lib", "/css")
// Request ending with this extension will be not recorded
.ExcludeExtension(".jpg", ".ico", "robots.txt", "sitemap.xml")
// I don't want to track my own activity on the website
.Exclude(x => x.UserIdentity() == "matteo")
// And also request coming from my home wifi
.ExcludeIp(IPAddress.Parse("192.168.0.1"))
// Request coming from local host will be not recorded
.ExcludeLoopBack();
app.UseStaticFiles();
}
这就是中间件的所有内容。😀
存储
您是否注意到上面中间件将收集到的数据写入一个由 IAnalyticStore
接口表示的通用存储,这个组件将处理这项工作的所有繁重任务。
我写了三个存储
- https://nuget.net.cn/packages/ServerSideAnalytics.Mongo 用于 Mongo DB
- https://nuget.net.cn/packages/ServerSideAnalytics.SqlServer 用于 Microsoft SQL Server
- https://nuget.net.cn/packages/ServerSideAnalytics.Sqlite 用于 SQLite
在附带的代码中,您会发现一个使用 SQLite 的示例站点,因此运行示例不需要任何外部进程。
存储必须实现一个接口,其中包含服务器端分析调用的两个方法以及一些用于查询已存储请求的方法。
这是因为数据库类型的隔离非常酷,但也意味着您无法将 Expression<Func<MyType,bool>>
强制转换为 Expression<Func<WebRequest,bool>>
,无论 MyType
和 WebRequest
有多么相似。
我们将在文章的最后一部分,关于如何在 Web 应用程序中展示我们的数据时,看到这些方法的用法。
public interface IAnalyticStore
{
/// <summary>
/// Store received request. Internally invoked by ServerSideAnalytics
/// </summary>
/// <param name="request">Request collected by ServerSideAnalytics</param>
/// <returns></returns>
Task StoreWebRequestAsync(WebRequest request);
/// <summary>
/// Return unique identities that made at least a request on that day
/// </summary>
/// <param name="day"></param>
/// <returns></returns>
Task<long> CountUniqueIndentitiesAsync(DateTime day);
/// <summary>
/// Return unique identities that made at least a request inside the given time interval
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns></returns>
Task<long> CountUniqueIndentitiesAsync(DateTime from, DateTime to);
/// <summary>
/// Return the raw number of request served in the time interval
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns></returns>
Task<long> CountAsync(DateTime from, DateTime to);
/// <summary>
/// Return distinct Ip Address served during that day
/// </summary>
/// <param name="day"></param>
/// <returns></returns>
Task<IEnumerable<IPAddress>> IpAddressesAsync(DateTime day);
/// <summary>
/// Return distinct IP addresses served during given time interval
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns></returns>
Task<IEnumerable<IPAddress>> IpAddressesAsync(DateTime from, DateTime to);
/// <summary>
/// Return any request that was served during this time range
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns></returns>
Task<IEnumerable<WebRequest>> InTimeRange(DateTime from, DateTime to);
/// <summary>
/// Return all the request made by this identity
/// </summary>
/// <param name="identity"></param>
/// <returns></returns>
Task<IEnumerable<WebRequest>> RequestByIdentityAsync(string identity);
/// <summary>
/// Add a geocoding ip range.
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <param name="countryCode"></param>
/// <returns></returns>
Task StoreGeoIpRangeAsync(IPAddress from, IPAddress to, CountryCode countryCode);
/// <summary>
/// Makes the geeo ip resolution of incoming request. Internally invoked by ServerSideAQnalytics
/// </summary>
/// <param name="address"></param>
/// <returns></returns>
Task<CountryCode> ResolveCountryCodeAsync(IPAddress address);
/// <summary>
/// Remove all item in request collection
/// </summary>
/// <returns></returns>
Task PurgeRequestAsync();
/// <summary>
/// Remove all items in geo ip resolution collection
/// </summary>
/// <returns></returns>
Task PurgeGeoIpAsync();
}
身份
您可能注意到每个 WebRequest
都有一个名为 Identity
的字段。这是因为最重要的数据是了解“谁做了什么”。
但它是如何评估的?
- 如果来自已注册用户,我们将使用用户名
- 如果不是,我们将使用默认的 AspNetCore Cookie
- 如果不可用,我们将使用当前连接的连接 ID
- 然后,我们将尝试将结果保存在我们自己的 Cookie 中,这样我们就无需再次执行此操作。
在代码中
public static string UserIdentity(this HttpContext context)
{
var user = context.User?.Identity?.Name;
const string identityString = "identity";
string identity;
if (!context.Request.Cookies.ContainsKey(identityString))
{
if (string.IsNullOrWhiteSpace(user))
{
identity = context.Request.Cookies.ContainsKey("ai_user")
? context.Request.Cookies["ai_user"]
: context.Connection.Id;
}
else
{
identity = user;
}
context.Response.Cookies.Append("identity", identity);
}
else
{
identity = context.Request.Cookies[identityString];
}
return identity;
}
IP 地理定位
每个分析系统中最有趣的数据之一是了解您的用户来自哪里。
因此,SSA 的 IAnalyticStore
实现了一些方法来对传入请求进行 IP 地址地理编码。
遗憾的是,在 2018 年,存在一个成熟的协议,尽管 Int128 不是一个成熟的数据类型,尤其是在数据库中。
因此,我们需要实现一个很棒的解决方法,以便能够高效地查询我们的数据库。
至少这是我在我的三个存储中使用的策略,如果您有更好的想法,可以实现自己的分析存储,甚至更好地为项目做出贡献。
我们将把每个 IP 地址范围保存为一对 string
。
算法
- 如果 IP 地址是 IPV4,它应该被映射到 IPV6,以便它们可以一起存储。
- 然后,我们将获取我们新 IP 地址的字节。
- 我们将颠倒它们,所以“
10.0.0.0
”将保持为“10.0.0.0
”而不是“10
”。 - 现在我们得到了一个表示一个非常大数字的字节字符串。
- 让我们使用所有数字打印这个数字,以便数据库可以正确地对其进行比较。
(从000000000000000000000000000000000000000
到340282366920938463463374607431768211455
)
或者在代码中
private const string StrFormat = "000000000000000000000000000000000000000";
public static string ToFullDecimalString(this IPAddress ip)
{
return (new BigInteger(ip.MapToIPv6().GetAddressBytes().Reverse().ToArray())).ToString(StrFormat);
}
我在 ServerSideAnalytics.ServerSideExtensions.ToFullDecimalString
中实现了这个函数,所以如果你想重用它,你不必像我一样抓狂。
既然我们已经将 IP 地址标准化为定义明确的 string
格式,那么查找数据库中保存的相关国家就非常简单了。
public async Task<CountryCode> ResolveCountryCodeAsync(IPAddress address)
{
var addressString = address.ToFullDecimalString();
using (var db = GetContext())
{
var found = await db.GeoIpRange.FirstOrDefaultAsync
(x => x.From.CompareTo(addressString) <= 0 &&
x.To.CompareTo(addressString) >= 0);
return found?.CountryCode ?? CountryCode.World;
}
}
但是要查询数据库,我们首先需要填充它。
找到一个可靠且廉价的国家及其相对 IP 地址范围的数据库可能相当困难。
因此,我编写了另外三个充当现有存储包装器的分析存储,以提供备用的地理 IP 解析。
如果第一个存储库不包含有效的客户端 IP 地址范围,它将询问第二个存储库,依此类推。
如果在链的末端找到了有效的地理 IP,它将被保存在主存储中。
我写了其中三个,如果您想添加更多,请在 GitHub 上贡献。
您可以在 ServerSideAnalytics.Extensions 中找到这些分析存储。
IpApiAnalyticStore
:使用 Ip Api (ip-api.com) 添加 IP 地理编码。IpInfoAnalyticStore
:使用 Ip Stack (ipinfo.io) 添加 IP 地理编码。IpStackAnalyticStore
:使用 Ip Stack (ipstack.com) 添加 IP 地理编码。
我个人正在使用一个预加载的 IP 地址范围数据库,并启用了所有三个故障转移。
public IAnalyticStore GetAnalyticStore()
{
var store = (new MongoAnalyticStore("mongodb:///"))
.UseIpStackFailOver("IpStackAPIKey")
.UseIpApiFailOver()
.UseIpInfoFailOver();
return store;
}
让我们以其中一个为例,看看它是如何工作的。
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ServerSideAnalytics.Extensions
{
class IpApiAnalyticStore : IAnalyticStore
{
readonly IAnalyticStore _store;
public IpApiAnalyticStore(IAnalyticStore store)
{
_store = store;
}
public Task<long> CountAsync(DateTime from, DateTime to) => _store.CountAsync(from, to);
public Task<long> CountUniqueIndentitiesAsync(DateTime day) =>
_store.CountUniqueIndentitiesAsync(day);
public Task<long> CountUniqueIndentitiesAsync(DateTime from, DateTime to) =>
_store.CountUniqueIndentitiesAsync(from, to);
public Task<IEnumerable<WebRequest>> InTimeRange(DateTime from, DateTime to) =>
_store.InTimeRange(from, to);
public Task<IEnumerable<IPAddress>> IpAddressesAsync(DateTime day) =>
_store.IpAddressesAsync(day);
public Task<IEnumerable<IPAddress>> IpAddressesAsync(DateTime from, DateTime to) =>
_store.IpAddressesAsync(from,to);
public Task PurgeGeoIpAsync() => _store.PurgeGeoIpAsync();
public Task PurgeRequestAsync() => _store.PurgeRequestAsync();
public Task<IEnumerable<WebRequest>> RequestByIdentityAsync(string identity) =>
_store.RequestByIdentityAsync(identity);
public async Task<CountryCode> ResolveCountryCodeAsync(IPAddress address)
{
try
{
var resolved = await _store.ResolveCountryCodeAsync(address);
if(resolved == CountryCode.World)
{
var ipstr = address.ToString();
var response = await (new HttpClient()).GetStringAsync
($"http://ip-api.com/json/{ipstr}");
var obj = JsonConvert.DeserializeObject(response) as JObject;
resolved = (CountryCode)Enum.Parse(typeof(CountryCode),
obj["country_code"].ToString());
await _store.StoreGeoIpRangeAsync(address, address, resolved);
return resolved;
}
return resolved;
}
catch (Exception)
{
return CountryCode.World;
}
}
public Task StoreGeoIpRangeAsync(IPAddress from, IPAddress to, CountryCode countryCode)
{
return _store.StoreGeoIpRangeAsync(from, to, countryCode);
}
public Task StoreWebRequestAsync(WebRequest request)
{
return _store.StoreWebRequestAsync(request);
}
}
}
好了,各位,这就是全部内容!:)