65.9K
CodeProject 正在变化。 阅读更多。
Home

基于服务的日志解决方案

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (8投票s)

2014 年 6 月 9 日

CPOL

31分钟阅读

viewsIcon

22360

downloadIcon

861

介绍一个集中式的两阶段 log4net 日志记录解决方案及其实时通知 API。

相关文章

目录

引言 ^

日志记录是许多软件系统的重要方面之一。例如,它可以用于

  • 监控和/或审计系统和/或用户行为。日志信息提供了系统执行流程的“扁平化”且对时间敏感的视图。只要记录了足够的信息,就可以对其进行事后分析(例如,使用数据挖掘技术)以揭示在设计时难以分析的系统很多方面。
  • 调试开发中系统的潜在问题。这在许多情况下是必需的,例如在调试分布式和/或多线程系统的并发、实时行为时,其中许多“进程”并行进行,使用调试器停止其中一个会破坏非常被调试功能所依赖的、对时间敏感的环境。
  • 软件剖析,尤其是分布式软件,依赖于仪表化(请参阅此处)。结构化的日志项和原生的面向对象关系数据图编程 API 提供了丰富的信息集和数据迭代环境,供剖析软件利用。
  • 在生产环境中,选择性地微调已发布系统的性能或诊断特定部分的潜在问题,无论是否有源代码,前提是该系统已正确仪表化。
  • 关键事件通知和处理。有许多种可以被视为关键的异常事件,例如某些关键的预失败警告或失败事件,各种机会事件,潜在的系统安全漏洞或违反事件等,系统可能需要使用日志系统来通知,以便可以分配更专业的代理来处理它们。这些事件应尽快处理,因此需要一个实时系统。

本文介绍了一个无损log4net附加程序的实现,该附加程序将完整的日志信息发送到一个集中的数据服务,其中来自分布式系统各个部分的结构化数据以相同的格式保存,而无需在第一阶段对这些数据的使用方式做出任何假设。第二阶段涉及客户端软件利用智能查询 API(请参阅此处)和数据服务的实时通知框架。

它介绍了一种日志数据服务的实时通知 API,通过该 API,监控客户端软件或代理可以获取感兴趣的数据更改的通知,并根据系统需求实时响应收到的事件。

背景 ^

log4net 是 .NET 应用程序可以采用的成熟日志框架之一。它拥有一个相当灵活的配置系统,可以使用它将日志记录器注入到系统的选定部分(此处是教程)。它也很容易扩展。本文利用了上述可扩展性来构建一个基于服务的关系日志数据系统。

log4net 生成的结构化日志项包含有关每个日志项生成时的执行环境的丰富信息。例如,它可以使用 .Net 诊断功能在进行日志记录的点提取完整的堆栈帧集。在各种调试或剖析场景中了解这些信息非常重要。

log4net 中包含的默认附加程序和大多数公开可用的自定义附加程序遵循一种传统的策略,即只输出所述数据的特定视图(或布局),大多数时候是面向文本的,许多重要的元信息在“面世”之前以一种或另一种方式被投影出去。投影后丢失的信息将无法用于事后日志记录或实时订阅分析。此外,稍后更改投影策略可能会导致早期数据在格式和信息内容上与后期数据不一致。这可能会给自动化分析或处理工具带来问题。

如今,Web 应用程序本质上是多用户、多线程和多进程的,而且很可能是分布式的。它们同时并行进行。最近的 .Net 框架的新异步编程模式使情况“更糟”,因为异步方法可能包含在由框架调度的线程池中的不同线程上执行的代码段,这些线程似乎是随机选择的。执行的任务由逻辑上连接的操作序列组成,形成一条执行路径。这些逻辑上连续的执行路径在以下内容中通常被称为“逻辑线程”。对于这类系统,集中的日志系统包含来自各种并行逻辑线程的交错实体。随着线程数量的增加,它们会很快变得过于复杂,无法使用传统的方法(例如,依赖我们的眼睛和大脑)进行识别和解缠,从而无法使用准确的、最可能是结构化的查询工具进行分析。

下图是一个仅为一个用户(“user-a”)在单个基于范围的并行下载会话中创建的文件日志的快照,该日志由一个功能强大的下载客户端软件为保存在数据服务中的大约 100 MB 的“文件”创建。下载是在 Web 应用程序中使用 .Net 异步编程框架处理的。这里的 [...] 中的数字是线程 ID,# 后面的数字是数据块的序列号。一个逻辑线程可以通过具有连续递增序列号的记录来标识。从图中可以看出,它们以看似随机的方式混合在一起。

图:物理线程和逻辑线程混合的交错日志记录。

例如,数据块的序列 #1,#2,#3 ... 由托管线程 [24],[13],[8] ... 处理,尽管所有这些(包括其他序列或逻辑线程)都在异步方法的循环中处理。

本解决方案有三个主要特点:

  1. 它将 log4net 框架获取的所有默认信息以及与多用户 Web 应用程序典型执行环境相关的其他元信息保存到关系数据源中,而无需对如何使用这些数据做出早期假设或判断。这样,日志信息就可以被各种方和各种目的使用,在此时可以根据数据进行特定的投影或布局;
  2. 关系数据由支持关系数据服务的通用用户界面支持,该界面可用于借助智能引导系统立即制定和执行任意复杂度的结构化查询。通用界面可能不足以满足更复杂的需求的演示和分析,例如迭代数据分析或数据挖掘。为此,用户可以基于数据服务的客户端 API(尤其是查询 API)创建自己的分析工具。有关通常如何完成此操作的介绍,请参阅此处
  3. 数据服务包含订阅端口,客户端代理可以订阅这些端口以实时处理日志事件。

实时监控系统有很多优点,尤其是对于那些被视为关键的事件。关键事件有多种类型。有设计时预期到的、易于理解的、由系统本身处理的事件,以及意外的、不太易于理解的或在系统构建时由系统本身处理起来过于复杂的事件。它们应该被丢弃,或者在一个设计更好的系统中,委托给一个可扩展的端点,例如日志系统,该系统可以或不可以有外部处理程序。

还有其他实时异常监控解决方案,如用于可视化监控系统未捕获异常的 ElmahR项目。但本解决方案更通用。它不仅可以监控未捕获的异常,还可以基于用户提供的筛选表达式监控任何感兴趣的日志数据流。此外,由于可编程 API,它更具可扩展性,使得监控代理能够自动对收到的事件做出反应。

数据架构 ^

根据其使用情况,日志信息可以保存到不同的数据存储中。一些日志信息可以保存到属于系统主数据库的数据集中,而另一些,例如开发期间的调试信息,可以保存到更临时的、可以在特定调试任务完成后丢弃的数据集中。此外,log4net 还允许将相同的日志项保存到多个存储中。

下面显示的内容仅包含本文的相关数据集。它们应该通过外键 `AppID` 属性附加到数据源的其他部分,该属性指向包含应用程序集合的数据集中的一个实体。为简单起见,本文包含的数据服务从 ASP.NET 会员数据源描述的此处此处的简单 ASP.NET 会员数据源扩展而来。选择这样做没有特别的原因,只是因为它可以通过不创建另一个独立的演示数据服务项目来节省我们的项目管理复杂性。

当前实现负责日志记录的数据架构部分由三个数据集组成,即 `EventLogs`、`EventLocations` 和 `EventStackFrames`。它们之间的依赖关系如图所示

图:新数据集附加到现有关系数据集。

在这里,`EventLogs` 数据集中的一个实体代表主日志项。它与 `EventLocations` 数据集中的相应实体是一对一的关系,该实体包含发生相应日志记录的代码(文件名)中的位置信息(行号)。`EventLocations` 数据集中的实体与 `EventStackFrames` 数据集的一个子集具有一对多的关系。所述实体子集代表导致日志项生成位置的代码的调用路径。这些信息在调试问题和/或调整系统性能方面有时非常重要。

以下是上述每个数据集的详细数据架构:

图:EventLogs 数据集的数据架构。

图:EventLocations 数据集的数据架构。

图:EventStackFrames 数据集的数据架构。

包含的日志数据服务是根据从上述定义派生的扩展数据架构生成的。

附加程序 ^

数据 ^

发现在 `log4net.Core` 命名空间中,原生的 log4net 日志实体类型 `LoggingEvent` 不包含足够的元信息来描述 Web 应用程序的执行流程。它被扩展以包含更多信息,这些信息被编码在类中

internal class LoggingEventWrap
{
    public LoggingEvent Evt;
    public string webUser;
    public string pageUrl;
    public string referUrl;
    public string requestId;
}

这里 `Evt` 是原生的 log4net 日志实体,其余的是与多用户 Web 应用程序相关的最小信息集。它们从一组为 log4net 全局注册的值提供程序中获取,如果可能的话。

string webUserName = GlobalContext.Properties["user"].ToString();
string pageUrl = GlobalContext.Properties["pageUrl"].ToString();
string referUrl = GlobalContext.Properties["referUrl"].ToString();
string requestId = GlobalContext.Properties["requestId"].ToString();

它被注册在 ASP.NET 应用程序的 `Global.asax.cs` 文件中。

protected void Application_Start()
{
    log4net.GlobalContext.Properties["user"] = new HttpContextUserNameProvider();
    log4net.GlobalContext.Properties["pageUrl"] = new HttpRequestUrlProvider();
    log4net.GlobalContext.Properties["referUrl"] = new HttpReferringUrlProvider();
    log4net.GlobalContext.Properties["requestId"] = new HttpRequestTraceIDProvider();
    ....
}

这些提供程序定义在项目文件 `HttpContextInfoProvider.cs` 中。例如:

public class HttpContextUserNameProvider
{
    public override string ToString()
    {
        HttpContext c = HttpContext.Current;
        if (c != null)
        {
            if (c.User != null && c.User.Identity.IsAuthenticated)
                return c.User.Identity.Name;
            else
                return c.Request != null && c.Request.AnonymousID != null ? 
                       c.Request.AnonymousID : "Request from Unknown Users";
        }
        else
        {
            if (Thread.CurrentPrincipal.Identity.IsAuthenticated)
                return Thread.CurrentPrincipal.Identity.Name;
            else
                return "Request from Unknown Users";
        }
    }
}

其目的是获取当前用户(已认证或未认证)的唯一 ID,以便日志记录来自不同用户的记录可以被识别和分离,以达到任何目的。在 `HttpContext.Current` 可用的情况下,它被用于提取与 Web 相关的信息。例如,当用户已认证时,它返回用户名;否则,如果 Web 应用程序被配置为启用未认证访问者的匿名标识,则返回访问者的 `AnonymousID` 以将其与其他访问者区分开。

注意:`AnonymousID` 对于 ASP.NET 应用程序不是自动启用的,您必须在 `Web.config` 文件中的 `` 下添加以下节点,即:

<anonymousIdentification 
         enabled="true" 
         cookieless="UseCookies" 
         cookieName=".ASPXANONYMOUS" 
         cookieTimeout="30" 
         cookiePath="/" 
         cookieRequireSSL="false" 
         cookieSlidingExpiration="true" 
         cookieProtection="All" 
/>

`HttpContext.Current` 在 ASP.NET 应用程序中并非总是可用。例如,如果请求由 Web API 或 SignalR 通道处理,则 `HttpContext.Current` 不可用。在这种情况下,当访问者已认证时,使用通用的 `Thread.CurrentPrincipal.Identity` 属性。然而,似乎没有一种机制可以在以下情况识别匿名用户:HttpContext.Current不可用

要发送到数据服务实体的实体图是从 `LoggingEventWrap` 类的实例构建的。

private static EventLog getEntity(LoggingEventWrap evtw)
{
    EventLog log = new EventLog();
    log.ID = Guid.NewGuid().ToString();
    log.AppAgent = evtw.Evt.UserName;
    log.AppDomain = evtw.Evt.Domain;
    log.AppID = App != null ? App.ID : null;
    log.EventLevel = evtw.Evt.Level.Name;
    if (evtw.Evt.ExceptionObject != null)
    {
        log.ExceptionInfo = excpetionToString(evtw.Evt.ExceptionObject);
        //it's important to turn this on for delay loaded properties
        log.IsExceptionInfoLoaded = true;
    }
    log.LoggerName = evtw.Evt.LoggerName;
    TracedLogMessage tmsg = null;
    if (evtw.Evt.MessageObject is TracedLogMessage)
    {
        tmsg = evtw.Evt.MessageObject as TracedLogMessage;
        log.Message_ = tmsg.Msg;
        log.CallTrackID = tmsg.ID;
    }
    else if (evtw.Evt.MessageObject is string)
        log.Message_ = evtw.Evt.MessageObject as string;
    else
        log.Message_ = evtw.Evt.RenderedMessage;
    log.ThreadName_ = evtw.Evt.ThreadName;
    log.ThreadPrincipal = evtw.Evt.Identity;
    log.TimeStamp_ = evtw.Evt.TimeStamp.Ticks;
    log.Username = evtw.webUser == null ? evtw.Evt.UserName : evtw.webUser;
    log.PageUrl = evtw.pageUrl;
    log.ReferringUrl = evtw.referUrl;
    if (tmsg == null)
        log.RequestID = evtw.requestId;
    if (evtw.Evt.Level >= Level.Debug && evtw.Evt.LocationInformation != null && 
                                                                   _recordStackFrames)
    {
        log.ChangedEventLocations = new EventLocation[] 
                                    { 
                                        getLocation(log.ID, evtw.Evt.LocationInformation) 
                                    };
    }
    return log;
}

private static EventLocation getLocation(string id, LocationInfo loc)
{
    EventLocation eloc = new EventLocation();
    eloc.EventID = id;
    eloc.ClassName_ = loc.ClassName;
    // 220 is the current FileName_ size.
    eloc.FileName_ = loc.FileName != null && loc.FileName.Length > 220 ? 
                         "..." + loc.FileName.Substring(loc.FileName.Length - 220 - 3) : 
                          loc.FileName;
    eloc.MethodName_ = loc.MethodName;
    eloc.LineNumber = loc.LineNumber;
    if (loc.StackFrames != null && loc.StackFrames.Length > 0)
    {
        List<EventStackFrame> frames = new List<EventStackFrame>();
        int frmId = 1;
        foreach (var frm in loc.StackFrames)
        {
            if (_maxStackFramesUp >= 0 &&frmId > _maxStackFramesUp)
                break;
            else if (_userStackFramesOnly && string.IsNullOrEmpty(frm.FileName))
                continue;
            EventStackFrame efrm = new EventStackFrame();
            efrm.EventID = id;
            efrm.ID = frmId++;
            efrm.ClassName_ = frm.ClassName;
            // 220 is the current FileName_ size.
            efrm.FileName_ = frm.FileName != null && frm.FileName.Length > 220 ? 
                                 "..." + frm.FileName.Substring(frm.FileName.Length - 220 - 3) : 
                                 frm.FileName;
            string callinfo = frm.Method.Name + "(";
            foreach (var p in frm.Method.Parameters)
                callinfo += p + ", ";
            callinfo = callinfo.TrimEnd(", ".ToCharArray()) + ")";
            efrm.MethodInfo = callinfo;
            frames.Add(efrm);
        }
        eloc.ChangedEventStackFrames = frames.ToArray();
    }
    return eloc;
}

正如其他一些相关文章中所述,为了使一组依赖于另一个实体的实体与后者实体一起更新,依赖项必须放置在

"Changed" + <dependent entity name> + "s"

所述实体的属性中。在本例中,因为 `EventLog` 实体有一组类型为 `EventLocation` 的依赖实体(只有一个,因为关系是一对一),所以 `EventLog` 实体必须有一个名为 `ChangedEventLocations` 的属性,其类型为 `EventLocation[]`。类型为 `EventLocation` 的实体,它们依赖于所述实体,应该被放置在 `ChangedEventLocations` 属性中,以便它们与所述实体一起在数据服务中更新。同样,类型为 `EventLocation` 的实体有一组类型为 `EventStackFrame` 的依赖实体,因此其相应的 `EventStackFrame` 类型实体集应该被放置在实体 的 `ChangedEventStackFrames` 属性中,以便它们与所述实体一起在数据服务中更新。上述两个方法用于构建一个实体树,客户端可以使用该树在一次调用数据服务时插入相应的数据图,该数据服务能够处理主键、外键约束和数据重复(由于树结构)。

操作 ^

log4net 附加程序的实现可以从 `IAppender` 接口或 `AppenderSkeleton` 类开始。它们都定义在 `log4net` 包的 `log4net.Appender` 命名空间下。我们选择后者以简化事情,因为 `AppenderSkeleton` 类已经处理了一些标准工作。只需实现重载的 `Append(LoggingEvent evt)` 方法,以及可选的 `Append(LoggingEvent[] evts)` 方法。

尽管如此简单,在处理远程数据服务时还有其他需要考虑的事项。其中之一是调用远程数据服务比执行本地 I/O 操作花费的时间更多。log4net 本身使用 `log4net.Appender` 命名空间下 `BufferingAppenderSkeleton` 类中实现的 `Append` 方法来处理此类附加程序。它在我们的应用程序场景中存在一些问题。因此,我们希望开发自己的机制。

因此,必须设计一种方法来隐藏,或者如果完全不可能,至少尽可能延迟这些影响,以便在大多数应用程序场景中它看起来几乎没有实际影响。其中一种场景是系统平均每单位时间记录的项目不多,不足以超过网络和数据服务的吞吐量,但可能出现临时的大量日志项爆发。在这种情况下,本附加程序可能看起来比许多本地日志附加程序更快,或至少与它们一样快。

为此,我们选择使用一种异步机制在一个生产者-消费者设置中进行数据服务更新。它的实现虽然很标准,但可能有点复杂。然而,.NET 框架已经提供了一个现成的解决方案。更具体地说,可以使用 `System.Collections.Concurrent` 命名空间中的有界 `BlockingCollection` 实例来保存日志项,当调用上述 `Append` 方法时,它充当`生产者`。

private static BlockingCollection<EventLog> EventQueue
{
    get
    {
        return _eventQueue ?? (_eventQueue = new BlockingCollection<EventLog>(_maxQueueLength));
    }
}
private static BlockingCollection<EventLog> _eventQueue = null;    

它保存了要发送到数据服务的 `EventLog` 类型实体图的集合。只要上述实例的容量未达到,将项添加到列表所需的时间非常少。

protected override void Append(LoggingEvent evt)
{
    SetupThread();
    string webUserName = GlobalContext.Properties["user"].ToString();
    string pageUrl = GlobalContext.Properties["pageUrl"].ToString();
    string referUrl = GlobalContext.Properties["referUrl"].ToString();
    string requestId = GlobalContext.Properties["requestId"].ToString();
    var evtw = new LoggingEventWrap { 
                       Evt = evt, 
                       webUser = webUserName, 
                       pageUrl = pageUrl, 
                       referUrl = referUrl, 
                       requestId = requestId 
                   };
    if (!_lossy)
        EventQueue.Add(getEntity(evtw));
    else
        EventQueue.TryAdd(getEntity(evtw));
}

方法 `getEntity` 用于构建要发送到数据服务的实体图,该图在上一节中已描述。它以不同的方式处理添加,如果仍有 `_maxQueueLength` 个项目等待发送到数据服务,这可能会在数据服务速度过快以至于无法处理时发生,具体取决于配置中指定的 `_lossy` 值。当 `_lossy` 为 `false` 时,该方法将在 `EventQueue.Add(getEntity(evtw))` 语句处被阻塞,直到一些项目被发送到数据服务,否则,新的日志项将被丢弃,该方法将立即返回。

同时,创建一个充当 `消费者` 的后台线程,在创建或完成更新最后一个日志项块后,在无限循环中进行数据服务更新。

private static void EventProcessThread()
{
    List<EventLog> block = new List<EventLog>();
    while (!stopProcessing)
    {
        EventLog e;
        while (!EventQueue.TryTake(out e, 300))
        {
            if (block.Count > 0)
                sendBlock(block);
            if (stopProcessing)
                break;
        }
        block.Add(e);
        if (block.Count >= _maxUpdateBlockSize)
            sendBlock(block);
    }
    _thread = null;
}    

它所做的是构建一个本地项目列表以供更新。

  • 当阻塞集合中有持续添加时,它将在 `sendBlock` 方法中调用数据服务,在累积 `_maxUpdateBlockSize` 个项目后发送日志项块。
  • 然而,当它不忙时,`EventQueue.TryTake(out e, 300)` 中的阻塞将在每 300 毫秒后过期。发生这种情况时,它将首先检查 `block` 变量中是否有剩余项,如果有,则先将它们发送到数据服务;然后它将检查 `stopProcessing` 信号是否已标记,如果已标记,则退出循环,否则继续等待项目。

`sendBlock` 方法是:

private static void sendBlock(List<EventLog> block)
{
    try
    {
        var svc = GetService();
        svc.AddOrUpdateEntities(ClientContext.CreateCopy(), 
                                              new EventLogSet(), 
                                              block.ToArray());
    }
    catch (Exception ex)
    {
        log4net.Util.LogLog.Warn(typeof(DataServiceAppender), 
                                          excpetionToString(ex));
    }
    finally
    {
        block.Clear();
    }
}

监控器 ^

实时监控是通过数据服务提供的推送通知机制和接口实现的。

数据服务支持两种类型的推送通知端点:

  1. SignalR 通知:它还没有服务器端过滤机制,所以所有日志事件都会推送到客户端,这会产生很多噪音且性能较低。它也不太可靠,因此不建议在此处用于我们的目的。

    这个可扩展的通道可以用于不太可靠的最终用户端可视化推送通知显示,例如在网页上。默认情况下未启用。您必须在数据服务的 `Web.config` 文件中的 `cud-subscriptions/clients` 节点下设置

    <publish name="EventLog" disabled="false" />

    。设置后,数据服务将在 `NotificationHub` 通道上广播更改通知。以下语句序列是设置客户端监控器所必需的。

    var hubConn = new HubConnection(url);
    hubProxy = hubConn.CreateHubProxy("NotificationHub");
    hubConn.Start().Wait();
    hubProxy.Invoke("JoinGroup", EntitySetType.EventLog.ToString()).Wait();
    hubProxy.On<dynamic>("entityChanged", (e) => {
        ... handle the event ...
    });
  2. WCF 回调:这种方式更可靠,主要用于特定安全边界内的服务器到服务器通知。它可以被精确地控制在客户端软件内部。因此,它更适合用作特定安全边界内机器到机器的推送通知方式。

    对于每种类型的数据源实例,都有一个用于订阅和接收数据更改通知的入口点。它们通过一个实例完成

    <数据源名称> + "DuplexServiceProxy"

    其中 `<数据源名称>` 是支持需要执行的日志记录的数据源的名称,在本例中是 `AspNetMember`。同时可能存在多个。

通知处理类 ^

通知或回调处理程序类必须实现日志数据服务 `Shared` 库中定义的 `IServiceNotificationCallback` 接口。

[CallbackBehavior(ConcurrencyMode = ConcurrencyMode.Multiple, UseSynchronizationContext = false)]
public class CallbackHandler : IServiceNotificationCallback
{
    ... other members of the class ....

    public void EntityChanged(EntitySetType SetType, int Status, string Entity)
    {
        if ((Status & (int)EntityOpStatus.Added) != 0)
        {
            switch (SetType)
            {
                case EntitySetType.EventLog:
                    {
                        var ser = new DataContractJsonSerializer(typeof(EventLog));
                        byte[] bf = Encoding.UTF8.GetBytes(Entity);
                        MemoryStream strm = new MemoryStream(bf);
                        strm.Position = 0;
                        var e = ser.ReadObject(strm) as EventLog;

                        ... handle the entity ...


                    }
                    break;
                // case for other data sets, if interested...
            }
        }
    }

    ... other members of the class ....

}

通知订阅 ^

要订阅数据服务中的数据更改,应在应用程序的某个地方执行以下语句,以 `AspNetMember` 数据源为例:

var _handler = new CallbackHandler();
var _notifier = new InstanceContext(_handler);

_notifier.Closing += ... channel closing event handler

... other _notifier related event handler ...

var svc = new AspNetMemberDuplexServiceProxy(_notifier);

这里 `CallbackHandler` 是上面定义的类。下一步应该构建服务端的过滤器表达式。设置服务端过滤器是过滤推送回事件的首选方式,因为它能显著提高性能。假设监控代理对 ERROR 或 FATAL 级别的日志事件感兴趣,那么应该构造以下过滤器表达式:

var qexpr = new QueryExpresion
{
    FilterTks = new List<QToken>()
};
qexpr.FilterTks.Add(new QToken
{
    TkName = "EventLevel == ERROR || EventLevel == FATAL"
});

如果开发人员不熟悉如何构造此类表达式,他/她可以阅读我们之前文章中的介绍(请参阅,例如,此处此处)。当然,不同的监控代理可能对日志事件的不同方面感兴趣。它们必须使用带有不同订阅身份(参见下文)的相应过滤器进行订阅。指定过滤器后,订阅通过以下方式实现:

var sub = new SetSubscription
{
    EntityType = EntitySetType.EventLog,
    EntityFilter = qexpr
};
svc.SubscribeToUpdates(cctx, OwnerID, SubscribeID, new SetSubscription[] { sub });

其中 `cctx` 是日志数据服务的 `CallContext` 类型的全局实例,`OwnerID` 是用于维护订阅的订阅所有者标识,`SubscribeID` 是客户端软件需要跟踪以管理其订阅的 ID。最后一个 `sub` 指定要监视的数据集类型以及相应的过滤器表达式。如果要监视多个数据集,只需以相同方式为每个数据集构造相应的项。这里我们只对 `EventLog` 数据集感兴趣。订阅只能由其所有者更改或取消订阅。每个订阅 ID 只能有一个订阅,因此为了避免与其他系统订阅者发生冲突或无意中更改所有者的订阅,推荐使用 GUID 值。如果订阅是全局的,特定于某个应用程序,那么可以使用,例如,在初始化系统期间成功 `SignInService` 后由日志数据服务返回的 `CallContext` 的全局实例的 `ClientID` 属性(请参阅此处)作为 `OwnerID` 和 `SubscribeID`。

当由于某种原因不再需要订阅时,建议取消订阅。以下语句可用于取消订阅:

var svc = new AspNetMemberDuplexServiceProxy();
svc.UnsubscribeToUpdates(cctx, OwnerID, SubscriberID);

如何使用 ^

设置日志记录服务 ^

您可以按照此处的说明设置演示日志数据服务。请注意,数据服务必须在 .NET 4.5.1 下运行。

源代码根目录下的 `Documents` 子目录包含一个Sandcastle项目,可用于为客户端(即 Web 应用程序端)编程编译文档。但在编译文档之前,请确保已完成以下操作:

  • 安装 Sandcastle(如果尚未安装)。
  • 编译 Web 应用程序的解决方案,以便生成 XML 文档文件。
  • 文档的默认输出路径是文档目录相对路径下的 `..\..\DataService\Documents\ClientAPI45\`。确保这是一个允许的输出目录。如果不是,请将其更改为适当的目录。
  • 如果上述输出目录与数据服务网站的 `Documents\ClientAPI45` 子目录不同,请删除后者中的所有内容(如果存在),然后将前者中生成的所有文档文件复制到后者。

重新编译 ^

.Net 4.5 及以上应用程序 ^

由于 log4net 的当前版本 1.2.13 是在 .NET 4.0 下编译的。为了保持一致,您应该从其源代码(可在此处获取)在当前的 .NET 框架(即 .NET 4.5.1)下重新编译它。新编译的 log4net 程序集应被引用,而不是下载的程序集(例如从 nuget.org)。到目前为止,在切换目标框架方面没有发现问题。

.Net 4.0 应用程序 ^

如果用户的系统仍然基于 .NET 4.0,并且他/她仍然希望使用日志数据服务,这也没有问题。需要做的是将数据服务的“Shared”和“Proxy”项目重新定位到 .NET 4.0,然后删除(关闭)“SUPPORT_ASYNC”条件编译标志。接下来,他/她应该从他的主项目中引用更改后的项目,就像在此处所做的那样。

仪表化 ^

网上有很多关于如何在 .NET 应用程序中使用 log4net 的教程,例如 CodeProject 上的这篇。本文包含的演示应用程序使用了一个名为 `Web.log4net` 的配置文件,该文件位于 `Web.config` 文件之外。以下行应添加到 `AssemblyInfo.cs` 文件中:

[assembly: log4net.Config.XmlConfigurator(ConfigFile = "Web.log4net", Watch = true)]

要使用当前的 log4net 附加程序,必须先初始化它。

  • 首先,从 Web 应用程序中引用附加程序项目,然后将以下行添加到 `Global.asax.cs` 文件中:

    添加命名空间引用

    using log4net;
    using Archymeta.Web.Logging;

    添加 ASP.NET 参数提供程序

    protected void Application_Start()
    {
        log4net.GlobalContext.Properties["user"] = new HttpContextUserNameProvider();
        log4net.GlobalContext.Properties["pageUrl"] = new HttpRequestUrlProvider();
        log4net.GlobalContext.Properties["referUrl"] = new HttpReferringUrlProvider();
        log4net.GlobalContext.Properties["requestId"] = new HttpRequestTraceIDProvider();
        ... other initialization steps
    }
  • 初始化对日志记录数据服务的调用。在初始化日志记录数据服务的 `CallContext` 之后,将以下行添加到

    DataServiceAppender.App = App;
    DataServiceAppender.ClientContext = ClientContext.CreateCopy();
    

    `Startup.Auth.cs` 文件中(请参阅此处)。注意:如果日志数据服务与应用程序的主数据服务不同,则 `App` 和 `ClientContext` 变量必须分别初始化,对于日志数据服务,使用与主数据服务不同的名称。

  • 跟踪用户。在某些分析或视图场景中,需要将单个用户的活动从日志记录中分离出来。如果是这样,日志项必须用用户标识信息进行标记。`HttpContextUserNameProvider`,它

    public class HttpContextUserNameProvider
    {
        public override string ToString()
        {
            HttpContext c = HttpContext.Current;
            if (c != null)
            {
                if (c.User != null && c.User.Identity.IsAuthenticated)
                    return c.User.Identity.Name;
                else
                    return c.Request != null && c.Request.AnonymousID != null ? 
                           c.Request.AnonymousID : "Request from Unknown Users";
            }
            else
            {
                if (Thread.CurrentPrincipal.Identity.IsAuthenticated)
                    return Thread.CurrentPrincipal.Identity.Name;
                else
                    return "Request from Unknown Users";
            }
        }
    }

    用于此目的。用户标识信息可以很容易地从已认证的用户中提取,如上所示。然而,这并不总是对未认证的用户可能。ASP.NET 应用程序有一个基于 cookie 的用户匿名 ID,可以用于识别用户,至少在一定时间跨度内。然而,对于 ASP.NET 框架的最新添加(如 Web API 通道或 SignalR 通道等),这种信息尚不可用。

    对于传统的 ASP.NET 应用程序,用户匿名 ID 默认情况下未启用。您必须添加以下节点

    <anonymousIdentification 
              enabled="true" 
              cookieless="UseCookies" 
              cookieName=".ASPXANONYMOUS" 
              cookieTimeout="30" 
              cookiePath="/" 
              cookieRequireSSL="false" 
              cookieSlidingExpiration="true" 
              cookieProtection="All" 
    />
    

    在 `Web.config` 文件的 `` 节点下。当然,提供的许多参数都应进行修改以适应特定应用程序的需求。未认证用户的匿名 ID 也不是自动生成的。以下处理程序应添加到 `Global.asax.cs` 文件中:

    public void AnonymousIdentification_Creating(Object sender, AnonymousIdentificationEventArgs e)
    {
        e.AnonymousID = Guid.NewGuid().ToString();
    }

    当未认证用户的匿名 ID 不可用或已过期时,会调用它。进行这些设置后,`Request.AnonymousID` 将可用于每个未认证用户的请求。

  • 跟踪请求。如上面快照的日志文件中所示,一个用户可以在短时间内发出多个并行请求,生成具有相同用户标识信息的交错日志记录。仅用户标识信息不足以进行更详细的分析。必须找到一种方法来跟踪单个请求的整个生命周期。对于传统的 ASP.NET 请求,这可以通过在对应于请求的控制器方法中插入以下内容来完成:

    public async Task<ActionResult> SomeMethod(...)
    {
        HttpContext.Items["RequestTraceID"] = Guid.NewGuid().ToString();
        ...
    }

    这将由 `HttpRequestTraceIDProvider` 类捕获。

    public class HttpRequestTraceIDProvider
    {
        public override string ToString()
        {
            HttpContext c = HttpContext.Current;
            if (c != null && c.Request != null)
                return c.Items["RequestTraceID"] == null ? null : c.Items["RequestTraceID"].ToString();
            else
                return null;
        }
    }

    然而,`HttpContext.Current` 仅对常规 ASP.NET 请求可用,因此其 `Items` 属性在任何其他类型的请求(如 Web API 请求或 SignalR 请求)中都不会存在。在没有此类传递请求绑定参数的机制的情况下,必须显式进行。您可以使用 `TracedLogMessage` 类来包装要传递给附加程序的日志消息和请求标识符,如下所示:

    log.Debug(new TracedLogMessage { ID = "...", Msg = "..." });

    其中 `ID` 属性的值将用作发送到日志记录数据服务的请求跟踪标识符。然而,传递给 `TracedLogMessage` 构造函数的“...”值需要从请求的开始传递到在进行日志记录的请求生命周期中涉及的每个方法。

  • 未捕获的异常。可以通过在 `Global.asax.cs` 文件中添加以下处理程序来记录:

    protected void Application_Error(object sender, EventArgs e)
    {
        HttpException hex = Server.GetLastError() as HttpException;
        if (hex.InnerException != null)
            log.Error("Unhandled exception thrown", hex.InnerException);
    }

如上所述,对于多用户 ASP.NET 应用程序,日志记录包含来自多个线程、进程和用户的交错记录。上述标记方法可以帮助读者做到这一点。当然,读者可以发明自己适合其应用程序的标记方法。

配置 ^

log4net 的配置位于 `Web.log4net` 文件中,如上面所述的 `AssemblyInfo.cs` 文件中指定的。当前附加程序的配置作为示例提供:

<appender 
      name="DataServiceLogAppender" 
      type="Archymeta.Web.Logging.DataServiceAppender, Log4NetServiceAppender">
  <maxQueueLength value="5000" />
  <maxUpdateBlockSize value="10" />
  <recordStackFrames value="true" />
  <userStackFramesOnly value="true" />
  <maxStackFramesUp value="10" />
  <lossy value="false" />
  <!-- parameters for the appender as a client of the log data service -->
  <loggerServiceUrl value="" />
  <maxReceivedMessageSize value="65536000" />
  <maxBufferPoolSize value="65536000" />
  <maxBufferSize value="65536000" />
  <maxArrayLength value="104857600" />
  <maxBytesPerRead value="4096" />
  <maxDepth value="64" />
  <maxNameTableCharCount value="16384" />
  <maxStringContentLength value="181920" />
</appender>

以下提供了对各种属性的说明。

  • maxQueueLength:要发送到数据服务的等待日志项的最大数量。默认为 5000。当等待日志项的数量达到此值时,系统对新日志项的响应取决于 `lossy` 的值。当 lossy 为 true 时,新项将被丢弃,否则添加语句将阻塞,直到等待日志项的数量降至此值以下。

  • maxUpdateBlockSize:在将日志项发送到数据服务之前,本地累积日志项的最大数量。默认为 10。如果附加程序不忙,它将定期发送本地事件块中剩余的项。客户端代码无需“刷新”它们。

  • recordStackFrames:是否在日志位置记录方法调用堆栈帧。默认为 true。

  • userStackFramesOnly:是否包含代码文件已知的堆栈帧。这在这里是“用户”的意思。默认为 true。如果部署站点上没有相应程序集的 .pdb 文件,则该程序集以及引用它的堆栈帧不是“用户”的。

  • maxStackFramesUp:要包含的最大堆栈帧数。默认为 -1,表示全部。现代框架即使对于简单的用户端调用也可能具有相当深的调用堆栈。这些堆栈帧信息对于调试用户端代码的意义非常有限。您可以使用此选项来限制它(如果需要)。

  • lossy:当等待日志项达到 `maxQueueLength` 时,是否丢弃新的日志项。默认为 false。

  • loggerServiceUrl:独立日志数据服务的可选基本 URL。仅当目标数据服务与应用程序的主数据服务不同时才指定。如果未指定,则假定为应用程序的主数据服务。当其设置为有效值时,以下附加程序的附加属性用于将应用程序设置为日志数据服务的客户端。

    以下是 WCF 客户端绑定的配置:

    • maxReceivedMessageSize:在任何服务调用通道上可以接收的消息的最大大小(以字节为单位)。
    • maxBufferPoolSize:为接收来自任何服务调用通道的消息的消息缓冲区管理器分配的最大内存量(以字节为单位)。
    • maxBufferSize:为接收来自任何服务调用通道的消息的消息缓冲区管理器分配的最大内存量(以字节为单位)。

    以下是绑定对应的 `readerQuotas`:

    • maxArrayLength:接收来自任何服务调用通道的消息的缓冲区允许的最大大小(以字节为单位)。
    • maxBytesPerRead:每次读取允许的最大字节数。
    • maxDepth:最大嵌套节点深度。
    • maxNameTableCharCount:名称表中允许的最大字符数。
    • maxStringContentLength:读取器返回的最大字符串长度。

    这些属性应设置为与日志数据服务的配置参数一致。

此附加程序没有 `<layout> ... </layout>` 子节点,因为它不涉及任何“布局”数据投影。

演示 ^

随附的包包含一个设置为使用日志系统的演示 Web 应用程序。

日志位置 ^

记录了 Web 应用程序的启动。这是在应用程序根目录的 `Startup.cs` 文件中完成的。

public void Configuration(IAppBuilder app)
{
    ConfigureAuth(app);
    log.Info("System start up");
}

登录和注销活动也被记录下来,这是在 `Libraries` 解决方案下的 `ArchymetaMembershipStores.dll` 程序集中完成的。为简单起见,本文未包含其源代码。您可以在本文中找到它(您阅读本文时,它很可能没有插入日志记录,但自己做起来也很容易)。

以下错误处理程序插入到 `Global.asax.cs` 文件中:

protected void Application_Error(object sender, EventArgs e)
{
    var ex = Server.GetLastError() as Exception;
    if (!(ex is HttpException))
        log.Error("Unhandled exception thrown.", ex);
}

以记录未处理的异常。以下行插入到 `HomeController` 类的 `About` 方法中:

 throw new InvalidOperationException("Invalid operation!!!!!!!!!");

以生成一个未处理的异常供日志记录器记录。每次访问 `About` 页面时都会抛出此异常,但这是故意的,不是 bug!

以下应用程序结束处理程序插入到 `Global.asax.cs` 文件中:

protected void Application_End(object sender, EventArgs e)
{
    log.Info("The web application stopped.");
}

以记录应用程序结束事件。

查看日志条目 ^

运行演示站点后,日志数据将被记录到数据服务中。读者的疑问可能是:如何查看日志条目?

这个问题的答案是:这取决于您希望如何在这样一个两阶段日志解决方案中查看它们。为了快速查看,您可以使用数据服务内置的查询页面。使用浏览器访问数据服务,然后尝试找到 `EventLogs` 数据集。在顶部指定排序条件后,将显示日志事件列表,如下所示。

图:数据服务提供的日志实体原始数据查询页面。

在查询页面上依赖于特定实体的实体集显示在该页面左下角,在该实体被选中后。在本例中,`EventLogs` 数据集中的某些实体(实际上只有一个)依赖于 `EventLogs` 数据集中的一个实体。通过点击相应的名称“EventLocations”(在本例中),可以打开依赖子集的弹出页面。弹出页面中显示的每个实体也可能有自己的依赖实体集,可以使用相同的方法递归显示。在本例中,`EventLocations` 数据集中的每个实体都有一个 `EventStackFrames` 数据集子集依赖于它,它们可以通过点击那里(即 `EventLocations` 的弹出页面内)的“EventStackFrames”按钮在嵌套弹出页面中显示。

这种原始数据查看过程可能不适合每个人的需求,因为它们是以通用方式生成的。如果用户希望更自定义地查看日志条目,包括它们应该如何布局,他/她应该按照此处的说明设计和开发自己的日志条目布局和查询应用程序。

监控数据源活动 ^

这稍微偏离了客户端(数据服务)端“日志记录”的主题,但因为它本身就是一种可以构成有用调试手段的服务端“日志记录”,所以如果您尝试使用数据服务开发软件,了解它可能很有用,因为它可以显示在数据服务中执行的数据修改操作序列,对应于客户端操作,例如插入日志实体图、用户登录/注销等。

数据更改活动,包括更改数据源中 `EventLogs` 数据集的操作,可以在数据服务的一个网页上监视,如下面的快照所示:

图:跨数据源的更改监视页面。

数据服务默认不开启此类监视器。在使用监视器之前,您必须在数据服务的 `Web.config` 文件中的 `` 节点下将以下项设置为 `true` 值,即:

<add key="DataSourceMonitoring" value="true" />

监视页面的相对路径(URL)为 `<数据源名称>/Monitoring`,其中 `<数据源名称>` 是数据源的名称,在本例中是 `AspNetMember`。可以从数据服务的 `Main` 页通过点击顶部的 `Monitoring` 链接访问它。从 `Home` 页通过点击 `Data Source` 链接可以访问 `Main` 页。

超越演示 ^

这里展示的似乎很简单,但它只是触及了一个更强大系统的表面。该解决方案为第三方提供了一套丰富的 API,可用于满足他们的日志记录和/或事件驱动应用程序需求:从简单的可视化布局,到智能查询和分析,甚至为具备复杂事件处理(CEP)的某些实时系统提供基础。

历史 ^

  • 2014-06-08。文章版本 1.0。初始提交。
© . All rights reserved.