vue +signalR+log4net 實時日誌推送

飛天豬皮怪發表於2020-09-24

系列

原始碼地址:https://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>

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

        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>
        /// https://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官網最新的描述: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看看資料

 拿到這個資料,在客戶端就直接可以用來展示,那麼讀取日誌檔案這部分就說完了,然後再說如何傳送日誌給客戶端。

實時傳送日誌資料

 在日誌過濾器中有這樣一段程式碼,玩過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);
        }

        /// <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官網: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();

    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原始碼地址:https://github.com/QQ2287991080/SignalRServerAndVueClientDemo

學習使我快樂!!!

 

 

相關文章