客户端软件中报警信息显示的实现探讨

发布时间 2023-03-31 09:10:38作者: Kelons

一.功能背景

很多情况下,软件需要将运行过程中产生的必要信息(日志或报警信息)实时输出,以便用户及时关注到系统健康状态,如下图。

二.实现方式探讨

在客户端软件中,一般有专门的窗口来显示报警信息,但报警信息的产生却可能发生在系统的各个地方,如UI层的不同窗口,业务层的方法。

1.第一种实现方式

经常见到的一种实现方式是使用事件机制,如下示例代码,在产生报警信息的窗口Form1、Form2...中定义事件:

public partial class Form1 : Form
{
    /// <summary>
    /// 定义产生日志的事件
    /// </summary>
    public event LogEventHandler LogGenerate;

    /// <summary>
    /// 实例对象,便于访问
    /// </summary>
    public static Form1 Instance = new Form1();

    public Form1()
    {
        InitializeComponent();
    }
}
public partial class Form2 : Form
{
    /// <summary>
    /// 定义产生日志的事件
    /// </summary>
    public event LogEventHandler LogGenerate;

    /// <summary>
    /// 实例对象,便于访问
    /// </summary>
    public static Form2 Instance = new Form2();

    public Form2()
    {
        InitializeComponent();
    }
}
...

然后在显示报警信息的窗口中,给这些事件注册统一的报警输出方法:

public partial class OutLogForm : UserControl
{
    public OutLogForm()
    {
        InitializeComponent();
        LogRegister();
    }

    /// <summary>
    /// 给Form1、Form2、Form3注册显示报警信息的方法
    /// </summary>
    private void LogRegister()
    {
        Form1.Instance.LogGenerate += ShowLog;
        Form2.Instance.LogGenerate += ShowLog;
        Form3.Instance.LogGenerate += ShowLog;
    }

    /// <summary>
    /// 显示报警信息
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void ShowLog(object sender, LogEventArgs e)
    {
        this.richTextBox1.AppendText($"{e.Log}{Environment.NewLine}");
    }
}

这样,在Form1、Form2中如果产生了报警信息,则直接触发事件LogGenerate,便能实现报警信息的显示。

上述实现方式还可以做进一步优化,比如给产生报警信息的类定义统一的接口,然后通过反射统一注册,还可以将事件定义为静态事件,这样就不用公开实例对象。

事件机制实现比较简单直接,但上面的代码存在一些设计上的问题:

显示报警信息的类需要知道所有会产生报警的类;
如果显示报警的类发生修改,或者是更换成其他实现,也会影响到报警事件的注册代码;
因为事件注册的原因,事件源Form1、Form2持有对窗口OutLogForm的引用,可能造成OutLogForm窗口资源无法释放的问题。
这里面其实就涉及到一些设计原则:迪米特法则(也叫最少知道原则)和开放封闭原则,即降低耦合、提高可扩展性。

2.第二种实现方式(推荐)

我们从降低耦合的角度重新去思考:报警信息的产生者无需知道报警信息最终会如何显示、由谁显示,它仅仅需要将报警信息抛出来(上面的事件机制基本已经达到这个目的了,就是麻烦了些);报警信息的显示者也无需知道报警信息由谁产生,它要做的只是提供一个接口,用来显示信息而已。

按照这个思想,我们就不能让报警显示类去依赖产生类;反过来,如果让产生类去依赖显示类,每次有报警产生时,主动调用显示类的方法,这样的话,依赖不仅没有消除,还被分散在各处,并且底层模块产生报警时,是无法直接访问上层显示类的,再者,如果后续显示类发生修改,那系统中各个产生类都有可能受到影响。分析到这一步,我们意识到---不能依赖具体的显示类,这就是设计原则中的依赖倒置原则。

依赖倒置,指的是依赖关系的反转,上层不依赖下层的具体实现,而是依赖下层的抽象,可以参考https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/architectural-principles#dependency-inversion帮助理解。依赖倒置原则指导我们面向接口编程,因为接口是相对独立且稳定的,不仅能避免与具体实现类间的耦合,对接口的不同实现也提高了功能的可扩展性。接口与实现的绑定,我们可以使用IOC容器去实现。这看起来是个不错的想法,我们下面进行代码实现,这里使用Autofac容器。

首先定义记录报警信息的接口ISystemLog,这个接口建议定义在基础服务层,这样UI和业务服务都能访问到

public interface ISystemLog
    {
        /// <summary>
        /// 记录报警信息
        /// </summary>
        /// <param name="log"></param>
        void Log(string log);
    }

在UI层具体显示报警信息的窗口中实现该接口

public partial class OutLogForm : UserControl, ISystemLog
{
    public static OutLogForm Instance = new OutLogForm();

    public OutLogForm()
    {
        InitializeComponent();
    }

    /// <summary>
    /// 显示报警信息,实现ISystemLog接口的方法
    /// </summary>
    /// <param name="log"></param>
    public void Log(string log)
    {
        // 考虑存在非UI线程产生的日志,这里使用UI线程
        this.Invoke(new Action(() =>
        {
            this.richTextBox1.AppendText($"{log}{Environment.NewLine}");
        }));
    }
}

在系统启动时,使用OutLogForm的实例来注册记录报警信息的服务ISystemLog

using Autofac;
using LogDemo.Service;
using System;
using System.Windows.Forms;

namespace LogDemo
{
    internal static class Program
    {
        /// <summary>
        /// 应用程序的主入口点。
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            ContainerRegister();// 容器注册
            Application.Run(new MainForm());
        }

        /// <summary>
        /// 容器注册
        /// </summary>
        private static void ContainerRegister()
        {
            ContainerBuilder builder = new ContainerBuilder();
            builder.RegisterInstance(OutLogForm.Instance).As<ISystemLog>();
            ContainerHelper.Container = builder.Build();
        }
    }
}

其中ContainerHelper类是为了方便访问容器服务而创建的帮助类:

public static class ContainerHelper
{
    public static IContainer Container { get; set; }

    public static T GetService<T>()
    {
        return (T)Container?.Resolve(typeof(T));
    }
}

至此,记录报警信息的容器服务已经有了。为了方便访问ISystemLog服务,我们使用SystemLogService类来做一个简单的封装。同ISystemLog接口一样,SystemLogService也建议定义在基础服务层,这样UI和业务服务都能直接调用:

public class SystemLogService
{
    /// <summary>
    /// 记录报警信息
    /// </summary>
    /// <param name="log"></param>
    public static void Log(string log)
    {
        ISystemLog systemLog = ContainerHelper.GetService<ISystemLog>();
        systemLog.Log(log);
    }
}

现在,我们准备在Form1、Form2...中记录报警信息,之前声明的事件都可以删掉了,直接调用SystemLogService中的Log方法即可。

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private void button1_Click(object sender, System.EventArgs e)
    {
        SystemLogService.Log("产生报警");
    }
}

这样我们便利用容器实现了完整的报警信息的产生与显示。

我们来看下这种实现方式的优点:

低耦合:具体的报警信息类OutLogForm不再与产生报警的类有耦合,产生报警的类也不需要关心报警信息输出到哪里了;
可扩展性好:如果再增加产生报警的类,直接调用服务即可,显示报警的类不需要做任何修改;如果后面哪天需要换个窗口显示报警信息,或者换成了文件记录,只需要注册新的服务即可,这也是开放封闭原则中“对修改关闭,对扩展开放”的体现。
举例:如果后面需求变更,要将报警信息输出到文本文件中,只需要新增一个写文件的类OutLogToFile,实现ISystemLog接口,然后在容器注册的地方,用这个写文件的类来注册ISystemLog服务即可。代码如下:

public class OutLogToFile : ISystemLog
{
    private static string logFile = "log.txt";
    private static readonly object logLock = new object();

    static OutLogToFile()
    {
        File.Create(logFile).Dispose();
    }
    /// <summary>
    /// 显示报警信息,实现ISystemLog接口的方法
    /// </summary>
    /// <param name="log"></param>
    public void Log(string log)
    {
        lock (logLock)
        {
            File.AppendAllText(logFile, log + Environment.NewLine);
        }
    }
}
/// <summary>
/// 容器注册
/// </summary>
private static void ContainerRegister()
{
    ContainerBuilder builder = new ContainerBuilder();
    //builder.RegisterInstance(OutLogForm.Instance).As<ISystemLog>();
    // 换成用OutLogToFile来注册ISystemLog服务
    builder.RegisterType<OutLogToFile>().As<ISystemLog>();
    ContainerHelper.Container = builder.Build();
}

以上就是对系统报警信息输出的实现方式探讨。