Golang中私有后端API的错误处理与日志记录实践

Golang中私有后端API的错误处理与日志记录实践 大家好,

我正在开发我的第一个微服务应用,它是一个私有的后端API。 我已经开发了一些东西,但我想听听你们的建议,因为我不了解市场上常见日志聚合器的格式要求,而且我很确定这里有经验丰富的人能指出这个尝试中的许多缺陷。

为了方便测试,我在这里创建了一个仓库:

https://github.com/BigBoulard/api-error-handling-and-logging

非常感谢你们的帮助。

README

Test

go run src/main.go

Context

这段代码是一个简化的私有API示例,用于响应浏览器(客户端)的请求。

Goals

如果发生错误,客户端应该能够根据此服务器接收到的有效载荷,向最终用户提供适当的错误消息, 而不会泄露任何敏感数据,也不会透露任何关于后端代码结构或底层技术栈的信息。

错误日志记录应提供足够的信息来理解错误的来源:

Assumptions (to be challenged)

1 - 必须在控制器、服务、仓库或HTTP客户端中检测到错误时创建错误。 2 - 另一方面,日志记录应仅在控制器中进行,并应提供足够的信息来理解生成错误的请求路径。

Proposal

创建了一个 restErr 结构体,它包含:

  1. 提供正确HTTP错误响应所需的字段:

    • HTTP状态码,
    • 错误的标题
    • 潜在的原因(例如缺失/无效字段的名称)。 这些字段会被序列化并呈现给客户端。
  2. 日志记录信息:这些数据可能包含敏感信息(数据库字段名、数据库系统名称等),因此不会被序列化。

    • ErrPath:包含类似 productcontroller.GetProduct/productservice.GetProduct/productrepo.GetByID 的内容,用于告知错误是由产品仓库GetByID 函数触发的,该函数由产品服务GetProduct 函数调用,依此类推。
    • ErrCodeErrMessage:从MySQL、PostGres、外部API或其他微服务检索到的原始错误代码和消息。
type restErr struct {
	ErrStatus  int    `json:"status"`          // HTTP Status Code
	ErrTitle   string `json:"title"`           // A string representation of the Status Code
	ErrCause   string `json:"cause,omitempty"` // The cause of the error, can be empty

	ErrPath    string `json:"-"`               // Only used for Logging: The path of the error. Ex: "controller/controllerfunc/service/servicefunc/dbclient/dblientfunc"
	ErrMessage string `json:"-"`               // Only used for Logging: Raw error message returned by a DB, another Servive or whatever
	ErrCode    string `json:"-"`               // Only used for Logging: Raw error code from the DB or another service
}

type RestErr interface {
	Status() int     // HTTP status code
	Title() string   // A string representation of the Status Code

	Path() string    // Only used for Logging: The path of the error. Ex: "controller/controllerfunc/service/servicefunc/dbclient/dblientfunc"
	WrapPath(string) // Only used for Logging: Wrapper func to keep track of the path of the error
	Code() string    // Only used for Logging: Raw error code
	Message() string // Only used for Logging: Raw error message not returned to the client

	Error() string   // string representation of a restErr
}

更多关于Golang中私有后端API的错误处理与日志记录实践的实战教程也可以访问 https://www.itying.com/category-94-b0.html

1 回复

更多关于Golang中私有后端API的错误处理与日志记录实践的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


错误处理与日志记录的专业分析

你的设计思路基本正确,但有几个关键问题需要改进。以下是具体的实现建议:

1. 错误接口设计问题

你的 RestErr 接口暴露了太多日志相关方法,这违反了接口隔离原则。应该将错误响应和日志记录分离:

// 客户端错误响应接口
type ClientError interface {
    Status() int
    Title() string
    Cause() string
    Error() string
}

// 日志错误接口(内部使用)
type LoggableError interface {
    ClientError
    Path() string
    RawCode() string
    RawMessage() string
    WrapPath(string)
}

// 具体实现
type restErr struct {
    errStatus  int
    errTitle   string
    errCause   string
    
    errPath    string
    errCode    string
    errMessage string
}

func (e *restErr) Status() int { return e.errStatus }
func (e *restErr) Title() string { return e.errTitle }
func (e *restErr) Cause() string { return e.errCause }
func (e *restErr) Path() string { return e.errPath }
func (e *restErr) RawCode() string { return e.errCode }
func (e *restErr) RawMessage() string { return e.errMessage }
func (e *restErr) WrapPath(component string) { 
    e.errPath = component + "/" + e.errPath 
}

2. 错误工厂函数

创建统一的错误创建函数,确保错误格式一致:

func NewRestError(status int, title, cause string) *restErr {
    return &restErr{
        errStatus: status,
        errTitle:  title,
        errCause:  cause,
        errPath:   "",
    }
}

func WrapError(err error, component string, code, message string) *restErr {
    var restErr *restErr
    if errors.As(err, &restErr) {
        restErr.WrapPath(component)
        if code != "" {
            restErr.errCode = code
        }
        if message != "" {
            restErr.errMessage = message
        }
        return restErr
    }
    
    // 如果是普通error,转换为restErr
    return &restErr{
        errStatus:  http.StatusInternalServerError,
        errTitle:   "Internal Server Error",
        errCause:   err.Error(),
        errPath:    component,
        errCode:    code,
        errMessage: message,
    }
}

3. 中间件日志记录

在控制器层面统一记录日志,而不是在每个控制器方法中:

func LoggingMiddleware(logger *zap.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            
            // 创建自定义ResponseWriter来捕获状态码
            rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
            
            next.ServeHTTP(rw, r)
            
            duration := time.Since(start)
            
            // 记录请求日志
            logger.Info("request completed",
                zap.String("method", r.Method),
                zap.String("path", r.URL.Path),
                zap.Int("status", rw.statusCode),
                zap.Duration("duration", duration),
                zap.String("user_agent", r.UserAgent()),
            )
        })
    }
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

4. 错误响应格式化

统一错误响应格式,确保不泄露敏感信息:

func WriteError(w http.ResponseWriter, err error) {
    var clientErr ClientError
    if errors.As(err, &clientErr) {
        response := map[string]interface{}{
            "status": clientErr.Status(),
            "title":  clientErr.Title(),
            "cause":  clientErr.Cause(),
        }
        
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(clientErr.Status())
        json.NewEncoder(w).Encode(response)
        
        // 如果是可记录的错误,记录详细信息
        var logErr LoggableError
        if errors.As(err, &logErr) {
            zap.L().Error("request error",
                zap.String("path", logErr.Path()),
                zap.String("code", logErr.RawCode()),
                zap.String("message", logErr.RawMessage()),
                zap.Int("http_status", logErr.Status()),
            )
        }
        return
    }
    
    // 未知错误,返回通用错误
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "500",
        "title":  "Internal Server Error",
        "cause":  "An unexpected error occurred",
    })
}

5. 使用示例

// 仓库层
func (r *ProductRepo) GetByID(id string) (*Product, error) {
    var product Product
    err := r.db.QueryRow("SELECT * FROM products WHERE id = $1", id).Scan(&product)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, NewRestError(http.StatusNotFound, "Not Found", "Product not found")
        }
        // 包装数据库错误
        return nil, WrapError(err, "productrepo.GetByID", "DB_ERROR", err.Error())
    }
    return &product, nil
}

// 服务层
func (s *ProductService) GetProduct(id string) (*Product, error) {
    product, err := s.repo.GetByID(id)
    if err != nil {
        return nil, WrapError(err, "productservice.GetProduct", "", "")
    }
    return product, nil
}

// 控制器层
func (c *ProductController) GetProduct(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    product, err := c.service.GetProduct(id)
    if err != nil {
        WriteError(w, WrapError(err, "productcontroller.GetProduct", "", ""))
        return
    }
    
    json.NewEncoder(w).Encode(product)
}

6. 日志聚合器兼容性

对于常见的日志聚合器(如ELK、Loki、Datadog),建议使用结构化日志格式:

// 初始化Zap日志
func NewLogger() *zap.Logger {
    config := zap.NewProductionConfig()
    config.OutputPaths = []string{"stdout"}
    config.EncoderConfig.TimeKey = "timestamp"
    config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    
    logger, _ := config.Build()
    return logger
}

// 日志格式示例(JSON):
// {
//   "timestamp": "2024-01-15T10:30:00Z",
//   "level": "error",
//   "msg": "request error",
//   "path": "productcontroller.GetProduct/productservice.GetProduct/productrepo.GetByID",
//   "code": "DB_ERROR",
//   "message": "connection refused",
//   "http_status": 500,
//   "trace_id": "abc-123-xyz"  // 分布式追踪ID
// }

关键改进点:

  1. 接口分离:将客户端错误和日志错误接口分离
  2. 错误包装:使用WrapError统一包装错误,保持调用链
  3. 中间件日志:在中间件中统一记录请求日志,避免控制器污染
  4. 结构化日志:使用Zap等结构化日志库,方便日志聚合器解析
  5. 错误工厂:统一错误创建方式,确保一致性

这种设计符合微服务错误处理的最佳实践,同时满足日志聚合器的格式要求。

回到顶部