平时项目开发中,有涉及到开发多个 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": "" // 通知到哪些邮箱,多个邮箱可以都好分割
}
]
}
首先需要编写读取配置文件的逻辑,Golang
中 JSON
解析比 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 &!
打完收工