22.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--单体转微服务--增加公共代码
在拆分服务之前,我们需要先提取一些公共代码。本篇将重点新增日志记录、异常处理以及Redis的通用代码。这些组件将被整合到一个共享类库中,便于在微服务架构中高效复用。
Tip:在后续的教程中我们会穿插多篇提取公共代码的文章,帮助大家更好地理解如何将单体应用拆分为微服务。
在创建通用代码前,我们需要创建通用代码类库。首先,我们需要在当前git库中新建一个基于单体应用的分支 Microservices,并切换到这个分支,后续的操作都在这个分支上进行。具体操作这里就不赘述了,不清楚的可以去翻阅我的另一个专栏《GIT版本控制》。接着,在解决方案下新建一个类库项目,命名为 SP.Common,这个类库将用于存放我们提取的公共代码。最后在 SP.Common 类库中创建三个文件夹,分别命名为 Redis、ExceptionHandling 和 Logger,用于存放Redis相关的代码、异常处理相关的代码和日志记录相关的代码。
在创建完文件夹后,我们就可以开始编写代码了。我们将从Redis相关的代码开始,接着是异常处理相关的代码,最后是日志记录相关的代码。每个部分的代码都将包含详细的注释和说明,以便于大家理解和使用。
一、Redis代码
在 Redis 文件夹中我们要实现Redis相关的通用代码,代码结构采用接口与实现分离的设计模式,通过IRedisService
接口定义操作,RedisService
类实现具体功能,同时使用依赖注入和选项模式简化配置和使用。整体设计封装了底层StackExchange.Redis
库的操作,提供异常处理和日志记录,支持多种Redis功能包括字符串、对象操作、哈希表、分布式锁和发布订阅。
1.1 Redis配置类
首先,我们需要创建Redis配置类,用于存储Redis连接的相关配置信息。这个类将作为选项模式(Options Pattern)的一部分,便于在依赖注入系统中配置和使用Redis服务。代码很简单,这里就不讲解了。
namespace SP.Common.Redis
{/// <summary>/// Redis配置选项/// </summary>public class RedisOptions{/// <summary>/// Redis连接字符串/// </summary>public string ConnectionString { get; set; } = "localhost:6379";/// <summary>/// 默认数据库索引/// </summary>public int DefaultDatabase { get; set; } = 0;/// <summary>/// 连接空闲超时时间(秒)/// </summary>public int ConnectionIdleTimeout { get; set; } = 180;/// <summary>/// 连接超时时间(毫秒)/// </summary>public int ConnectTimeout { get; set; } = 5000;/// <summary>/// 默认缓存过期时间(秒)/// </summary>public int DefaultExpireSeconds { get; set; } = 3600;}
}
Tip:选项模式(Options Pattern)是.NET Core中一种推荐的配置管理方式,它允许我们将应用程序的配置分离到一个或多个类中,并通过依赖注入将这些配置类注入到需要它们的服务中,这样可以使代码更加清晰和可维护。
1.2 Redis服务接口
接下来,我们需要创建Redis服务接口IRedisService
,这个接口定义了我们需要的Redis操作方法,包括字符串、对象、哈希表、分布式锁和发布订阅等操作。代码如下:
namespace SP.Common.Redis
{/// <summary>/// Redis服务接口/// </summary>public interface IRedisService{/// <summary>/// 获取字符串值/// </summary>/// <param name="key">键</param>/// <returns>字符串值</returns>Task<string?> GetStringAsync(string key);/// <summary>/// 设置字符串值/// </summary>/// <param name="key">键</param>/// <param name="value">值</param>/// <param name="expirySeconds">过期时间(秒),默认使用配置中的默认过期时间</param>/// <returns>是否成功</returns>Task<bool> SetStringAsync(string key, string value, int? expirySeconds = null);/// <summary>/// 获取对象/// </summary>/// <typeparam name="T">对象类型</typeparam>/// <param name="key">键</param>/// <returns>对象</returns>Task<T?> GetAsync<T>(string key) where T : class;/// <summary>/// 设置对象/// </summary>/// <typeparam name="T">对象类型</typeparam>/// <param name="key">键</param>/// <param name="value">值</param>/// <param name="expirySeconds">过期时间(秒),默认使用配置中的默认过期时间</param>/// <returns>是否成功</returns>Task<bool> SetAsync<T>(string key, T value, int? expirySeconds = null) where T : class;/// <summary>/// 删除键/// </summary>/// <param name="key">键</param>/// <returns>是否成功</returns>Task<bool> RemoveAsync(string key);/// <summary>/// 键是否存在/// </summary>/// <param name="key">键</param>/// <returns>是否存在</returns>Task<bool> ExistsAsync(string key);/// <summary>/// 设置过期时间/// </summary>/// <param name="key">键</param>/// <param name="expirySeconds">过期时间(秒)</param>/// <returns>是否成功</returns>Task<bool> SetExpiryAsync(string key, int expirySeconds);/// <summary>/// 批量获取/// </summary>/// <param name="keys">键集合</param>/// <returns>值字典</returns>Task<Dictionary<string, string>> GetAllStringAsync(IEnumerable<string> keys);/// <summary>/// 获取所有匹配的键/// </summary>/// <param name="pattern">匹配模式</param>/// <returns>键集合</returns>Task<IEnumerable<string>> GetKeysAsync(string pattern);/// <summary>/// 获取Hash值/// </summary>/// <param name="key">Hash键</param>/// <param name="field">字段</param>/// <returns>值</returns>Task<string?> HashGetAsync(string key, string field);/// <summary>/// 设置Hash值/// </summary>/// <param name="key">Hash键</param>/// <param name="field">字段</param>/// <param name="value">值</param>/// <returns>是否成功</returns>Task<bool> HashSetAsync(string key, string field, string value);/// <summary>/// 获取所有Hash值/// </summary>/// <param name="key">Hash键</param>/// <returns>字段值字典</returns>Task<Dictionary<string, string>> HashGetAllAsync(string key);/// <summary>/// 发布消息/// </summary>/// <param name="channel">频道</param>/// <param name="message">消息</param>/// <returns>接收到消息的客户端数量</returns>Task<long> PublishAsync(string channel, string message);/// <summary>/// 获取分布式锁/// </summary>/// <param name="key">锁键</param>/// <param name="expiry">锁过期时间</param>/// <returns>是否成功获取锁</returns>Task<bool> LockAsync(string key, TimeSpan expiry);/// <summary>/// 释放分布式锁/// </summary>/// <param name="key">锁键</param>/// <returns>是否成功释放锁</returns>Task<bool> UnlockAsync(string key);}
}
我们在前述代码中可以看到,所有方法都是异步的,这样可以提高性能,避免阻塞线程。并且我们使用了Task
作为返回类型,这样可以方便地与异步编程模型结合使用。
Tip:在.NET中,异步编程是一种重要的编程模型,它允许我们在等待某些操作完成时继续执行其他操作,从而提高应用程序的响应性和性能。使用
async
和await
关键字可以轻松实现异步编程。
1.3 Redis服务实现
我们还需要实现IRedisService
接口的实现类RedisService
。这个类将实现所有的Redis操作方法,并使用StackExchange.Redis
库与Redis进行交互,因此需要在类库中安装StackExchange.Redis
包。我们可以使用NuGet包管理器或者命令行工具安装这个包,命令如下:
dotnet add package StackExchange.Redis
安装完成后,我们就可以在RedisService
类中使用StackExchange.Redis
库了。这个类的实现不是很复杂,主要是对Redis的操作进行了封装,并添加了异常处理和日志记录。
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;namespace SP.Common.Redis
{/// <summary>/// Redis服务实现/// </summary>public class RedisService : IRedisService{private readonly ILogger<RedisService> _logger;private readonly RedisOptions _options;private readonly Lazy<ConnectionMultiplexer> _connectionMultiplexer;private readonly string _lockValuePrefix;/// <summary>/// 构造函数/// </summary>/// <param name="options">Redis配置选项</param>/// <param name="logger">日志器</param>public RedisService(IOptions<RedisOptions> options, ILogger<RedisService> logger){_logger = logger;_options = options.Value;_lockValuePrefix = $"lock:{Environment.MachineName}:{Guid.NewGuid()}:";_connectionMultiplexer = new Lazy<ConnectionMultiplexer>(() =>{var configOptions = ConfigurationOptions.Parse(_options.ConnectionString);configOptions.DefaultDatabase = _options.DefaultDatabase;configOptions.ConnectTimeout = _options.ConnectTimeout;configOptions.AbortOnConnectFail = false;return ConnectionMultiplexer.Connect(configOptions);});}/// <summary>/// 获取Redis连接/// </summary>private ConnectionMultiplexer Connection => _connectionMultiplexer.Value;/// <summary>/// 获取Redis数据库/// </summary>private IDatabase Database => Connection.GetDatabase();/// <summary>/// 获取字符串值/// </summary>public async Task<string?> GetStringAsync(string key){try{var value = await Database.StringGetAsync(key);return value.HasValue ? value.ToString() : null;}catch (Exception ex){_logger.LogError(ex, "Redis获取字符串值失败,Key: {Key}", key);return null;}}/// <summary>/// 设置字符串值/// </summary>public async Task<bool> SetStringAsync(string key, string value, int? expirySeconds = null){try{var expiry = TimeSpan.FromSeconds(expirySeconds ?? _options.DefaultExpireSeconds);return await Database.StringSetAsync(key, value, expiry);}catch (Exception ex){_logger.LogError(ex, "Redis设置字符串值失败,Key: {Key}", key);return false;}}/// <summary>/// 获取对象/// </summary>public async Task<T?> GetAsync<T>(string key) where T : class{try{var value = await GetStringAsync(key);if (string.IsNullOrEmpty(value)){return null;}return JsonSerializer.Deserialize<T>(value);}catch (Exception ex){_logger.LogError(ex, "Redis获取对象失败,Key: {Key}, Type: {Type}", key, typeof(T).Name);return null;}}/// <summary>/// 设置对象/// </summary>public async Task<bool> SetAsync<T>(string key, T value, int? expirySeconds = null) where T : class{if (value == null){throw new ArgumentNullException(nameof(value));}try{var json = JsonSerializer.Serialize(value);return await SetStringAsync(key, json, expirySeconds);}catch (Exception ex){_logger.LogError(ex, "Redis设置对象失败,Key: {Key}, Type: {Type}", key, typeof(T).Name);return false;}}/// <summary>/// 删除键/// </summary>public async Task<bool> RemoveAsync(string key){try{return await Database.KeyDeleteAsync(key);}catch (Exception ex){_logger.LogError(ex, "Redis删除键失败,Key: {Key}", key);return false;}}/// <summary>/// 键是否存在/// </summary>public async Task<bool> ExistsAsync(string key){try{return await Database.KeyExistsAsync(key);}catch (Exception ex){_logger.LogError(ex, "Redis检查键是否存在失败,Key: {Key}", key);return false;}}/// <summary>/// 设置过期时间/// </summary>public async Task<bool> SetExpiryAsync(string key, int expirySeconds){try{return await Database.KeyExpireAsync(key, TimeSpan.FromSeconds(expirySeconds));}catch (Exception ex){_logger.LogError(ex, "Redis设置过期时间失败,Key: {Key}", key);return false;}}/// <summary>/// 批量获取/// </summary>public async Task<Dictionary<string, string>> GetAllStringAsync(IEnumerable<string> keys){try{var keyArray = keys.ToArray();var redisKeys = keyArray.Select(k => (RedisKey)k).ToArray();var values = await Database.StringGetAsync(redisKeys);var result = new Dictionary<string, string>();for (var i = 0; i < keyArray.Length; i++){if (values[i].HasValue){result.Add(keyArray[i], values[i].ToString());}}return result;}catch (Exception ex){_logger.LogError(ex, "Redis批量获取失败");return new Dictionary<string, string>();}}/// <summary>/// 获取所有匹配的键/// </summary>public async Task<IEnumerable<string>> GetKeysAsync(string pattern){try{var keys = new List<string>();var endpoints = Connection.GetEndPoints();foreach (var endpoint in endpoints){var server = Connection.GetServer(endpoint);var serverKeys = server.Keys(pattern: pattern).Select(k => (string)k).ToList();keys.AddRange(serverKeys);}return await Task.FromResult(keys);}catch (Exception ex){_logger.LogError(ex, "Redis获取匹配键失败,Pattern: {Pattern}", pattern);return Enumerable.Empty<string>();}}/// <summary>/// 获取Hash值/// </summary>public async Task<string?> HashGetAsync(string key, string field){try{var value = await Database.HashGetAsync(key, field);return value.HasValue ? value.ToString() : null;}catch (Exception ex){_logger.LogError(ex, "Redis获取Hash值失败,Key: {Key}, Field: {Field}", key, field);return null;}}/// <summary>/// 设置Hash值/// </summary>public async Task<bool> HashSetAsync(string key, string field, string value){try{return await Database.HashSetAsync(key, field, value);}catch (Exception ex){_logger.LogError(ex, "Redis设置Hash值失败,Key: {Key}, Field: {Field}", key, field);return false;}}/// <summary>/// 获取所有Hash值/// </summary>public async Task<Dictionary<string, string>> HashGetAllAsync(string key){try{var entries = await Database.HashGetAllAsync(key);return entries.ToDictionary(entry => entry.Name.ToString(),entry => entry.Value.ToString());}catch (Exception ex){_logger.LogError(ex, "Redis获取所有Hash值失败,Key: {Key}", key);return new Dictionary<string, string>();}}/// <summary>/// 发布消息/// </summary>public async Task<long> PublishAsync(string channel, string message){try{return await Connection.GetSubscriber().PublishAsync(channel, message);}catch (Exception ex){_logger.LogError(ex, "Redis发布消息失败,Channel: {Channel}", channel);return 0;}}/// <summary>/// 获取分布式锁/// </summary>public async Task<bool> LockAsync(string key, TimeSpan expiry){try{var lockKey = $"lock:{key}";var lockValue = $"{_lockValuePrefix}{DateTime.UtcNow.Ticks}";// SET命令的NX选项确保键不存在时才设置值return await Database.StringSetAsync(lockKey, lockValue, expiry, When.NotExists);}catch (Exception ex){_logger.LogError(ex, "Redis获取分布式锁失败,Key: {Key}", key);return false;}}/// <summary>/// 释放分布式锁/// </summary>public async Task<bool> UnlockAsync(string key){try{var lockKey = $"lock:{key}";return await Database.KeyDeleteAsync(lockKey);}catch (Exception ex){_logger.LogError(ex, "Redis释放分布式锁失败,Key: {Key}", key);return false;}}}
}
在RedisService
类中,我们使用了Lazy<T>
来延迟初始化ConnectionMultiplexer
,这样可以避免在应用程序启动时就连接Redis,提高性能。并且我们使用了IOptions<T>
来获取配置选项,这样可以方便地在依赖注入系统中配置和使用Redis服务。
1.4 Redis扩展方法
最后,我们还需要创建一个扩展方法类RedisServiceExtensions
,用于将Redis服务注册到依赖注入系统中,方便我们后续在应用程序中使用。代码如下:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;namespace SP.Common.Redis
{/// <summary>/// Redis服务扩展方法/// </summary>public static class RedisServiceExtensions{/// <summary>/// 添加Redis服务/// </summary>/// <param name="services">服务集合</param>/// <param name="configuration">配置</param>/// <returns>服务集合</returns>public static IServiceCollection AddRedisService(this IServiceCollection services, IConfiguration configuration){// 从配置中获取Redis节点var redisSection = configuration.GetSection("Redis");// 注册RedisOptionsservices.Configure<RedisOptions>(options =>{// 将配置节点中的值绑定到options对象if (redisSection["ConnectionString"] != null)options.ConnectionString = redisSection["ConnectionString"];if (int.TryParse(redisSection["DefaultDatabase"], out int defaultDb))options.DefaultDatabase = defaultDb;if (int.TryParse(redisSection["ConnectionIdleTimeout"], out int idleTimeout))options.ConnectionIdleTimeout = idleTimeout;if (int.TryParse(redisSection["ConnectTimeout"], out int connectTimeout))options.ConnectTimeout = connectTimeout;if (int.TryParse(redisSection["DefaultExpireSeconds"], out int expireSeconds))options.DefaultExpireSeconds = expireSeconds;});// 注册Redis服务services.AddSingleton<IRedisService, RedisService>();return services;}/// <summary>/// 添加Redis服务/// </summary>/// <param name="services">服务集合</param>/// <param name="connectionString">连接字符串</param>/// <returns>服务集合</returns>public static IServiceCollection AddRedisService(this IServiceCollection services, string connectionString){// 注册RedisOptionsservices.Configure<RedisOptions>(options => { options.ConnectionString = connectionString; });// 注册Redis服务services.AddSingleton<IRedisService, RedisService>();return services;}}
}
在这个扩展方法中,我们使用了IConfiguration
来获取Redis的配置选项,并将其绑定到RedisOptions
对象中。然后,我们将IRedisService
注册为单例服务,这样在应用程序中就可以通过依赖注入来使用Redis服务了。
二、日志记录
在 Logger 文件夹中,我们要实现日志记录的通用代码。我们将集成 Serilog 日志框架来实现日志记录,并提供了多种日志级别的支持,包括信息、警告、错误等。我们还将创建一个扩展方法AddLoggerService
,用于将日志记录器注册到依赖注入系统中,方便我们后续在应用程序中使用。
2.1 日志服务接口
我们需要创建一个日志服务接口ILoggerService
,这个接口定义了我们需要的日志操作方法,包括信息、警告、错误等操作。代码如下:
namespace SP.Common.Logger;/// <summary>
/// 日志服务接口
/// </summary>
public interface ILoggerService
{/// <summary>/// 记录信息日志/// </summary>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogInformation(string message, params object[] args);/// <summary>/// 记录警告日志/// </summary>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogWarning(string message, params object[] args);/// <summary>/// 记录错误日志/// </summary>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogError(string message, params object[] args);/// <summary>/// 记录错误日志/// </summary>/// <param name="exception">异常</param>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogError(Exception exception, string message, params object[] args);/// <summary>/// 记录调试日志/// </summary>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogDebug(string message, params object[] args);/// <summary>/// 记录关键错误日志/// </summary>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogCritical(string message, params object[] args);/// <summary>/// 记录关键错误日志/// </summary>/// <param name="exception">异常</param>/// <param name="message">日志消息</param>/// <param name="args">格式化参数</param>void LogCritical(Exception exception, string message, params object[] args);
}
在这个接口中,我们定义了多个日志记录方法,包括信息、警告、错误、调试和关键错误等方法。每个方法都接受一个消息参数和可选的格式化参数,这样可以方便地记录不同类型的日志。
2.2 日志服务实现
接下来,我们需要实现ILoggerService
接口的实现类LoggerService
。这个类将实现所有的日志操作方法,并使用 Serilog 日志框架与日志进行交互,因此需要在类库中安装 Serilog 包。我们可以使用NuGet包管理器或者命令行工具安装这个包,命令如下:
dotnet add package Serilog
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Grafana.Loki
安装完成后,我们就可以在LoggerService
类中使用 Serilog 日志框架了。
using Microsoft.Extensions.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;namespace SP.Common.Logger
{/// <summary>/// 日志服务实现/// </summary>public class LoggerService : ILoggerService{private readonly ILogger _logger;/// <summary>/// 构造函数/// </summary>/// <param name="logger">日志记录器</param>public LoggerService(ILogger<LoggerService> logger){_logger = logger;}/// <summary>/// 记录信息级别的日志/// </summary>/// <param name="message"></param>/// <param name="args"></param>public void LogInformation(string message, params object[] args){_logger.LogInformation(message, args);}/// <summary>/// 记录警告级别的日志/// </summary>/// <param name="message"></param>/// <param name="args"></param>public void LogWarning(string message, params object[] args){_logger.LogWarning(message, args);}/// <summary>/// 记录错误级别的日志/// </summary>/// <param name="message"></param>/// <param name="args"></param>public void LogError(string message, params object[] args){_logger.LogError(message, args);}/// <summary>/// 记录错误级别的日志/// </summary>/// <param name="exception"></param>/// <param name="message"></param>/// <param name="args"></param>public void LogError(Exception exception, string message, params object[] args){_logger.LogError(exception, message, args);}/// <summary>/// 记录调试级别的日志/// </summary>/// <param name="message"></param>/// <param name="args"></param>public void LogDebug(string message, params object[] args){_logger.LogDebug(message, args);}/// <summary>/// 记录关键错误级别的日志/// </summary>/// <param name="message"></param>/// <param name="args"></param>public void LogCritical(string message, params object[] args){_logger.LogCritical(message, args);}/// <summary>/// 记录关键错误级别的日志/// </summary>/// <param name="exception"></param>/// <param name="message"></param>/// <param name="args"></param>public void LogCritical(Exception exception, string message, params object[] args){_logger.LogCritical(exception, message, args);}}
}
在LoggerService
类中,我们使用了 Serilog 日志框架来记录日志。我们将日志记录器注入到构造函数中,并在各个日志方法中调用相应的日志记录方法。代码很简单,这里不再详细讲解。
2.3 Loki日志配置服务
然后创建一个Loki日志配置服务LokiLoggerConfiguration
,用于配置Loki日志的相关信息。首先创建Loki日志配置类LokiOptions
,用于存储Loki日志的相关配置信息,代码如下:
namespace SP.Common.Logger
{/// <summary>/// Loki日志配置选项/// </summary>public class LokiOptions{/// <summary>/// Loki服务器地址,例如:http://loki:3100/// </summary>public string Url { get; set; } = string.Empty;/// <summary>/// 应用名称,用于标识日志来源/// </summary>public string AppName { get; set; } = "SporeAccounting";/// <summary>/// 环境名称,如development、production等/// </summary>public string Environment { get; set; } = "development";/// <summary>/// 用户名(如果Loki配置了基本认证)/// </summary>public string? Username { get; set; }/// <summary>/// 密码(如果Loki配置了基本认证)/// </summary>public string? Password { get; set; }}
}
在这个类中,我们定义了Loki日志的相关配置信息,包括Loki服务器地址、应用名称、环境名称、用户名和密码等信息。接下来,我们需要创建一个Loki日志配置服务接口ILokiLoggerConfigService
,代码如下:
namespace SP.Common.Logger;/// <summary>
/// Loki日志配置服务接口
/// </summary>
public interface ILokiLoggerConfigService
{/// <summary>/// 配置并返回Serilog日志记录器/// </summary>/// <returns>已配置的Serilog日志记录器</returns>Serilog.Core.Logger ConfigureLogger();
}
在这个接口中,我们定义了一个方法LokiLoggerConfigService
,用于配置并返回Serilog日志记录器。接下来,我们需要实现这个接口的实现类LokiLoggerConfigService
,代码如下:
using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.Grafana.Loki;namespace SP.Common.Logger
{/// <summary>/// Loki日志配置服务实现/// </summary>public class LokiLoggerConfigService : ILokiLoggerConfigService{private readonly LokiOptions _options;/// <summary>/// 构造函数/// </summary>/// <param name="options">Loki配置选项</param>public LokiLoggerConfigService(IOptions<LokiOptions> options){_options = options.Value;}/// <summary>/// 配置并返回Serilog日志记录器/// </summary>/// <returns>配置的Serilog日志记录器</returns>public Serilog.Core.Logger ConfigureLogger(){// 创建基本标签var labels = new List<LokiLabel>(){new LokiLabel(){Key = "app",Value = _options.AppName},new LokiLabel(){Key = "environment",Value = _options.Environment}};// 创建Loki配置var credentials = string.IsNullOrEmpty(_options.Username)? null: new LokiCredentials{Login = _options.Username,Password = _options.Password};// 配置Serilogvar configuration = new LoggerConfiguration().MinimumLevel.Debug().MinimumLevel.Override("Microsoft", LogEventLevel.Information).Enrich.FromLogContext().WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Debug,outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}").WriteTo.GrafanaLoki(uri: _options.Url,credentials: credentials,textFormatter: null,batchPostingLimit: 100,queueLimit: 10000,period: TimeSpan.FromSeconds(2),labels: labels,restrictedToMinimumLevel: LogEventLevel.Information);return configuration.CreateLogger();}}
}
在LokiLoggerConfigService
类中,我们使用了Loki日志的相关配置信息来配置Serilog日志记录器。我们创建了基本标签和Loki配置,并使用这些信息来配置Serilog日志记录器,最后返回已配置的Serilog日志记录器。其中,基本标签的作用是区分不同的日志来源,例如应用名称和环境名称等信息。Loki配置则是用于连接Loki服务器的相关信息,包括用户名和密码等信息,同时配置配置日志输出到控制台,并调用
.WriteTo.GrafanaLoki方法来将日志同时输出到Loki服务器。
2.4 日志服务扩展方法
最后创建一个扩展方法类LoggerServiceExtensions
,用于将日志服务注册到依赖注入系统中,方便我们后续在应用程序中使用。代码如下:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;namespace SP.Common.Logger
{/// <summary>/// 日志服务扩展方法/// </summary>public static class LoggerServiceExtensions{/// <summary>/// 添加日志服务/// </summary>/// <param name="services">服务集合</param>/// <param name="configuration">配置</param>/// <returns>服务集合</returns>public static IServiceCollection AddLoggerService(this IServiceCollection services, IConfiguration configuration){// 从配置中获取Loki选项services.Configure<LokiOptions>(configuration.GetSection("Loki"));// 注册Loki日志配置服务services.AddSingleton<ILokiLoggerConfigService, LokiLoggerConfigService>();// 配置Serilog并设置为默认日志提供程序var sp = services.BuildServiceProvider();var lokiConfigService = sp.GetRequiredService<ILokiLoggerConfigService>();Log.Logger = lokiConfigService.ConfigureLogger();// 添加Serilogservices.AddLogging(loggingBuilder =>{loggingBuilder.ClearProviders();loggingBuilder.AddSerilog(dispose: true);});// 注册日志服务services.AddScoped<ILoggerService, LoggerService>();return services;}}
}
在这个扩展方法中,我们使用了IConfiguration
来获取Loki的配置选项,并将其绑定到LokiOptions
对象中。然后,我们将ILokiLoggerConfigService
注册为单例服务,并配置Serilog日志记录器。最后,我们将ILoggerService
注册为作用域服务,这样在应用程序中就可以通过依赖注入来使用日志服务了。
三、异常处理代码
在 ExceptionHandling 文件夹中,我们要实现异常处理的通用代码。我们将创建一个中间件ExceptionHandlingMiddleware
,用于捕获应用程序中的未处理异常,并记录日志。这个中间件将会在请求管道中被调用,当发生异常时,它会将异常信息记录到日志中,并返回一个友好的错误响应。异常处理涉及到的代码比较多,但大部分代码类似,因此在这一小节我们只展示关键代码,其他的代码可以参考Github上的完整代码。
3.1 异常基类
当前项目中,所有请求的异常类都继承自AppException
类,这个类是一个自定义的异常基类,包含了错误码、错误信息以及内部异常信息,代码如下:
using System.Net;namespace SP.Common.ExceptionHandling.Exceptions
{/// <summary>/// 应用程序自定义异常基类/// </summary>public class AppException : Exception{/// <summary>/// HTTP状态码/// </summary>public HttpStatusCode StatusCode { get; }/// <summary>/// 创建一个应用程序异常实例/// </summary>/// <param name="message">错误消息</param>/// <param name="statusCode">HTTP状态码</param>public AppException(string message, HttpStatusCode statusCode = HttpStatusCode.InternalServerError): base(message){StatusCode = statusCode;}/// <summary>/// 创建一个应用程序异常实例/// </summary>/// <param name="message">错误消息</param>/// <param name="innerException">内部异常</param>/// <param name="statusCode">HTTP状态码</param>public AppException(string message, Exception innerException, HttpStatusCode statusCode = HttpStatusCode.InternalServerError): base(message, innerException){StatusCode = statusCode;}}
}
在这个类中,我们定义了一个StatusCode
属性,用于存储HTTP状态码。我们还提供了两个构造函数,一个用于传入错误消息和状态码,另一个用于传入错误消息、内部异常和状态码。我们可以在应用程序中抛出这个异常类的实例,来表示应用程序中的错误,但是在项目中我们一般不会直接使用这个类,而是使用它的派生类,在当前代码中,已经定义了五个异常类,分别是BadRequestException
、ForbiddenException
、NotFoundException
、UnauthorizedException
和ValidationException
,这些异常类都继承自AppException
类,并提供了不同的HTTP状态码。
3.2 异常中间件
与前面的Redis和日志服务类似,我们还需要创建一个中间件ExceptionHandlingMiddleware
,用于捕获应用程序中的未处理异常,并记录日志。这个中间件将会在请求管道中被调用,当发生异常时,它会将异常信息记录到日志中,并返回一个友好的错误响应。代码如下:
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using SP.Common.ExceptionHandling.Exceptions;
using SP.Common.Logger;namespace SP.Common.ExceptionHandling
{/// <summary>/// 全局异常处理中间件/// </summary>public class ExceptionHandlingMiddleware{private readonly RequestDelegate _next;private readonly ILogger<ExceptionHandlingMiddleware> _logger;private readonly ILoggerService _loggerService;/// <summary>/// 构造函数/// </summary>/// <param name="next">请求委托</param>/// <param name="logger">日志记录器</param>/// <param name="loggerService">日志服务</param>public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger,ILoggerService loggerService){_next = next;_logger = logger;_loggerService = loggerService;}/// <summary>/// 处理请求/// </summary>/// <param name="context">HTTP上下文</param>/// <returns>Task</returns>public async Task Invoke(HttpContext context){try{await _next(context);}catch (Exception ex){await HandleExceptionAsync(context, ex);}}private async Task HandleExceptionAsync(HttpContext context, Exception exception){// 记录详细异常信息到日志_logger.LogError(exception, "处理请求时发生未处理的异常: {Message}", exception.Message);// 记录到Loki日志(包含更详细的信息)LogExceptionToLoki(context, exception);// 设置响应内容类型context.Response.ContentType = "application/json";// 获取状态码和错误消息HttpStatusCode statusCode = HttpStatusCode.InternalServerError;string errorMessage = "服务器内部错误,请稍后再试";object errors = null;// 根据异常类型设置不同的状态码和错误消息if (exception is ValidationException validationException){statusCode = validationException.StatusCode;errorMessage = validationException.Message;errors = validationException.Errors;}else if (exception is AppException appException){statusCode = appException.StatusCode;errorMessage = appException.Message;}else if (exception is ArgumentException){statusCode = HttpStatusCode.BadRequest;errorMessage = exception.Message;}else if (exception is UnauthorizedAccessException){statusCode = HttpStatusCode.Unauthorized;errorMessage = "未授权访问";}// 可以根据需要添加更多的异常类型处理// 设置响应状态码context.Response.StatusCode = (int)statusCode;// 创建异常响应对象var response = new ExceptionResponse{StatusCode = statusCode,ErrorMessage = errorMessage};// 如果有验证错误,添加到响应中if (errors != null){var jsonResponse = JsonSerializer.Serialize(new{response.StatusCode,response.ErrorMessage,Errors = errors}, new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase});await context.Response.WriteAsync(jsonResponse);return;}// 在开发环境下可以返回详细的异常信息,生产环境下只返回友好消息#if DEBUGif (!(exception is AppException)){response.ErrorMessage = exception.Message;}response.StackTrace = exception.StackTrace;#endif// 序列化响应对象var jsonResponseDefault = JsonSerializer.Serialize(response, new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase});// 写入响应await context.Response.WriteAsync(jsonResponseDefault);}/// <summary>/// 记录异常到Loki日志/// </summary>/// <param name="context">HTTP上下文</param>/// <param name="exception">异常</param>private void LogExceptionToLoki(HttpContext context, Exception exception){try{// 收集请求信息var request = context.Request;var requestPath = request.Path;var requestMethod = request.Method;var requestQuery = request.QueryString.ToString();var requestHeaders = SerializeHeaders(request.Headers);string requestBody = "未捕获";try{// 如果请求是可重置的,尝试读取bodyif (request.Body.CanSeek){var position = request.Body.Position;request.Body.Position = 0;using var reader = new StreamReader(request.Body, leaveOpen: true);requestBody = reader.ReadToEndAsync().GetAwaiter().GetResult();request.Body.Position = position;}}catch{// 忽略读取请求体的错误}// 构建详细的日志消息var errorLogModel = new{RequestInfo = new{Url = requestPath,Method = requestMethod,QueryString = requestQuery,Headers = requestHeaders,Body = requestBody},ExceptionInfo = new{Message = exception.Message,ExceptionType = exception.GetType().FullName,StackTrace = exception.StackTrace,InnerException = exception.InnerException?.Message},User = GetUserInfo(context),Timestamp = DateTime.UtcNow};// 序列化为JSON以便于在Loki中查看var errorLogJson = JsonSerializer.Serialize(errorLogModel, new JsonSerializerOptions{WriteIndented = true,PropertyNamingPolicy = JsonNamingPolicy.CamelCase});// 记录到Loki_loggerService.LogError(exception, "处理请求发生异常: {ErrorDetails}", errorLogJson);}catch (Exception ex){// 如果记录日志本身出错,使用标准日志记录_logger.LogError(ex, "记录异常到Loki时发生错误");}}/// <summary>/// 序列化请求头/// </summary>/// <param name="headers">请求头集合</param>/// <returns>序列化后的请求头</returns>private Dictionary<string, string> SerializeHeaders(IHeaderDictionary headers){var result = new Dictionary<string, string>();foreach (var header in headers){// 排除敏感信息,如Authorization、Cookie等if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase) &&!header.Key.Equals("Cookie", StringComparison.OrdinalIgnoreCase)){result[header.Key] = header.Value.ToString();}}return result;}/// <summary>/// 获取用户信息/// </summary>/// <param name="context">HTTP上下文</param>/// <returns>用户信息</returns>private object GetUserInfo(HttpContext context){try{var userId = context.User?.Identity?.Name;var isAuthenticated = context.User?.Identity?.IsAuthenticated ?? false;return new{UserId = userId,IsAuthenticated = isAuthenticated,IpAddress = context.Connection.RemoteIpAddress?.ToString()};}catch{return new { IpAddress = context.Connection.RemoteIpAddress?.ToString() };}}}
}
在ExceptionHandlingMiddleware
类中,我们实现了一个Invoke
方法,用于处理请求。在这个方法中,我们调用下一个中间件,并捕获任何未处理的异常。如果发生异常,我们调用HandleExceptionAsync
方法来处理异常。在这个方法中,我们记录异常信息到日志,并返回一个友好的错误响应。我们还提供了一个LogExceptionToLoki
方法,用于将异常信息记录到Loki日志中。
3.3 异常处理扩展方法
最后创建一个扩展方法类ExceptionHandlingMiddlewareExtensions
,用于将异常处理中间件注册到请求管道中。代码如下:
using Microsoft.AspNetCore.Builder;namespace SP.Common.ExceptionHandling
{/// <summary>/// 异常处理中间件扩展/// </summary>public static class ExceptionHandlingMiddlewareExtensions{/// <summary>/// 使用全局异常处理中间件/// </summary>/// <param name="builder">应用程序构建器</param>/// <returns>应用程序构建器</returns>public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder builder){return builder.UseMiddleware<ExceptionHandlingMiddleware>();}/// <summary>/// 启用请求缓冲,使请求体可以被多次读取/// </summary>/// <param name="builder">应用程序构建器</param>/// <returns>应用程序构建器</returns>public static IApplicationBuilder UseRequestBuffering(this IApplicationBuilder builder){return builder.UseMiddleware<EnableRequestBufferingMiddleware>();}/// <summary>/// 使用全局异常处理(包括请求缓冲)/// </summary>/// <param name="builder">应用程序构建器</param>/// <returns>应用程序构建器</returns>public static IApplicationBuilder UseFullExceptionHandling(this IApplicationBuilder builder){return builder.UseRequestBuffering().UseExceptionHandling();}}
}
在这个扩展方法中,我们提供了一个UseExceptionHandling
方法,用于将异常处理中间件注册到请求管道中。我们还提供了一个UseRequestBuffering
方法,用于启用请求缓冲,使请求体可以被多次读取。最后,我们提供了一个UseFullExceptionHandling
方法,用于同时注册这两个中间件。
3.4 启用请求缓冲中间件
在前面代码中,我们看到了EnableRequestBufferingMiddleware
中间件,这个中间件的作用是启用请求缓冲,使请求体可以被多次读取。启用请求缓冲中间件的主要原因是在ASP.NET Core中,请求体(Request Body)默认是一个单向流,只能被读取一次。如果在管道的某个中间件中读取了请求体,那么后续的中间件将无法再次读取相同的内容。其次当发生异常时,我们希望在异常处理中间件中能够记录完整的请求信息,包括请求体内容。如果没有启用请求缓冲,当异常发生时,请求体可能已经被之前的中间件或控制器读取过,导致异常处理中间件无法获取请求体内容。对于复杂错误的调试,完整的请求上下文信息非常重要。通过启用请求缓冲,我们可以确保在Loki日志中记录完整的请求信息,包括请求体数据,这对于排查问题特别有价值。并且在某些场景下,应用可能需要多次读取请求体数据,例如先进行请求验证,然后进行请求处理,最后可能还需要记录请求日志。启用请求缓冲可以满足这些多次读取的需求。
具体实现是EnableRequestBufferingMiddleware
通过调用context.Request.EnableBuffering()
方法,将请求体内容加载到内存中并允许多次读取。这样,在请求处理过程中的任何地方,包括异常处理中间件,都可以读取到完整的请求体内容。
Tip:启用请求缓冲会占用额外的内存资源,特别是对于大型请求体。因此,在配置请求缓冲时,可能需要考虑设置缓冲大小限制,以防止潜在的内存问题。
using Microsoft.AspNetCore.Http;namespace SP.Common
{/// <summary>/// 启用请求缓冲中间件,使请求体可以被多次读取/// </summary>public class EnableRequestBufferingMiddleware{private readonly RequestDelegate _next;/// <summary>/// 构造函数/// </summary>/// <param name="next">请求委托</param>public EnableRequestBufferingMiddleware(RequestDelegate next){_next = next;}/// <summary>/// 处理请求/// </summary>/// <param name="context">HTTP上下文</param>/// <returns>Task</returns>public async Task Invoke(HttpContext context){// 启用请求缓冲,使请求体可以被多次读取context.Request.EnableBuffering();await _next(context);}}
}
在EnableRequestBufferingMiddleware
类中,我们实现了一个Invoke
方法,用于处理请求。在这个方法中,我们调用context.Request.EnableBuffering()
方法来启用请求缓冲,然后调用下一个中间件。
四、总结
到这里,我们已经完成了Redis、日志记录和异常处理的通用代码实现。我们创建了Redis服务、日志服务和异常处理中间件,并提供了相应的扩展方法来注册这些服务到依赖注入系统中。通过这些通用代码,我们可以在应用程序中方便地使用Redis、日志记录和异常处理功能,提高了代码的可维护性和可读性。在实际应用中,我们可以根据需要扩展这些服务的功能,例如添加更多的日志级别、支持不同的日志输出格式、支持更多的异常类型等。同时,我们还可以根据项目的需求,进一步优化这些服务的性能和可靠性。