Log2Net组件代码详解(附开源代码)
上一篇,我们介绍了Log2Net的需求和整体框架,我们接下来介绍我们是如何用代码实现Log2Net组件的功能的。
一、整体介绍
Log2Net组件本身是一个Dll,供其他系统调用。
本部分由以下几部分组成:
- 日志平台实体定义;
- 工具方法定义,包括ComUtil(例如缓存帮助类、序列化帮助类、消息队列帮助类等)和DBUtil(例如Sql server帮助类、Oracle帮助类、MySql帮助类、EF帮助类等);
- 日志信息获取类(例如如获取客户端、服务器端信息,写日志数据到消息队列等);
- .NetCore中间件定义(例如HttpContext中间件、错误消息处理中间件等);
- Config配置类(包括Log2NetConfigurationSectionHandler类、消息队列管理类等);
- 日志追加器类(FileAppender、DirectDBAppender、Queue2DBAppender、MQ2DBAppender等);
- 外部接口LogApi类(例如组件注册类、写日志类等);
使用的第三方类库有RabbitMQ访问类库RabbitMQ.Client、InfluxDB访问类库InfluxData.Net、缓存组件CacheManager、对象映射组件AutoMapper、缓存工具Microsoft.Extensions.Caching、Microsoft.AspNetCore.Session等。使用NuGet工具下载安装这些类库,会自动检测和匹配当前.NET版本并安装其他依赖。请尽量不要手动下载类库安装,可能会出现各种各样的不兼容、缺少依赖库的情况。
本组件使用VS2017开发,为类库项目,支持.net4.5~netCore2.2(此项目初始使用VS2017开发,于2019-10为支持.NetCore3.0改用了VS2019。若您未安装VS2019,将依赖项中的.NetCoreApp 3.0(或项目文件中的netcoreapp3.0)移除即可使用VS2017打开)。若您把源码下载下来,而您的电脑上缺少某个.Net版本,请在csproj文件中的TargetFrameworks中移除该net版本。
为了测试该组件,分别添加了一个.NET4.5的MVC项目和.netCore2.0的MVC项目。项目文件图如下图所示:
本项目代码已开源,地址为 https://github.com/yuchen1030/Log2Net ,您可以参照代码理解下述的设计。
二、模型实体Models类库
模型实体包括定义在ModelsInDB.cs中数据库中使用的模型、定义在ModelsUI.cs中外部接口使用的模型、定义在ModelsInCode.cs中本类库代码中使用的模型。
系统中的操作轨迹数据的数据库实体为Log_OperateTrace,监控数据数据库实体为Log_SystemMonitor。代码中以这两个实体为核心定义了其他数据实体,具体参见代码。
三、工具方法Util定义
本部分包括公共工具ComUtil类和DBUtil类。
3.1 ComUtil类
该类库(Util类库)中,封装了了一些公共的方法和类,如下表所示:
文件名 |
用途描述 |
AppConfig |
配置文件读写类 |
AutoMapperHelper |
对象映射帮助类 |
CacheHelper.cs |
缓存操作类 |
DtModelConvert.cs |
泛型Model和DataTable互转操作类 |
LambdaToSqlHelper |
Lambda表达式转Sql帮助类 |
RabbitMQHelper.cs |
RabbitMQ消息队列帮助类 |
SerializerHelper.cs |
序列化反序列化帮助类 |
StringEnum.cs |
字符串枚举类 |
XmlSerializeHelper.cs |
Xml和实体转换类 |
这些类是通用的方法封装,与具体业务逻辑无关,其他系统可以借鉴使用。
3.2 DBUtil类
这些类是用来访问各种数据库的方法的封装。包括对Sql Server、Oracle、MySql、InfluxDB等4种数据库的访问。若您需要添加对其他数据(如Access、SQLite、PostgreSQL等)的支持,请在此部分下添加。
对常用的数据库,本代码中使用了两种方式进行访问:ADO.net方式和EF方式,如果您需要使用NHibernate/SqlSugar/Dapper等其他方式,也请在该部分下添加。
3.2.1 AdoNet方式访问数据库
该部分是使用ADO.Net方法直接访问数据库,因为要支持SqlServer,Oracle,MySql等多种数据库,支持多个数据库实体,它们需要遵循相同的接口契约,有一些共同的实现方法,因此定义了泛型接口类和泛型基础类。类图如下所示:
在泛型接口IAdoNetBase中,定义了添加和获取数据的方法,如下所示:
1 internal interface IAdoNetBase<T> where T : class 2 { 3 ExeResEdm Add(string tableName, T model, params string[] skipCols); 4 ExeResEdm GetListByPage(string tableName, PageSerach<T> para); 5 }
数据库访问基础类AdoNetBase为抽象类,定义了各种数据库共用的一些基础方法,如下图所示:
在实现这些公共方法的时候,各种数据库的实现方法不同,因此需要定义抽象方法,子类需要实现它。
例如接口的public ExeResEdm Add(string tableName, T model, params string[] skipCols)方法需要调用私有方法ExecuteNonQuery,而该私有方法的定义如下:
1 ExeResEdm ExecuteNonQuery(string cmdText, params DbParameter[] parameters) 2 { 3 ExeResEdm dBResEdm = SqlCMD(cmdText, cmd => cmd.ExecuteNonQuery(), parameters); 4 if (dBResEdm.ErrCode == 0) 5 { 6 dBResEdm.ExeNum = Convert.ToInt32(dBResEdm.ExeModel); 7 } 8 return dBResEdm; 9 }
该ExecuteNonQuery方法中要调用SqlCMD方法,而各种数据库中SqlCMD方法方法实现不同,因此需要SqlCMD方法为抽象方法,各子类需要各自实现之。以下分别列出SqlServer和MySql中SqlCMD方法的实现:
1 protected override ExeResEdm SqlCMD(string sql, Func<DbCommand, object> fun, params DbParameter[] pms) 2 { 3 ExeResEdm dBResEdm = new ExeResEdm(); 4 try 5 { 6 pms = ParameterPrepare(pms); 7 using (SqlConnection con = new SqlConnection(connstr)) 8 { 9 using (SqlCommand cmd = new SqlCommand(sql, con)) 10 { 11 con.Open(); 12 if (pms != null && pms.Length > 0) 13 { 14 cmd.Parameters.AddRange((pms)); 15 } 16 var res = fun(cmd); 17 dBResEdm.ExeModel = res; 18 return dBResEdm; 19 } 20 } 21 } 22 catch (Exception ex) 23 { 24 dBResEdm.Module = "SqlCMD方法"; 25 dBResEdm.ExBody = ex; 26 dBResEdm.ErrCode = 1; 27 return dBResEdm; 28 } 29 }
1 protected override ExeResEdm SqlCMD(string sql, Func<DbCommand, object> fun, params DbParameter[] pms) 2 { 3 ExeResEdm dBResEdm = new ExeResEdm(); 4 try 5 { 6 pms = ParameterPrepare(pms); 7 using (MySqlConnection con = new MySqlConnection(connstr)) 8 { 9 using (MySqlCommand cmd = new MySqlCommand(sql, con)) 10 { 11 con.Open(); 12 if (pms != null && pms.Length > 0) 13 { 14 cmd.Parameters.AddRange((pms)); 15 } 16 var res = fun(cmd); 17 dBResEdm.ExeModel = res; 18 return dBResEdm; 19 } 20 } 21 } 22 catch (Exception ex) 23 { 24 dBResEdm.Module = "SqlCMD方法"; 25 dBResEdm.ExBody = ex; 26 dBResEdm.ErrCode = 1; 27 return dBResEdm; 28 } 29 }
基础类中的其他方法也是类似的套路,在此不再赘述,具体请参见源码。
在定义了接口和基础方法之后,各个子类就可以在此基础上继承和实现它们了,本代码中的子类是SqlServerHelper,OracleHelperBase,MySqlHelper三个,分别实现对SqlServer,oracle,MySql数据库的访问。这些子类中的方法就是对基类方法的重写,例如SqlServerHelper定义如下:
对oracle数据库,建议使用Oracle.ManagedDataAccess.Client实现的oracle 数据库访问类OracleHelper(无需安装客户端),无32位/64位之分,使用方便,性能好。但该类库仅支持Oracle10g及以上,因此又使用System.Data.OracleClient实现的oracle 数据库访问类OracleHelperMS。这两个类的代码可以是一模一样的,只是引用的类库不同(Oracle.ManagedDataAccess.Client和System.Data.OracleClient)。这两个类可以合并为一个,只需要添加如下代码:
1 //#define MS_OracleClient // 是采用微软oracle类库还是oracle自家的类库 2 3 #if MS_OracleClient 4 using System.Data.OracleClient; 5 #else 6 using Oracle.ManagedDataAccess.Client; 7 #endif
若您还需要支持其他数据库类型,请继承和实现 AdoNetBase<T>, IAdoNetBase<T> 即可。
3.2.2 数据访问层Dal
上面我们定义了ADO.Net访问数据的方法,EF方法只需引用类库即可。工具已备好,我们接下来就可以使用ADO.Net方法或EF方法访问具体的数据库表了。类图如下图所示:
首先,我们定义一个泛型抽象类DBAccessDal(也可以定义为接口),里面定义了需要实现的获取数据方法和添加数据的方法:
1 internal abstract class DBAccessDal<T> where T : class 2 { 3 internal abstract ExeResEdm GetAll(PageSerach<T> para); 4 5 internal abstract ExeResEdm Add(AddDBPara<T> dBPara); 6 7 }
然后,分别定义ADO.Net方法访问数据的基类AdoNetBaseDal和EF方法访问数据库的基类EFBaseDal:
最后,根据上一步中的基类,实现Log_OperateTrace和Log_SystemMonitor的数据访问子类,如下图:
ADO.Net方式中,基类中已指明了数据库连接对象,Dal中只需要调用相关方法即可。EF方式中,前文写的代码很少,但欠债总是要还的,这里需要额外定义继承自DbContext的Log_OperateTraceContext和Log_SystemMonitorContext来指定数据库上下文。
3.2.3 数据库访问方式工厂
上文中,介绍了数据库又ADO.Net方式和EF访问方式,我们可以在配置文件中配置使用ADO.Net方式或EF方式,这是通过工厂模式实现的,类图如下:
例如Log_OperateTraceDBAccessFac定义如下:
1 internal class Log_OperateTraceDBAccessFac : DBAccessFac<Log_OperateTrace> 2 { 3 protected override DBAccessDal<Log_OperateTrace> GetDalByDBAccessType(DBAccessType dbAccessType) 4 { 5 if (dbAccessType == DBAccessType.EF) 6 { 7 Log_OperateTraceEFDal log_OperateTraceDal = new Log_OperateTraceEFDal(new Log_OperateTraceContext()); 8 return log_OperateTraceDal; 9 } 10 else if (dbAccessType == DBAccessType.NH) 11 { 12 throw new Exception("Not define dal methods when DBAccessType = NH"); 13 } 14 else 15 { 16 return new Log_OperateTraceAdoDal(); 17 } 18 19 } 20 }
另外,还有数据库功能公共类ComDBFun和InfluxDB访问类InfluxDBHelper的介绍略。
至此,数据库访问帮助类介绍完毕,详情请参阅DBUtil部分代码。
四、日志信息获取类
本部分定义了日志组件使用的基础方法,如客户端服务器信息ClientServerInfo类、在线人数访客人数统计VisitOnlineCount 类、日志组件公共类LogCom.cs。
4.1 ClientServerInfo类
该类库用于收集客户端和服务器端的信息,包括客户端信息子类ClientInfo和服务器端信息子类ServerInfo。
ClientInfo类用来获取客户端的ip地址、主机名、Mac地址、浏览器信息等。
ServerInfo类用来获取服务器端的ip地址、主机名、操作系统、CLR版本、服务器运行时间、可用硬盘空间、CPU使用率、内存使用率等信息。
4.2 访客人数统计类VisitOnlineCount类
本类中定义了在线人数和访客统计抽象类IVisitCount类,具体的类要实现该类中的抽象方法。
对.net平台,存在Session_Start和Session_End事件,访客统计的实现思路较为清晰,本组件提供了两种方案:使用Application对象实现、使用缓存实现。具体采用哪种方案由简单工厂决定,默认采用缓存方案。
对.NetCore平台,不存在Session_Start和Session_End事件事件,需要借助于HttpContext中间件来实现。在HttpContext中,保存了所有的SessionID,若Session过期,则视为该SessionID离线。据此就可以统计出在线人数和历史访客。
4.3 公共类LogCom类
本类中定义一些本组件内部使用的公共类,主要是写文件的类、日志实体封装类,实现非常简单,类图如下:
五、日志追加器Appender类库
日志追加器用于将封装后的日志实体写到媒介中。根据追加方式的不同,实现方案也不同。
日志追加方式有写到文件、ADO方式写到数据库、通过队列写到数据库、通过消息队列写到数据库四种。相应的有FileAppender、DirectDBAppender、Queue2DBAppender、MQ2DBAppender四种追加器。这四种追加器都实现了公共的追加器BaseAppender类。类图如下:
公共追加器BaseAppender为抽象类中,定义了两个抽象的WriteLog方法,分别用来写用户操作日志和系统运行日志。
BaseAppender类中还定义了写日志的WriteLogAndHandFail方法和WriteLogAgain方法,两者的区别在于前者在失败时要写备份日志,参数为集合类型,在初次将日志写到媒介中使用;后者在失败时不进行其他处理,参数为单一实体,在读备份日志到媒介中使用。
FileAppender、DirectDBAppender、Queue2DBAppender、MQ2DBAppender这四种追加器实现自己的WriteLog方法。Queue2DBAppender是通过线程安全的ConcurrentQueue队列将数据写到数据,MQ2DBAppender是通过消息队列写数据到数据库,这两者都是通过一个缓冲Buffer写数据到数据库,继承自Buffer2DBAppender抽象类,需要实现各自的数据生产和数据消费的方法。而Buffer2DBAppender类由继承DirectDBAppender的写数据到数据库的方法,还开启了数据消费线程。Queue2DBAppender子类中,通过Enqueue 和TryDequeue方法即可实现数据的生产和消费,而MQ2DBAppender类中的数据生产消费任务较为复杂:需要调用RabbitMQManager的Send和Receive方法来生产和消费数据。
在写数据库中时,一方面写到SQL数据库中,便于读写分离的实现,另一方面写到时序数据库InfluxDB中,便于以后使用Grafana、ELK等工具进行更加灵活优雅的监控。
用户可以通过配置来决定使用哪一种追加器,代码中通过追加器工厂类AppenderFac,得到相应的追加器工厂实例,调用该追加器的方法进行日志的记录。
六、.NetCore中间件DNCMiddleware类库
日志追加器用于将封装后的日志实体写到媒介中。根据追加方式的不同,实现方案也不同。
.NetCore中没有Application_Error事件来捕捉全局异常,没有HttpContext.Current来保存当前请求的信息,需要我们自定义中间件来实现。
61 异常处理中间件ErrorHandlingMiddleware
在这里定义了异常处理中间件,在捕捉到异常时,将异常日志进行记录。
1 internal class ErrorHandlingMiddleware 2 { 3 private readonly RequestDelegate next; 4 5 public ErrorHandlingMiddleware(RequestDelegate next) 6 { 7 this.next = next; 8 } 9 10 public async Task Invoke(Microsoft.AspNetCore.Http.HttpContext context) 11 { 12 try 13 { 14 await next(context); 15 } 16 catch (Exception ex) 17 { 18 var statusCode = context.Response.StatusCode; 19 if (ex is ArgumentException) 20 { 21 statusCode = 200; 22 } 23 await HandleExceptionAsync(context, statusCode, ex.Message); 24 } 25 finally 26 { 27 var statusCode = context.Response.StatusCode; 28 var msg = ""; 29 if (statusCode == 401) 30 { 31 msg = "未授权"; 32 } 33 else if (statusCode == 404) 34 { 35 msg = "未找到服务"; 36 } 37 else if (statusCode == 502) 38 { 39 msg = "请求错误"; 40 } 41 else if (statusCode != 200 && statusCode != 302) 42 { 43 msg = "未知错误" + statusCode; 44 } 45 if (!string.IsNullOrWhiteSpace(msg)) 46 { 47 await HandleExceptionAsync(context, statusCode, msg); 48 } 49 } 50 } 51 52 private static Task HandleExceptionAsync(Microsoft.AspNetCore.Http.HttpContext context, int statusCode, string msg) 53 { 54 var data = new { code = statusCode.ToString(), is_success = false, msg = msg }; 55 var result = JsonConvert.SerializeObject(new { data = data }); 56 57 Log_OperateTraceBllEdm exLog = new Log_OperateTraceBllEdm() 58 { 59 Detail = result, 60 LogType = LogType.异常, 61 Remark = "异常时间" + DateTime.Now, 62 TabOrModu = "异常模块", 63 }; 64 LogApi.WriteLog( LogLevel.Error,exLog); 65 66 context.Response.ContentType = "application/json;charset=utf-8"; 67 return context.Response.WriteAsync(result); 68 } 69 } 70 71 72 public static class ErrorHandlingExtensions 73 { 74 public static IApplicationBuilder UseErrorHandling(this IApplicationBuilder builder) 75 { 76 return builder.UseMiddleware<ErrorHandlingMiddleware>(); 77 } 78 }
6.2 请求上下文中间件HttpContext
在这里,定义了请求上下文的中间件,记录了当前求请求的上下文,模拟了当前上下文的Session信息,将所有的SessionId保存起来,将过期的SessionId移除,来实现在线人数统计和历史访客统计。
1 internal static class HttpContext 2 { 3 public class SessionEdm 4 { 5 public string Key { get; set; } 6 public string Val { get; set; } 7 public DateTime ExpiresAtTime { get; set; } 8 } 9 10 11 public static Microsoft.AspNetCore.Http.HttpContext Current => _accessor.HttpContext; 12 13 static ConcurrentDictionary<string, SessionEdm> sessionMaps = new ConcurrentDictionary<string, SessionEdm>(); 14 15 static double dncSessionMins = AppConfig.GetDncSessionTimeoutMins(); 16 17 private static IHttpContextAccessor _accessor; 18 internal static void Configure(IHttpContextAccessor accessor) 19 { 20 _accessor = accessor; 21 } 22 23 public static VOEdm GetOnlineVisitNum(int preVisitNum) 24 { 25 if (_accessor.HttpContext != null) 26 { 27 var curSession = _accessor.HttpContext.Session; 28 SessionEdm sessionEdm = new SessionEdm() { Key = curSession.Id, Val = "1", ExpiresAtTime = DateTime.Now.AddMinutes(dncSessionMins) }; 29 sessionMaps.TryAdd(curSession.Id, sessionEdm); 30 } 31 int visitorsNum = sessionMaps.Count; 32 VOEdm vOEdm = new VOEdm() { VisitNum = preVisitNum + visitorsNum }; 33 //将过期session的值变为0,未过期的session的数量为在线人数 34 var keys = sessionMaps.Keys.ToArray(); 35 for (int i = 0; i < sessionMaps.Count; i++) 36 { 37 var cur = sessionMaps[keys[i]]; 38 if (cur.Val == "1" && cur.ExpiresAtTime <= DateTime.Now) //已过期 39 { 40 cur.Val = "0"; 41 } 42 } 43 var onlineNums = sessionMaps.Where(a => a.Value.Val == "1").Count(); 44 vOEdm.OnlineNum = onlineNums; 45 return vOEdm; 46 } 47 48 } 49 50 public static class StaticHttpContextExtensions 51 { 52 public static void AddHttpContextAccessor(this IServiceCollection services) 53 { 54 services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); 55 } 56 57 public static IApplicationBuilder UseStaticHttpContext(this IApplicationBuilder app) 58 { 59 var httpContextAccessor = app.ApplicationServices.GetRequiredService<IHttpContextAccessor>(); 60 HttpContext.Configure(httpContextAccessor); 61 return app; 62 } 63 }
七、外部接口类LogApi类
在本类中,调用其他类的方法,形成供其他业务系统调用的方法。包含以下内容:日志组件注册、写日志等。各业务系统不需要关心这些方法的具体实现,只需要封装业务实体,调用写日志的方法即可。LogApi类会调用其他的类,如等来实现日志记录的功能。类图如下:
- RegisterLogInitMsg:注册日志组件到本系统,为日志组件准备基础信息:服务器IP、服务器主机名,系统名称等;使用EF自动创建数据;并调用WriteServerStartupLog方法写启动日志,调用WriteMonitorLogThread方法写定时监控日志。
- GetLogWebApplicationsName:从配置文件中获取用户自定义的系统名称。
- 这里还包含网站生命周期事件中的日志记录,如下:
- WriteServerStartupLog:服务器启动时,获取操作系统,.NET CLR版本;
- WriteFirstVisitLog:网站被初次访问,记录记录IIS版本;
- WriteServerStopLog:服务器停止时,获取已运行时间;
- WriteServerStartupLog:系统异常时,记录异常日志;
- IncreaseOnlineVisitNum:Session Start时,在线人数和访客人数加1;
- ReduceOnlineNum:Session end时,在线人数减1。
以上的生命周期事件中,有些仅在.net中可以使用,.netCore中不存在,要实现类似的功能,就需要使用netCore中间件来实现。AddLog2netService和AddLog2netConfigure分别用来注册Log2net服务和Log2net配置。
本类中定义了4个写日志的方法:
- WriteLog方法重载(2个):封装日志实体,调用日志追加器的方法将日志写到媒介中,分别对业务操作和监控数据进行写。
- WriteMsgToDebugFile:写调试日志写到文件中,可通过bWriteInfoToDebugFile配置是否开启。
- WriteInfoToFile:将将日记写到本地文件中,记录一些重要但又不必写入log日志媒介的信息。
LogTraceEdm为操作轨迹类业务实体,LogMonitorEdm为监控信息实体,各业务系统将信息封装进这两个实体,然后调用WriteLog方法,就能将日志数据写到相应媒介中。若写日志出现异常,将则该消息以Json格式备份到本地.log文件中,并在以后自动将备份写到相应媒介中。
八、多平台的设计和实现
日志组件作为基础的组件,供不特定的系统使用,所以需要支持.net4.5/.net4.5.1/.net4.5.2/.net4.6/.net4.6.1/.net4.6.2.net4.7/.net4.7.1/.net4.7.2等平台,支持 .netCore2.0/.netCore2.1/.netCore2.2/.netCore3.0平台,其他的平台由于版本较旧,功能性能不太完善,使用较少,故不予支持。
实现多平台建议使用VS2017,将项目配置 .csporj 中的代码<TargetFramework>net45</TargetFramework> 改为 <TargetFrameworks>net45;net451;net452;net46;net461;net462;net47;net471;net472;netcoreapp2.0;netcoreapp2.1;netcoreapp2.2;netcoreapp3.0</TargetFrameworks> ,即可将单目标框架变为多目标框架。然后在项目配置中的ItemGroup 节点中添加 Condition条件,来指明这些引用所适用的框架平台,具体情况请参见项目配置.csporj 中文件。最后在项目代码中使用 #if #else #endif条件编译指明各个平台下适用的编码。
本组件中主要涉及生命周期事件的多平台实现、缓存的多平台实现、在线人数的多平台实现等。
对生命周期事件,.net平台中有 Application Started、Application Stop、Application Error、Session_Start、Session_End、Application_BeginRequest等事件,而在.netCore平台中仅有Application Started、Application Stop事件,其他事件需要通过Middleware中间件来实现。
对缓存,本系统使用http缓存和CacheManager缓存。http缓存中,分别使用HttpRuntime.Cache缓存和Microsoft.Extensions.Caching.Memory缓存;对CacheManager缓存,.net平台中支持内存缓存、Memcached缓存、redis缓存三种,.netCore平台中仅支持内存缓存、redis缓存两种。
对在线人数,.net平台中可以通过Application/缓存结合Session_Start、Session_End事件来实现,但在.netCore平台中,该实现较为麻烦,需要开启Session、自定义HttpContext中间件等,利用SessionId列表来标记历史访客,利用Session过期时间来移除过期的SessionId来标记某人的离线。