在拆分服务之前,我们需要先提取一些公共代码。本篇将重点新增日志记录、异常处理以及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");
// 注册RedisOptions
services.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)
{
// 注册RedisOptions
services.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
};
// 配置Serilog
var 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();
// 添加Serilog
services.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 DEBUG
if (!(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
{
// 如果请求是可重置的,尝试读取body
if (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、日志记录和异常处理功能,提高了代码的可维护性和可读性。在实际应用中,我们可以根据需要扩展这些服务的功能,例如添加更多的日志级别、支持不同的日志输出格式、支持更多的异常类型等。同时,我们还可以根据项目的需求,进一步优化这些服务的性能和可靠性。