Logging API 调用

在本节中,让我们简化日志拦截器。这与我们在上一节中所做的类似,不过这次我们将使用另一种中间件:日志中间件

虽然这个中间件可以与许多不同的日志记录器集成,但我们将在 Golang 中使用默认的日志包。这样,它就可以很容易地与您最喜欢的日志记录器集成。

接下来的命令只有在您没有获取 go-grpc-middleware 依赖时才需要。如果您按照章节顺序操作,则不需要此步骤。

首先,让我们获取这个中间件的依赖。在 server 文件夹中,我们运行以下命令:

$ go get github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging

现在,我们可以开始创建我们的日志记录器。我们将通过定义一个函数来创建它,该函数返回一个 loggerFunc。这个函数的签名如下:

func(ctx context.Context, lvl logging.Level, msg string, fields ...any)

我们已经知道 context 是什么,但其余的内容是与日志记录器相关的。lvl 是日志级别,例如 DebugInfoWarningError,通常用来根据严重性过滤日志。然后,msg 是由日志记录中间件生成的消息,比如 ":started call"":finished call"。这有助于我们理解日志的上下文。最后,fields 是我们打印有用日志所需的其他所有信息。在我们的例子中,我们将使用服务名称和方法名称。这将使我们能够创建如下的日志:

INFO :started call todo.v2.TodoService UpdateTasks

一个不容易理解的地方是 fields 参数。这是因为它呈现为一个任意类型的可变参数(vararg)。实际上,我们可以将其转换为一个 map,以便获取像 grpc.servicegrpc.method 等特定的字段名称。为了做到这一点,我们可以写出以下代码:

f := make(map[string]any, len(fields)/2)
i := logging.Fields(fields).Iterator()
for i.Next() {
    k, v := i.At()
    f[k] = v
}

注意,我们创建了一个长度为 len(fields)/2 的 map。这是因为在 fields 参数中,字段的名称和值是交替出现的。一个示例是:

grpc.service  todo.v2.TodoService  grpc.method ListTasks

您可以通过展开 vararg 打印 fields 来查看完整内容:

log.Println(fields...)

了解了这一点后,我们可以继续编写日志记录器。我们将创建一个名为 logCalls 的函数,它接受一个 log.Logger(来自 Golang 标准库)作为参数,并返回一个 logging.Logger(来自日志中间件)。日志记录器的逻辑将是检查日志级别,为消息添加级别前缀,然后将服务名称和方法名称附加到整个消息中:

import (
    //...
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
)

const grpcService = "grpc.service"
const grpcMethod = "grpc.method"

func logCalls(l *log.Logger) logging.Logger {
    return logging.LoggerFunc(func(_ context.Context, lvl logging.Level, msg string, fields ...any) {
        f := make(map[string]any, len(fields)/2)
        i := logging.Fields(fields).Iterator()
        for i.Next() {
            k, v := i.At()
            f[k] = v
        }
        switch lvl {
        case logging.LevelDebug:
            msg = fmt.Sprintf("DEBUG :%v", msg)
        case logging.LevelInfo:
            msg = fmt.Sprintf("INFO :%v", msg)
        case logging.LevelWarn:
            msg = fmt.Sprintf("WARN :%v", msg)
        case logging.LevelError:
            msg = fmt.Sprintf("ERROR :%v", msg)
        default:
            panic(fmt.Sprintf("unknown level %v", lvl))
        }
        l.Println(msg, f[grpcService], f[grpcMethod])
    })
}

现在,尽管这种方法总是准确的,因为我们可以从构建的 map 中检索键,但这意味着每次调用此拦截器时我们都需要构建一个 map。这实际上并不高效。我想先向您展示完整的示例,之后再展示高效的方式,以便您理解如何使用 fields 参数。

为了提高效率,我们可以利用服务和方法总是分别位于索引 5 和 7 的事实。因此,我们将删除 map 创建部分,替换 grpcServicegrpcMethod 为 5 和 7,并直接访问 fields 的第 5 和第 7 个元素:

const grpcService = 5
const grpcMethod = 7

func logCalls(l *log.Logger) logging.Logger {
    return logging.LoggerFunc(func(_ context.Context, lvl logging.Level, msg string, fields ...any) {
        // ...
        l.Println(msg, fields[grpcService], fields[grpcMethod])
    })
}

这样就更加高效了。值得注意的是,这样做的安全性较差。我们假设接收到的所有 fields 中,服务和方法总是位于相同的索引,并且我们的 fields 数组足够大。我们现在可以安全地这样假设,因为这些是始终按此顺序添加的常见字段。然而,如果库发生变化,您可能会遇到越界访问或者获取不同信息的情况,请对此保持警觉。

最后,我们需要做的就是注册拦截器。这与我们之前注册身份验证拦截器的方式类似,主要区别在于,现在我们需要创建一个日志记录器并将其传递给 logCalls 函数。我们将使用 Golang 的 log.Logger,它会在消息前打印日期和时间。最后,我们将 logCalls 的结果传递给 logging.UnaryServerInterceptorlogging.StreamServerInterceptor

import (
    //...
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
)

//...

func main() {
    //...
    logger := log.New(os.Stderr, "", log.Ldate|log.Ltime)
    opts := []grpc.ServerOption{
        //...
        grpc.ChainUnaryInterceptor(
            //...
            logging.UnaryServerInterceptor(logCalls(logger)),
        ),
        grpc.ChainStreamInterceptor(
            //...
            logging.StreamServerInterceptor(logCalls(logger)),
        ),
    }
    //...
}

之后,我们就可以运行我们的服务器:

$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051

在运行客户端之前,确保您已经将 client/interceptors.go 文件中的 authTokenValue 替换为 authd

然后运行客户端:

$ go run ./client 0.0.0.0:50051

如果我们检查运行服务器的终端,我们应该会看到如下信息:

INFO :started call todo.v2.TodoService ListTasks
INFO :finished call todo.v2.TodoService ListTasks

总结来说,我们看到与身份验证中间件类似,我们可以将一个日志记录器简单地添加到我们的 gRPC 服务器中。我们还看到,我们可以通过将 fields varargs 转换为 map 来访问更多信息。最后,我们看到,由于某些字段总是位于 vararg 中的相同位置,因此我们可以直接通过索引访问这些信息,而不必每次都生成 map