1.日志监控

日志监控

日志几乎是我们每一个iOS开发者每一天都要打交道的东西,比如运行时想看一下某个变量的值,那就NSLog()一下;当然,在Swift语言下我们还有另外一种选择,那就是print()方法。都是帮助我们方便调试与分析的工具。

开源社区也为我们贡献了很多非常优秀的日志框架,比如OC中大名鼎鼎的CocoaLumberjack,有兴趣的同学可以移步https://github.com/CocoaLumberjack/CocoaLumberjack

开源日志框架是为了我们方便极了日志而开发的,比如我们可以调用CocoaLumberjack的DDLogInfo("Info")来记录一个Info类型的日志,用DDLogError("Error")来记录一个Error类型的日志。不同的日志记录框架我们需要调用的API也不一样,但是他们都有一个共同的特点,就是到最后都会调用系统的NSLog()方法。

因此我们需要做日志全自动监控的话,可以从NSLog()方法入手。这里我们会分两方面来讲,因为在iOS上我们不单单可以用NSLog()来记录日志,在Swift语言下还可以用print()方法来记录我们的日志。这一章我们会先介绍如何选择和优化我们监控NSLog()日志的方案,然后比较NSLog()print()的不同点。然后再介绍Swift下特有的print()方法的处理。

1.1 监控NSLog日志

该章节涉及到的开源库ASLEye,大家可以先通过https://github.com/zixun/ASLEye下载下来,一遍对照开源代码一遍阅读该章节。

可能你在翻开该章节前已经有了自己的方案了,因为这道题并没有唯一解,我们只是要找出最优解。下面让我们来逐一分析监控NSLog日志的方案。

自定义API

可能你和我一样,第一个想到的就是模仿CocoaLumberjack,咱整一套自己的API,让大家都来调咱的API不完事了么,多省事。比如LogEyeInfo("Info")就是Info类型的日志,LogEyeError("Error")就是Error类型的日志。

哈哈,咱是省事了,但是接入咱日志监控的开发者不干了,或许他正在和CocoaLumberjack愉快的玩耍,你侬我侬,你突然让我全换了,你他妈的在逗我?

的确,这样接入方的开发者的工作量就太大了,而且也和全自动记录日志沾不上边,不是我们的最佳方案,换!


NSLog宏

相信大家这个时候也都想到了NSLog宏的方案。咱可以定义一个NSLog的宏,去覆盖掉系统的NSLog的方法。在咱的NSLog的宏里面去调用我们第一种方案中的自定义API就好。比如

#define NSLog(frmt, ...) AppInfo(frmt, ##__VA_ARGS__)

用这招偷梁换柱的方法,我们就可以在我们的AppInfo里面撒欢的处理我们的日志啦,用户一调我们就知道,用户一调我们就知道,好开心啊。赶紧找我们的接入方开发者来接入。

“我不接!”

“为啥子哟?”

“因为我现在用的开源日志记录框架也有了一个NSLog宏,把你的接入了日志记录框架的NSLog宏会被你覆盖掉,也有可能你的宏会被他的宏覆盖掉,谁先加载谁被覆盖!”

“......(握草泥马)”


看来,NSLog宏的方案虽然好,但是一旦出现另一个NSLog宏就会出现“一山不容二宏”,“宏和宏不可兼得”的现象。我们不能保证接入方的开发者们不会接入其他编写了NSLog宏的第三方库,也不能保证接入方的开发者自己不会写一个NSLog宏。由此可见,NSLog宏的方案虽好,但是存在易冲突,不可靠,不可控等问题。换!

寻找新的方案

现在,两个方案都被我们排除了,还有啥比这两个更好的方案可以用呢。如果脑海里暂时没有好的方案与想法,我们就应该祭出我们程序员的基本生存技能,问度娘。。。。。。不是,问Google!

首先,我们去搜索一下Apple关于NSLog的文档。我们会发现NSLog方法中调用的是NSLogv方法。Apple文档中对于NSLogv函数是这样写的Logs an error message to the Apple System Log facility(参见https://developer.apple.com/reference/foundation/1395074-nslogv?language=objc)。

这句话有两个信息我们需要重点关注下,一个就是error message,还有一个就是Apple System Log,为啥NSLog是记录错误信息的,而不是日志信息呢?Apple System Log又是个什么鬼?

不急,我们再搜索下CocoaLumberjack的文档,我们会发现CocoaLumberjack的文档说了这么两句话:

NSLog does 2 things:
    It writes log messages to the Apple System Logging (asl) facility. This allows log messages to show up in Console.app.

    It also checks to see if the application's stderr stream is going to a terminal (such as when the application is being run via Xcode). If so it writes the log message to stderr (so that it shows up in the Xcode console).

参见https://github.com/CocoaLumberjack/CocoaLumberjack/blob/b1a3837366bc286ee24671ef1042b7214a8aa0ca/Documentation/Performance.md

CocoaLumberjack的文档告诉我们,当我们写了一句NSLog的时候,他会做两件事情:1.把日志写到Apple System Log里面。2.把日志展示到Xcode的控制台上面。

两个文档都提到了Apple System Log,那这个Apple System Log到底是个什么鬼。其实Apple System Log可以理解为就是一个我们设备的日志数据库,这里存放了所有应用所有进程产生的日志。也就是说,不管在哪,只要你调用了NSLog,系统就会把它写到Apple System Log中。

那为啥NSLog是记录错误信息的,而不是日志信息呢?其实Apple设计NSLog的时候就是一个记录错误日志的API,不然他也不会把日志写到一个叫Apple System Log的数据库里面,要知道记录日志是一个高频发的操作,如果每条都放到数据库中,是一件很浪费性能的事情。所以我觉得应该叫NSLogError才更合适。

NSLog耗性能的一个原因也是因为需要把日志数据写到Apple System Log数据库,所以大伙的App线上Release版本尽量不要写NSLog,不但耗性能,而且不安全。Swift的print方法就不会把日志写到数据库中。当然在Swift中并没有废弃掉NSLog方法,但是Swift对NSLog方法做了优化,只有在模拟器环境下才会将日志写入Apple System Log

Apple System Log

OK,我们找到了我们的方案:Apple System Log。Apple为Apple System Log提供了一个framework供大家使用来对Apple System Log的数据进行操作。他就是asl,那下面我们就来试试怎么通过代码把Apple System Log的数据捞出来。

首先,我们需要把我们的asl模块import进来:

import asl

然后我们创建一个专门用来捞ASL的日志的类ASLEye,并且做个Timer定时器,定时拉取ASL的数据:

public class ASLEye: NSObject {

    public func open(with interval:TimeInterval) {
        self.timer = Timer.scheduledTimer(timeInterval: interval,target: self,selector: #selector(ASLEye.pollingLogs),userInfo: nil,repeats: true)
    }

    public func close() {
        self.timer?.invalidate()
        self.timer = nil
    }

    private var timer: Timer?
}

好了,现在就让我们来实现pollingLogs方法,来拉取我们的数据。

@objc private func pollingLogs() {
    //TODO  调用拉取数据API,获得数据数组
    //TODO  判断数据数组是否为空
    //TODO  将数据数组回调给上层
    }

咱先不着急pollingLogs方法的具体实现。大家可以和我一样先注释好方法要做的事情。

这里我们要先介绍下asl的api的特点。asl的api类似SQL语句,你可以往查询语句里面加参数来过滤数据,比如要查询当前App的日志的话,需要设置App的BundleID,当然,我们每次启动我们的App的时候都是不同的进程,所以我们还需要设置当前App的进程ID。因此我们可以编写一个initQuery的方法来生成我们的查询语句:

private func initQuery() -> aslmsg {
        var query: aslmsg = asl_new(UInt32(ASL_TYPE_QUERY))
        //set BundleIdentifier to ASL_KEY_FACILITY
        let bundleIdentifier = (Bundle.main.bundleIdentifier! as NSString).utf8String
        asl_set_query(query, ASL_KEY_FACILITY, bundleIdentifier, UInt32(ASL_QUERY_OP_EQUAL))

        //set pid to ASL_KEY_PID
        let pid = NSString(format: "%d", getpid()).cString(using: String.Encoding.utf8.rawValue)
        asl_set_query(query, ASL_KEY_PID, pid, UInt32(ASL_QUERY_OP_NUMERIC))

        return query
    }

然后我们来编写拉取数据API,获得数据数组的API,这个API要做的事情就是调用initQuery()方法生成我们的查询语句,然后解析返回的response,将response解析成一个字符串的数组返回给调动着,咱就叫他retrieveLogs()方法:

private func retrieveLogs() -> [String] {
        var logs = [String]()

        var query: aslmsg = self.initQuery()

        let response: aslresponse? = asl_search(nil, query)
        guard response != nil else {
            return logs
        }

        var message = asl_next(response)
        while (message != nil) {
            let log = self.log(from: message!)
            logs.append(log)

            message = asl_next(response)
        }
        asl_free(response)
        asl_free(query)

        return logs
    }

private func parserLog(from message:aslmsg) ->String {
        let content = asl_get(message, ASL_KEY_MSG)!;
        return String(cString: content, encoding: String.Encoding.utf8)!
    }

好,现在我们让pollingLogs()来调用我们的retrieveLogs()方法:

@objc private func pollingLogs() {
        let logs = self.retrieveLogs()
        if logs.count > 0 {
            self.delegate?.aslMonitor?(aslMonitor: self, didMonitorLogs: logs)
        }
    }

这样我们的日志就能通过我们的delegate方法回调给上层了。当然在这之前我们需要编写一个我们这个delegate的protocol

@objc public protocol ASLEyeDelegate: class {
    @objc optional func aslEye(aslEye:ASLEye,catchLogs logs:[String])
}

然后在ASLEye上添加一个delegate的变量:

    public weak var delegate: ASLEyeDelegate?

但是,这个时候大家去调用NSLog会发现存在一个问题。比如我调用了下NSLog("Hello"),我们的日志监控每次轮询都会取到我们的日志数据,我们的delegate会一直回调,把Hello给上层。

会造成这样其实也不难理解,因为App System Log是一个数据库,按照咱现在的查询语句,每次插叙的时候当然会把当前App当前的日志全部返回给我们,所以,我们还需要设置一个'游标',换句话说就是我们需要在我们的查询语句里加一个参数,将我们上次查到的最后一个日志的id给塞进去,然后查询大于这个ID的日志。

OK,首先,我们来设置一个变量lastMessageID,用来记录最后一次查到的日志ID:

private var lastMessageID: Int32 = 0

然后在我们的initQuery()方法里最后返回我们的query前将我们的lastMessageID给塞进去:
```swift
private func initQuery() -> aslmsg {
var query: aslmsg = asl_new(UInt32(ASL_TYPE_QUERY))
//set BundleIdentifier to ASL_KEY_FACILITY
let bundleIdentifier = (Bundle.main.bundleIdentifier! as NSString).utf8String
asl_set_query(query, ASL_KEY_FACILITY, bundleIdentifier, UInt32(ASL_QUERY_OP_EQUAL))

    //set pid to ASL_KEY_PID
    let pid = NSString(format: "%d", getpid()).cString(using: String.Encoding.utf8.rawValue)
    asl_set_query(query, ASL_KEY_PID, pid, UInt32(ASL_QUERY_OP_NUMERIC))
top Created with Sketch.