22.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--单体转微服务--增加公共代码

在拆分服务之前,我们需要先提取一些公共代码。本篇将重点新增日志记录、异常处理以及Redis的通用代码。这些组件将被整合到一个共享类库中,便于在微服务架构中高效复用。

Tip:在后续的教程中我们会穿插多篇提取公共代码的文章,帮助大家更好地理解如何将单体应用拆分为微服务。

在创建通用代码前,我们需要创建通用代码类库。首先,我们需要在当前git库中新建一个基于单体应用的分支 Microservices,并切换到这个分支,后续的操作都在这个分支上进行。具体操作这里就不赘述了,不清楚的可以去翻阅我的另一个专栏《GIT版本控制》。接着,在解决方案下新建一个类库项目,命名为 SP.Common,这个类库将用于存放我们提取的公共代码。最后在 SP.Common 类库中创建三个文件夹,分别命名为 RedisExceptionHandlingLogger,用于存放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中,异步编程是一种重要的编程模型,它允许我们在等待某些操作完成时继续执行其他操作,从而提高应用程序的响应性和性能。使用asyncawait关键字可以轻松实现异步编程。

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状态码。我们还提供了两个构造函数,一个用于传入错误消息和状态码,另一个用于传入错误消息、内部异常和状态码。我们可以在应用程序中抛出这个异常类的实例,来表示应用程序中的错误,但是在项目中我们一般不会直接使用这个类,而是使用它的派生类,在当前代码中,已经定义了五个异常类,分别是BadRequestExceptionForbiddenExceptionNotFoundExceptionUnauthorizedExceptionValidationException,这些异常类都继承自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、日志记录和异常处理功能,提高了代码的可维护性和可读性。在实际应用中,我们可以根据需要扩展这些服务的功能,例如添加更多的日志级别、支持不同的日志输出格式、支持更多的异常类型等。同时,我们还可以根据项目的需求,进一步优化这些服务的性能和可靠性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

喵叔哟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值