平时项目开发中,有涉及到开发多个 laravel 项目的情况,项目上线后,需要及时了解是否发生异常,这时候想到可以写一个日志监听器,当发生日志写入的情况后,能第一时间通过邮件把写入的错误日志发送到管理员邮箱。

第一版

首先想到 Laravel 中的异常处理器 Handler 中可以拦截渲染到视图中的异常,可以在 Handler 抛出异常前将报错完整地通过邮件发出,实现难度不大,用 PHP 即可完成。

时间仓促,实现后发现缺点也很大:

首先是重复性工作,多个项目代码都需要这么改才能实现,此外这种方式的对代码有侵入性,会破坏的原有的项目代码结构,并且 PHP 的性能瓶颈也是众所周知的,面对频繁报错的情况,大量邮件发送可能会引起阻塞。

改进

后面想到,既然 Laravel 会将所有异常都写入日志,通过实时监控日志变化,类似于 Linux 命令中的 Tail -f 功能,这样不就能实现异常监控了吗,正好最近在学习 Go 语言,就自然想到用 Go 来实现这个日志监控器

实现

go-mail/gomail 这个包可以实现发送邮件的功能,为了监听文件读写的事件,需要引入 fsnotify/fsnotify 这个包,为了方便迁移,邮箱信息和监控路径需要封装成 json 配置文件,通过运行时参数来指定配置文件的路径,全部用到的包:

import (
    "log"
    "time"
    "os"
    "flag"
    "io/ioutil"
    "encoding/json"
    "crypto/tls"
    "github.com/fsnotify/fsnotify"
    gomail "github.com/go-mail/gomail"
)

配置文件指定了邮件服务器的地址和监控文件的路径,形如:

{
    "mail": {
        "from": "",         // 邮箱账号,admin@admin.com
        "host": "",         // 邮箱SMTP地址,例如:smtp.admin.com
        "port": 465,        // SMTP 端口,非 SSL 加密端口 465
        "username": "",     // SMTP 登录用户名,通常不带域名,如:admin
        "password": ""      // SMTP 登录密码
    },
    "list": [               // 全部监控日志列表,可以扩展到多个项目日志
        {
            "title": "",    // 通知邮件标识
            "path": "",     // 监控路径
            "emails": ""    // 通知到哪些邮箱,多个邮箱可以都好分割
        }
    ]
}

首先需要编写读取配置文件的逻辑,GolangJSON 解析比 PHP 要麻烦一些:

type configMail struct {
    From string
    Host string
    Port int
    Username string
    Password string
}

type configListItem struct {
    Title string
    Path string
    Emails string
}

type config struct {
    Mail configMail
    List []configListItem
}

func readJson(path string, r *config) {
    data, err := ioutil.ReadFile(path)
    if err != nil {
        panic(err)
    }

    err = json.Unmarshal(data, &r)
    if err != nil {
        panic(err)
    }
}

封装发送邮件的函数:

func mail(mail configMail, to string, subject string, body string) {
    from := mail.From
    host := mail.Host
    port := mail.Port
    username := mail.Username
    password := mail.Password

    m := gomail.NewMessage()

    m.SetHeader("From", from)
    m.SetHeader("To", to)
    m.SetHeader("Subject", subject)
    m.SetBody("text/html", body)

    d := gomail.NewDialer(host, port, username, password)
    d.TLSConfig = &tls.Config{InsecureSkipVerify: true}

    if err := d.DialAndSend(m); err != nil {
        panic(err)
    }
}

为了方便轮询监控日志,还需要一些辅助函数:

// 判断文件路径是否存在
func isFileExists(filename string) bool {
    _, err := os.Stat(filename)

    if (err == nil || !os.IsNotExist(err)) {
        return true
    } else {
        return false
    }
}

//如果文件不存在,则阻塞直至出现
func waitIfNotExists(filename string) {
    duration := 250 * time.Millisecond
    for {
        if isFileExists(filename) {
            return
        }

        select {
        case <-time.After(duration):
        }
    }
}

监控日志中读出的监控项目:

func watch(v configListItem, m configMail) {
    //如果文件路径不存在,则阻塞,直到创建
    waitIfNotExists(v.Path)

    // 创建一个监控器
    watcher, _ := fsnotify.NewWatcher()
    defer watcher.Close()

    watcher.Add(v.Path)

    // 记录文件初始大小
    f, _ := os.Open(v.Path)
    originStat, _ := os.Stat(v.Path)
    originSize := originStat.Size()
    f.Close()

    for {
        select {

        // 日志修改时间处理
        case ev := <-watcher.Events:

            //如果日志被删除,则重新阻塞等待
            if ev.Op & fsnotify.Remove == fsnotify.Remove {
                log.Println("File removed: ", ev.Name)

                watcher.Close()
                waitIfNotExists(ev.Name)
                watcher, _ = fsnotify.NewWatcher()
                watcher.Add(ev.Name)

                st, _ := os.Stat(v.Path)
                originSize = st.Size()

                if originSize > 0 {
                    buffer := make([]byte, originSize)

                    f, _ = os.Open(v.Path)
                    f.ReadAt(buffer, 0)
                    f.Close()

                    to := v.Emails
                    subject := "【" + v.Title + "】 Exception Found"
                    body := "<pre>" + string(buffer) + "</pre>"

                    go mail(m, to, subject, body)
                }
            }

            // 如果出现写入事件,则读取差异部分内容,通过邮件发送
            if ev.Op & fsnotify.Write == fsnotify.Write {
                stat, _ := os.Stat(v.Path)
                newSize := stat.Size()

                if newSize > originSize {
                    bufferSize := newSize - originSize
                    buffer := make([]byte, bufferSize)

                    f, _ = os.Open(v.Path)
                    f.ReadAt(buffer, originSize)
                    f.Close()

                    originSize = newSize

                    to := v.Emails
                    subject := "【" + v.Title + "】 Exception Found"
                    body := "<pre>" + string(buffer) + "</pre>"

                    // 调用协程发送邮件
                    go mail(m, to, subject, body)
                }
            }
        }
    }
}

主函数:

func main() {
    configPath := flag.String("c", "/etc/reporter.json", "config.json file path")

    flag.Parse()

    r := config{}
    readJson(*configPath, &r)

    // 为每个监控项开启一个 goroutine,用于监控写入
    for _, v := range r.List {
        go watch(v, r.Mail)
    }

    // 阻塞防止退出
    done := make(chan bool)
    <-done
}

完成后编译成二进制文件 reporter

go build -ldflags "-s -w" -o reporter

放到后台执行,并重定向输出内容到 reporter.log,方便查看(替换命令中指定的配置文件路径):

nohup ./reporter -c /path/to/config.json >> /var/log/reporter.log 2>&1 &!

打完收工