我们的应用程序的核心逻辑不应该受到干扰,如果有太多的技术“细节”,比如日志记录或系统指标。当然,这很难避免。我发现在许多项目中,我们将记录器非常深入地放在代码中。在一天结束时,我们几乎到处都有记录器。在测试中,我们还必须在任何地方提供模拟实现。在大多数情况下,日志记录器是一个冗余依赖项。在本文中,我将论证我们应该只在顶层函数中使用记录器。
顶层日志记录规则背后的想法很简单——您只在一个地方记录所有内容,不要在应用程序的较低层中传递记录器。什么是顶层?例如,您的 CLI 命令或 HTTP 或事件处理程序。下面,您可以找到在处理程序级别记录每个错误的示例。
错误使用日志问题描述
代码语言:javascript复制type myHandler struct {
logger log.Logger
srv myService
}
func (h myHandler) operation(w ResponseWriter, r *Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.logger.Errorf("cannot read the body: %s", err)
w.WriteHeader(http.StatusBadRequest)
return
}
req := request{}
if err = json.Unmarshal(body, &req); err != nil {
h.logger.Errorf("cannot read the body: %s", err)
w.WriteHeader(http.StatusBadRequest)
return
}
err = h.srv.Operation(r.Context(), req.Param1, req.Param2)
if err != nil {
h.logger.Errorf("cannot execute the operation: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// return the success response
}
代码看起来很简单。但有时也会把日志记录器放到其他地方。该myService可以是一个很好的例子。
代码语言:javascript复制type myService struct {
logger log.Logger
}
func (s myService) Operation(ctx context.Context, param1, param2 int) error {
result := myOperation(param1, param2)
if result == 0 {
// this shouldn't happen but when it does, we're ignoring such cases
s.logger.Infof("the result is zero")
return nil
}
// do some other operations
if err := s.db.Persist(ctx, myCalculations); if err != nil {
return fmt.Errorf("cannot persist X: %w", err)
}
return nil
}
我们在服务级别独立使用日志记录器,通过日志让我们了解被忽略的潜在运行的极端情况。一方面,这是有道理的。我们不想返回错误,因为我们的逻辑已经为这种边缘情况做好了准备。另一方面,我们正在做两件事:
- 我们向不需要它的逻辑中添加了不必要的依赖
- 这使边缘情况变得更难测试
最后一点可能是最具争议的。测试有多难?我们所要做的就是为param1提供value,param2这将产生result = 0并检查该方法是否返回一个nil。你怎么能确保测试通过,因为result = 0?您可以通过以下几种方式做到这一点:
- 检查代码覆盖率 - 如果这些行是绿色的,我们已经完成了。当有人在我们的目标 if 语句之前更新代码时,问题就会发生。因为它提供了有关条件返回nil,这可能会导致我们的测试仍然通过的情况,
func (s myService) Operation(ctx context.Context, param1, param2 int) error {
op := anotherCheck(ctx, param1)
if op > threshold {
return nil
}
result := myOperation(param1, param2)
if result == 0 {
// this shouldn't happen but when it does, we're ignoring such cases
s.logger.Infof("the result is zero")
return nil
}
// do some other operations
if err := s.db.Persist(ctx, myCalculations); if err != nil {
return fmt.Errorf("cannot persist X: %w", err)
}
return nil
}
- 使用调试器确保这些行被执行——缺点和之前的想法类似
- 模拟记录器并检查记录的消息。那会很好用。因为不在产生断言错误,所以这会在测试过程中产生误解。
你会碰到更多这样的情况。以这种方式处理它们会在代码中隐藏更深层次的代码复杂性的问题。
自认为正确的例子
在这个例子中我可以建议的是定义一个新错误并返回它。
代码语言:javascript复制var ErrEmptyResult = errors.New("the result is zero")
func (s myService) Operation(ctx context.Context, param1, param2 int) error {
result := myOperation(param1, param2)
if result == 0 {
return ErrEmptyResult
}
// do some other operations
if err := s.db.Persist(ctx, myCalculations); if err != nil {
return fmt.Errorf("cannot persist X: %w", err)
}
return nil
}
请注意,我们不再需要Operation()方法中的记录日志。日志消息呢?我们可以轻松地将其移动到顶层处理程序。
代码语言:javascript复制func (h myHandler) operation(w ResponseWriter, r *Request) {
// ...
err = h.srv.Operation(r.Context(), req.Param1, req.Param2)
if err != nil {
if errors.Is(err, ErrEmptyResult) {
// this shouldn't happen but when it does, we're ignoring such cases
s.logger.Infof("the result is zero")
return
}
h.logger.Errorf("cannot execute the operation: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// return the success response
}
测试将更加易于理解和精确。我们清楚地说明了我们对该方法的期望,并且 100% 确定return调用的是哪个。缺点是if err != nil处理程序中的语句可能会在一段时间后变得非常庞大。在这种情况下,我会考虑这个地方的处理程序或逻辑是否太大,是否值得将其拆分为更小的部分。
在其他地方没有更多的日志?
我想做的是说服你避免在代码的更深层使用记录器。可能有些情况下,这可能是很难的。另一方面,拥有日志记录器可能是有用的。我想到的一个用法是,让人知道一些如上所示的边缘案例,但却隐藏在代码的深处。另一个是在跟踪或调试级别中添加日志,当我们开始在生产中遇到奇怪的问题时,启用适当的日志级别,这有助于我们发现问题。
当然,你的用法可能是有效的。问题是当我们过度使用日志,并且在我们有太复杂的代码或我们的测试同时覆盖了太多的代码时使用它,而很难找到根本原因所在。要记住日志记录不应该是重构的替代物。它虽然在短期内可以是有益的。但从长远来看,它可能只是通过引入一个问题进而带来另外一个问题。