vue +signalR+log4net 實時日誌推送

系列

源碼地址://github.com/QQ2287991080/SignalRServerAndVueClientDemo

效果

老規矩先看最後效果

步驟

配置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:mm: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>

想要更多配置的可以前往官網://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));
        }

        public static ILoggerRepository Logger { get; set; }

按照我最開始說的,在配置好日誌之後需要配置一個全局錯誤捕獲器,直接上程式碼。

 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文件。

讀取日誌文件

在配置日誌文件中已經將日誌配置了,再看看生成日誌文件內容。

 跟我在log4net.config中配置的是一樣的。

 <layout type="log4net.Layout.PatternLayout">
        <!--日誌模板,這個東西很重要後續讀取日誌文件的時候就是依據這個配置-->
        <conversionPattern value="%n時間:%date{yyyy-MM-dd HH:mm:ss},%n執行緒Id:%thread,%n日誌級別:%-5level,%n描述:%message|%newline"/>
      </layout>

然後需要讀取日誌文件的,把日誌文件的內容轉換成前端能夠識別的數據。

public class ReadHelper
    {
        /// <summary>
        /// //docs.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlockslim?view=netframework-4.8
        /// 這裡主要控制控制多個執行緒讀取日誌文件
        /// </summary>
        static ReaderWriterLockSlim _slimLock = new ReaderWriterLockSlim();

        public static List<SysExceptionData> Read(string filePath="")
        {
            //日誌對象集合
            List<SysExceptionData> datas = new List<SysExceptionData>();
            filePath = Directory.GetCurrentDirectory() + "\\logs\\system.log";
            //判斷日誌文件是否存在
            if (!File.Exists(filePath))
            {
                return datas;
            }
            _slimLock.EnterReadLock();
            try
            {

                //獲取日誌文件流
                var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
                //讀取內容
                var reader = new StreamReader(fs);
                var content = reader.ReadToEnd();
                reader.Close();
                fs.Close();
                /*
                 *處理內容,換行符替換掉,然後在log4net配置文件中在每一寫入日誌結尾的地方加上 |
                 *這樣做的好處是便於在讀取日誌文件的時候處理日誌數據返回給客戶端
                 *由於是在每一行結束的地方加上| 所有根據Split分割之後最後一個數據必然是空的
                 *所有Where去除一下。
                 */
                var contentList = content.Replace("\r\n", "").Split('|').Where(w => !string.IsNullOrEmpty(w));
                foreach (var item in contentList)
                {
                    //根據逗號分割單個日誌數據的內容
                    var info = item.Split(',');
                    //實例化日誌對象
                    SysExceptionData data = new SysExceptionData();
                    data.CreateTime = Convert.ToDateTime(info[0].Split('')[1]);
                    data.Level = info[2].Split('')[1];
                    data.Summary = info[3].Split('')[1];
                    datas.Add(data);
                }
            }
            finally
            {
                //退出
                _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官網最新的描述://docs.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlockslim?view=netframework-4.8

對了,我看的是孔雀鳥–《c# 7.0核心技術指南》c#想進階強烈推薦這本書。

同時這部分程式碼也有參考老張Blog.Core的源碼,感謝!


 

接下來調試一下看看讀取日誌文件處理後的數據,我在TestController加了故意拋出錯誤的介面。

 

 直接在瀏覽器輸入 ://localhost:13989/api/test/getLog

 成功進入斷點

 shift+f9監聽data看看數據

 拿到這個數據,在客戶端就直接可以用來展示,那麼讀取日誌文件這部分就說完了,然後再說如何發送日誌給客戶端。

實時發送日誌數據

 在日誌過濾器中有這樣一段程式碼,玩過signalr的人都知道SendAsync的第一個字元串其實是集線器中方法(Hub)的名稱,但是我們也是可以自定義它的名稱的。

//發送給客戶端
 await _hub.Clients.All.SendAsync("ReceiveLog", data);

signalr強類型中心://docs.microsoft.com/zh-cn/aspnet/core/signalr/hubs?view=aspnetcore-3.1#change-the-name-of-a-hub-method

之前用的Hub不是強類型中心,這次一併給他改造了。

    /// <summary>
    /// //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);
        }

        /// <summary>
        /// 客戶端連接服務端
        /// </summary>
        /// <returns></returns>
        public override Task OnConnectedAsync()
        {
            var id = Context.ConnectionId;
            //_logger.Info($"客戶端ConnectionId=>【{id}】已連接伺服器!");
            return base.OnConnectedAsync();
        }
        /// <summary>
        /// 客戶端斷開連接
        /// </summary>
        /// <param name="exception"></param>
        /// <returns></returns>
        public override Task OnDisconnectedAsync(Exception exception)
        {
            var id = Context.ConnectionId;
            //_logger.Info($"客戶端ConnectionId=>【{id}】已斷開伺服器連接!");
            return base.OnDisconnectedAsync(exception);
        }
        public async Task ReceiveLog(object data)
        {
            data = ReadHelper.Read();
            await Clients.All.ReceiveLog(data);
        }
    }

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官網://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("//localhost:13989/api/test/getLog").then((resp) => {
        //console.log(resp);
      });
    },
    //獲取系統日誌
    getLog: function () {
      this.$http
        .get("//localhost:13989/api/test/GetLogMessage")
        .then((res) => {
          console.log(res);
        });
    },
    getdatalist: function () {
      this.$http
        .get("//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("//localhost:13989/chathub", {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets,
      })
      .configureLogging(signalR.LogLevel.Information)
      .build();

    this.connection.start();
    //連接日誌發送事件

    this.connection.on("ReceiveLog", function (message) {
      console.log("listening receivelog");
      thisVue.tableData = message;
    });

    //初始化表格數據
    thisVue.getdatalist();
  },
};
</script>
<style scoped>
.table {
  margin: 20px;
}
</style>

啟動看看效果。

這是日誌介面展示的客戶端頁面

 之前部落格的內容在聊天中。。

 來個gif看看效果

結語

今天的分享到這裡就結束了,內心覺得寫一篇部落格真不容易,從這個想法的萌芽到寫demo去實現大概花了一周,不斷地去看資料,研究源碼。

俗話說,人不逼自己一下,不知道有多少潛力。

最後希望部落格能夠幫助到需要的人,後續還想研究下signalr 配置jwt,redis,sqlserver等。

Dome源碼地址://github.com/QQ2287991080/SignalRServerAndVueClientDemo

學習使我快樂!!!