最近使用 wails 库做一个工具时,遇到了日志的问题。为了方便查找问题,我选择使用 logrus 库打印日志,且为了方便开发和正式运行时查看日志,选择使用如下的方式设置,让日志同时打印到文件和控制台上:

f, err := os.OpenFile("logs.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755)
if err != nil {
	log.Fatal(err)
}
log.SetOutput(io.MultiWriter(os.Stdout, f))

在开发工具中时,一切正常,但打包之后虽然创建了日志文件,但却没有内容。一开始查找 logrus 库的问题,没有找到相关的情况,后面又怀疑时 wails 库的原因,也找不到答案。

看了 wails 也有日志功能,猜测可能需要使用自带的日志库才能正确输出,不过修改了之后也仅仅是会将网页端的日志也记录下来,但打包后的应用还是没有输出日志。

在一度的迷茫之后,忽然想到如果只用文件呢?测试了一下,只输出到文件就正常了,开发/或打包成应用后都能输出到文件。

于是猜测,可能是打包之后的应用,os.Stdout 也许没有了?查看 io.MultiWriter 的实现:

func (t *multiWriter) Write(p []byte) (n int, err error) {
	for _, w := range t.writers {
		n, err = w.Write(p)
		if err != nil {
			return
		}
		if n != len(p) {
			err = ErrShortWrite
			return
		}
	}
	return len(p), nil
}

果然其 Write 方法就是循环写入,有一个报错,后面就返回了。而我设置的顺序,文件恰好在后面,于是还没到它就跳过了。

那就得检查一下 os.Stdout 是否能写入,AI 表示,写了试一下就知道了:

if _, err = os.Stdout.Write([]byte{}); err == nil {
    log.SetOutput(io.MultiWriter(os.Stdout, f))
} else {
    log.SetOutput(f)
}

没想到这样又不行了,意思是 os.Stdout.Write 并没有报错。尝试在 []byte{} 中加入内容,不写入空的字节切片,没想到又可以了,比如这样:

if _, err = os.Stdout.Write([]byte{1}); err == nil {
    log.SetOutput(io.MultiWriter(os.Stdout, f))
} else {
    log.SetOutput(f)
}

查看 os.Stdout.Write 在 windows 上的实现,go\pkg\mod\golang.org\toolchain@v0.0.1-go1.23.4.windows-amd64\src\internal\poll\fd_windows.go

func (fd *FD) Write(buf []byte) (int, error) {
	if err := fd.writeLock(); err != nil {
		return 0, err
	}
	defer fd.writeUnlock()
	if fd.isFile {
		fd.l.Lock()
		defer fd.l.Unlock()
	}

	ntotal := 0
	for len(buf) > 0 {
		b := buf
		if len(b) > maxRW {
			b = b[:maxRW]
		}
		var n int
		var err error
		if fd.isFile {
			switch fd.kind {
			case kindConsole:
				n, err = fd.writeConsole(b)
			default:
				n, err = syscall.Write(fd.Sysfd, b)
				if fd.kind == kindPipe && err == syscall.ERROR_OPERATION_ABORTED {
					// Close uses CancelIoEx to interrupt concurrent I/O for pipes.
					// If the fd is a pipe and the Write was interrupted by CancelIoEx,
					// we assume it is interrupted by Close.
					err = ErrFileClosing
				}
			}
			if err != nil {
				n = 0
			}
		} else {
			if race.Enabled {
				race.ReleaseMerge(unsafe.Pointer(&ioSync))
			}
			o := &fd.wop
			o.InitBuf(b)
			n, err = execIO(o, func(o *operation) error {
				return syscall.WSASend(o.fd.Sysfd, &o.buf, 1, &o.qty, 0, &o.o, nil)
			})
		}
		ntotal += n
		if err != nil {
			return ntotal, err
		}
		buf = buf[n:]
	}
	return ntotal, nil
}

可以看到,当传入的内容为空切片时,直接就跳过返回了,也没有错误。

总结:wails 在编译为正式运行的程序时,会自动加上 -H windowsgui 标记,避免弹出控制台界面,此时的 os.Stdout 写入内容是会报错的,测试会返回 /dev/stdout: The handle is invalid. 错误,但写入空字节切片不会报错。而 logrus 使用 io.MultiWriter 来实现多处输出时,MultiWriter 中的 Write 方法是循环挨个写入 writer,如果遇到报错,就直接返回了,会让后面的就跳过了,不写入。