vue +signalR+log4net 实时日志推送
系列
源码地址:https://github.com/QQ2287991080/SignalRServerAndVueClientDemo
效果
老规矩先看最后效果
步骤
1、配置 log4net 日志
实现日志推送,首先需要配置 log4net 日志,然后定义一个全局异常捕获器,用于捕获错误写入到日志文件。
先把 nuget 包安装一下。
然后需要配置 log4net 的 xml 信息,右键 web 项目“添加”->“新建项”
找到Web 配置文件 ->“命名”->"点击添加"
然后把 xml 配置放入到 config 文件中,配置如下:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <log4net> <appender name="DebugAppender" type="log4net.Appender.DebugAppender" > <layout type="log4net.Layout.PatternLayout"> <conversionPattern value="%date [%thread] %-5level %logger - %message%newline" /> </layout> </appender> <!--全局异常日志--> <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> <!--日志文件存放位置--> <file value="../../../logs/system.log" /> <!--是否追加到日志文件中--> <appendToFile value="true" /> <!--基于文件大小滚动设置--> <rollingStyle value="Composite" /> <!--是否指定了日志文件名称--> <staticLogFileName value="true" /> <!--根据日期生成日志文件--> <!--<datePattern value="yyyyMMdd'.log'" />--> <!--最多保留 10 个旧文件--> <maxSizeRollBackups value="10" /> <!--日志文件的大小--> <maximumFileSize value="1GB" /> <layout type="log4net.Layout.PatternLayout"> <!--日志模板,这个东西很重要后续读取日志文件的时候就是依据这个配置--> <conversionPattern value="%n 时间:%date{yyyy-MM-dd HH🇲🇲ss},%n 线程 Id:%thread,%n 日志级别:%-5level,%n 描述:%message|%newline"/> </layout> </appender> <root> <level value="All"/> <appender-ref ref="DebugAppender" /> <appender-ref ref="RollingFile" /> </root> </log4net> </configuration>
想要更多配置的可以前往官网:http://logging.apache.org/log4net/release/config-examples.html
如果对生成多个文件夹有兴趣的可以看我另外:Asp.Net Core Log4Net 配置分多个文件记录日志(不同日志级别)
接下来就需要在 Startup 中配置 log4net.
public Startup(IConfiguration configuration) { Configuration = configuration; Logger = LogManager.CreateRepository(Assembly.GetEntryAssembly(), typeof(log4net.Repository.Hierarchy.Hierarchy)); XmlConfigurator.Configure(Logger, new FileInfo("log4net.config")); // _logger = LogManager.GetLogger(Logger.Name, typeof(Startup)); }</span><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> ILoggerRepository Logger { <span style="color: rgba(0, 0, 255, 1)">get</span>; <span style="color: rgba(0, 0, 255, 1)">set</span>; }</pre>
按照我最开始说的,在配置好日志之后需要配置一个全局错误捕获器,直接上代码。
public class SysExceptionFilter : IAsyncExceptionFilter { readonly IHubContext<ChatHub> _hub; //使用 log4 ILog _log = LogManager.GetLogger(Startup.Logger.Name, typeof(SysExceptionFilter)); public SysExceptionFilter(IHubContext<ChatHub> hub) { _hub = hub; } public async Task OnExceptionAsync(ExceptionContext context) { //错误 var ex = context.Exception; //错误信息 string message = ex.Message; //请求方法的路由 string url = context.HttpContext?.Request.Path; //写入日志文件描述 注意这个地方尽量不要用中文冒号,否则读取日志文件的时候会造成信息确实,当然你可以定义自己的规则 string logMessage = $"错误信息 =>【{message}】,【请求地址 =>{url}】"; //写入日志 _log.Error(logMessage); //读取日志 var data = ReadHelper.Read(); //发送给客户端 await _hub.Clients.All.SendAsync("ReceiveLog", data); //返回一个正确的 200http 码,避免前端错误 context.Result = new JsonResult(new { ErrCode = 0, ErrMsg = message, Data = true });} }
代码中的读取日志会在第二节中讲到。
在 Startup 服务中注册这个过滤器。
public void ConfigureServices(IServiceCollection services) { ...... services.AddMvc(option => { //添加错误捕获 option.Filters.Add(typeof(SysExceptionFilter)); //option.EnableEndpointRouting = false; }); ...... }
按照我这个配置将会在程序目录生成一个 logs 文件夹,以及一个 system.log 文件。
2、读取日志文件
在配置日志文件中已经将日志配置了,再看看生成日志文件内容。
跟我在 log4net.config 中配置的是一样的。
<layout type="log4net.Layout.PatternLayout"> <!--日志模板,这个东西很重要后续读取日志文件的时候就是依据这个配置--> <conversionPattern value="%n 时间:%date{yyyy-MM-dd HH🇲🇲ss},%n 线程 Id:%thread,%n 日志级别:%-5level,%n 描述:%message|%newline"/> </layout>
然后需要读取日志文件的,把日志文件的内容转换成前端能够识别的数据。
public class ReadHelper { /// <summary> /// https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlockslim?view=netframework-4.8 /// 这里主要控制控制多个线程读取日志文件 /// </summary> static ReaderWriterLockSlim _slimLock = new ReaderWriterLockSlim();</span><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> List<SysExceptionData> Read(<span style="color: rgba(0, 0, 255, 1)">string</span> filePath=<span style="color: rgba(128, 0, 0, 1)">""</span><span style="color: rgba(0, 0, 0, 1)">) { </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">日志对象集合</span> List<SysExceptionData> datas = <span style="color: rgba(0, 0, 255, 1)">new</span> List<SysExceptionData><span style="color: rgba(0, 0, 0, 1)">(); filePath </span>= Directory.GetCurrentDirectory() + <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">\\logs\\system.log</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">; </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">判断日志文件是否存在</span> <span style="color: rgba(0, 0, 255, 1)">if</span> (!<span style="color: rgba(0, 0, 0, 1)">File.Exists(filePath)) { </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> datas; } _slimLock.EnterReadLock(); </span><span style="color: rgba(0, 0, 255, 1)">try</span><span style="color: rgba(0, 0, 0, 1)"> { </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">获取日志文件流</span> <span style="color: rgba(0, 0, 255, 1)">var</span> fs = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">读取内容</span> <span style="color: rgba(0, 0, 255, 1)">var</span> reader = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> StreamReader(fs); </span><span style="color: rgba(0, 0, 255, 1)">var</span> content =<span style="color: rgba(0, 0, 0, 1)"> reader.ReadToEnd(); reader.Close(); fs.Close(); </span><span style="color: rgba(0, 128, 0, 1)">/*</span><span style="color: rgba(0, 128, 0, 1)"> *处理内容,换行符替换掉,然后在log4net配置文件中在每一写入日志结尾的地方加上 | *这样做的好处是便于在读取日志文件的时候处理日志数据返回给客户端 *由于是在每一行结束的地方加上| 所有根据Split分割之后最后一个数据必然是空的 *所有Where去除一下。 </span><span style="color: rgba(0, 128, 0, 1)">*/</span> <span style="color: rgba(0, 0, 255, 1)">var</span> contentList = content.Replace(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">\r\n</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(128, 0, 0, 1)">""</span>).Split(<span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(128, 0, 0, 1)">|</span><span style="color: rgba(128, 0, 0, 1)">'</span>).Where(w => !<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">.IsNullOrEmpty(w)); </span><span style="color: rgba(0, 0, 255, 1)">foreach</span> (<span style="color: rgba(0, 0, 255, 1)">var</span> item <span style="color: rgba(0, 0, 255, 1)">in</span><span style="color: rgba(0, 0, 0, 1)"> contentList) { </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">根据逗号分割单个日志数据的内容</span> <span style="color: rgba(0, 0, 255, 1)">var</span> info = item.Split(<span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(128, 0, 0, 1)">,</span><span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(0, 0, 0, 1)">); </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">实例化日志对象</span> SysExceptionData data = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> SysExceptionData(); data.CreateTime </span>= Convert.ToDateTime(info[<span style="color: rgba(128, 0, 128, 1)">0</span>].Split(<span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(128, 0, 0, 1)">:</span><span style="color: rgba(128, 0, 0, 1)">'</span>)[<span style="color: rgba(128, 0, 128, 1)">1</span><span style="color: rgba(0, 0, 0, 1)">]); data.Level </span>= info[<span style="color: rgba(128, 0, 128, 1)">2</span>].Split(<span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(128, 0, 0, 1)">:</span><span style="color: rgba(128, 0, 0, 1)">'</span>)[<span style="color: rgba(128, 0, 128, 1)">1</span><span style="color: rgba(0, 0, 0, 1)">]; data.Summary </span>= info[<span style="color: rgba(128, 0, 128, 1)">3</span>].Split(<span style="color: rgba(128, 0, 0, 1)">'</span><span style="color: rgba(128, 0, 0, 1)">:</span><span style="color: rgba(128, 0, 0, 1)">'</span>)[<span style="color: rgba(128, 0, 128, 1)">1</span><span style="color: rgba(0, 0, 0, 1)">]; datas.Add(data); } } </span><span style="color: rgba(0, 0, 255, 1)">finally</span><span style="color: rgba(0, 0, 0, 1)"> { </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">退出</span>
_slimLock.ExitReadLock();
}
return datas.OrderByDescending(bo=>bo.CreateTime).ToList();
}
}
public class SysExceptionData
{
/// <summary>
/// 时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 日志级别
/// </summary>
public string Level { get; set; }
/// <summary>
/// 日志描述
/// </summary>
public string Summary { get; set; }
}
这里需要说一下的是为什么要用ReaderWriterLockSlim,其实在写这篇博客之前我刚好看书学到这个东西。
来一段原文描述:
通常一个类型实例的并发读操作是线程安全的,而并发更新操作则不是。诸如文件这样的资源也具有相同的特点。
虽然可以简单的使用一个排它锁来保护对实例的任何形式的访问。
但是如果其读操作很多但是更新操作很少,则使用单一的锁限制并发性就不大合理了。
这种情况出现在业务应用服务器上,它会将常用的数据缓存在静态字段中进行快速检索。
ReaderWriterLockSlim 是专门为这种情形设计的,它可以最大限度的保证锁的可用性。ReaderWriterLockSlim 在.net3.5 引入的它替代了笨重的 ReaderWriterLock 类。虽然两者功能相识,但是后者的执行速度比前置慢数倍。ReaderWriteLockSlim 和 ReaderWriterLock 都拥有两种基本锁,读和写。
写锁是全局排它锁
读锁可以兼容其他的锁
因此,一个持有写锁的线程将阻塞其他任何试图获取读锁或写锁的京城。但是如果没有任何线程持有写锁的话,那么任意数量的线程都可以获得读锁。
ReaderWriterLockSlim 和 lock 一样也有类似 TryEnter 之类的方法,来判断是否超时,如果超时就抛出错误(lock 返回 false)
这是关于 ReaderWriterLockSlim 官网最新的描述:https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlockslim?view=netframework-4.8
对了,我看的是孔雀鸟 --《c# 7.0 核心技术指南》c# 想进阶强烈推荐这本书。
同时这部分代码也有参考老张的Blog.Core的源码,感谢!
接下来调试一下看看读取日志文件处理后的数据,我在 TestController 加了故意抛出错误的接口。
直接在浏览器输入 :http://localhost:13989/api/test/getLog
成功进入断点
shift+f9 监听 data 看看数据
拿到这个数据,在客户端就直接可以用来展示,那么读取日志文件这部分就说完了,然后再说如何发送日志给客户端。
3、实时发送日志数据
在日志过滤器中有这样一段代码,玩过 signalr 的人都知道 SendAsync 的第一个字符串其实是集线器中方法(Hub)的名称, 但是我们也是可以自定义它的名称的。
//发送给客户端 await _hub.Clients.All.SendAsync("ReceiveLog", data);
signalr 强类型中心:https://docs.microsoft.com/zh-cn/aspnet/core/signalr/hubs?view=aspnetcore-3.1#change-the-name-of-a-hub-method
之前用的 Hub 不是强类型中心,这次一并给他改造了。
/// <summary> /// https://docs.microsoft.com/zh-cn/aspnet/core/signalr/hubs?view=aspnetcore-3.1 /// 强类型中心 /// </summary> public interface IChatClient { Task ReceiveMessage(string user, string message); Task ReceiveMessage(object message); Task ReceiveCaller(object message); Task ReceiveLog(object data);}
重构源码之前的方法。
public class ChatHub : Hub<IChatClient> { /// <summary> /// 给所有客户端发送消息 /// </summary> /// <param name="user">用户</param> /// <param name="message">消息</param> /// <returns></returns> public async Task SendMessage(string user, string message) { await Clients.All.ReceiveMessage(user, message); } /// <summary> /// 向调用客户端发送消息 /// </summary> /// <param name="message"></param> /// <returns></returns> public async Task SendMessageCaller(string message) { await Clients.Caller.ReceiveCaller(message); }</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><summary></span> <span style="color: rgba(128, 128, 128, 1)">///</span><span style="color: rgba(0, 128, 0, 1)"> 客户端连接服务端 </span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"></summary></span> <span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><returns></returns></span> <span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">override</span><span style="color: rgba(0, 0, 0, 1)"> Task OnConnectedAsync() { </span><span style="color: rgba(0, 0, 255, 1)">var</span> id =<span style="color: rgba(0, 0, 0, 1)"> Context.ConnectionId; </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">_logger.Info($"客户端ConnectionId=>【{id}】已连接服务器!");</span> <span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">base</span><span style="color: rgba(0, 0, 0, 1)">.OnConnectedAsync(); } </span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><summary></span> <span style="color: rgba(128, 128, 128, 1)">///</span><span style="color: rgba(0, 128, 0, 1)"> 客户端断开连接 </span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"></summary></span> <span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><param name="exception"></param></span> <span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><returns></returns></span> <span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">override</span><span style="color: rgba(0, 0, 0, 1)"> Task OnDisconnectedAsync(Exception exception) { </span><span style="color: rgba(0, 0, 255, 1)">var</span> id =<span style="color: rgba(0, 0, 0, 1)"> Context.ConnectionId; </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">_logger.Info($"客户端ConnectionId=>【{id}】已断开服务器连接!");</span> <span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">base</span><span style="color: rgba(0, 0, 0, 1)">.OnDisconnectedAsync(exception); } </span><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">async</span> Task ReceiveLog(<span style="color: rgba(0, 0, 255, 1)">object</span><span style="color: rgba(0, 0, 0, 1)"> data) { data </span>=<span style="color: rgba(0, 0, 0, 1)"> ReadHelper.Read(); </span><span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> Clients.All.ReceiveLog(data); } }</span></pre>
ps:这个改动不会影响它在控制器注入,或者其它注入地方的使用。
其实服务端的配置差不多好了,现在需要想的是在客户端,首次进入页面的时候是应该手动给他调用一次发送日志,否则进入页面是没有数据的。
然后我在 TestController 中加上一个接口手动触发
[HttpGet] public async Task<JsonResult> GetLogMessage() { var data = ReadHelper.Read(); await _hubContext.Clients.All.SendAsync("ReceiveLog", data); return new JsonResult(0);}
🆗,接下来需要把注意力集中到客户端上了,
之前的两篇博客我是没有安装 element-ui 的,这一次我为了展示数据省事,就打算直接用 element-table 展示数据好了。
element 官网:https://element.eleme.cn/#/zh-CN/component/installation
npm i element-ui -S
在 mian.js 添加配置
//element import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css'
vue 这里我不敢乱讲,这个我也不是很会,所以直接放代码了,我把客户端直接的代码进行了一下改造,加了个菜单,然后之前的内容都放在不同的菜单。
<template> <div class="home"> <h1> 服务端错误日志返回 </h1> <button @click="sendErr"> 执行一个错误 </button> <div class="table"> <el-table :data="tableData" border style="width: 100%"> <el-table-column type="index" label="序号" width="100"></el-table-column> <el-table-column prop="createTime" label="日期" width="180"></el-table-column> <el-table-column prop="level" label="级别" width="100"></el-table-column> <el-table-column prop="summary" label="描述" width="300"></el-table-column> </el-table> </div> </div> </template><script>
// @ is an alias to /src
import HelloWorld from "@/components/HelloWorld.vue";
import * as signalR from "@aspnet/signalr";
export default {
name: "Home",
components: {
HelloWorld,
},
data() {
return {
message: "", //消息
connection: "", //signalr 连接
messages: [], //返回消息
tableData: [],
};
},
methods: {
//发出一个错误
sendErr: function () {
this.$http.get("http://localhost:13989/api/test/getLog").then((resp) => {
//console.log(resp);
});
},
//获取系统日志
getLog: function () {
this.$http
.get("http://localhost:13989/api/test/GetLogMessage")
.then((res) => {
console.log(res);
});
},
getdatalist: function () {
this.$http
.get("http://localhost:13989/api/test/GetLogMessage")
.then((res) => {
// console.log(res);
//this.tableData = res.data;
})
.catch((err) => {
console.log(err);
});
},
},
computed: {},
mounted: function () {
let thisVue = this;
this.connection = new signalR.HubConnectionBuilder()
.withUrl("http://localhost:13989/chathub", {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
})
.configureLogging(signalR.LogLevel.Information)
.build();</span><span style="color: rgba(0, 0, 255, 1)">this</span><span style="color: rgba(0, 0, 0, 1)">.connection.start(); </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">连接日志发送事件</span> <span style="color: rgba(0, 0, 255, 1)">this</span>.connection.on(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">ReceiveLog</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">, function (message) { console.log(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">listening receivelog</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">); thisVue.tableData </span>=<span style="color: rgba(0, 0, 0, 1)"> message; }); </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">初始化表格数据</span>
thisVue.getdatalist();
},
};
</script>
<style scoped>
.table {
margin: 20px;
}
</style>
启动看看效果。
这是日志接口展示的客户端页面
之前博客的内容在聊天中。。
来个 gif 看看效果
结语
今天的分享到这里就结束了,内心觉得写一篇博客真不容易,从这个想法的萌芽到写 demo 去实现大概花了一周,不断地去看资料,研究源码。
俗话说,人不逼自己一下,不知道有多少潜力。
最后希望博客能够帮助到需要的人,后续还想研究下 signalr 配置 jwt,redis,sqlserver 等。
Dome 源码地址:https://github.com/QQ2287991080/SignalRServerAndVueClientDemo
学习使我快乐!!!