编写更简洁的 Go Web 服务器
介绍
编写干净、高质量的代码使程序更易于理解、维护、改进和测试。
在本文中,我将分享一些编写简洁高效的 Go Web 服务器的技巧。这些技巧主要针对 Go 的架构和错误处理相关问题。
包含所有示例的完整项目可在Github上找到。
通过清晰的架构分离关注点
整洁架构是一种用于分离关注点的设计模式。罗伯特·“鲍勃叔叔”·马丁在其著作《整洁架构:软件结构与设计工匠指南》中,提出了这种架构,认为它可以将应用程序分解为松散耦合的组件。
来源:blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
该架构将应用程序分为四个主要组件。
实体是应用程序的业务模型。它们描述了系统最通用的需求。
用例层实现系统中的所有用例。它包含特定于应用程序的业务规则,并描述数据如何从实体流出和流向实体。
接口适配器层将来自外部机构(如数据库或 Web)的数据转换为最适合用例和实体的格式。该层包含控制器、演示器和视图。
控制点流从外部机构通过外部接口适配器和用例向内流向实体。
在编写 Go Web 服务器时,我使用模型作为实体、服务作为用例、存储库作为数据源(例如数据库、外部服务等)的接口适配器、处理程序作为 Web 的接口适配器等术语。
处理程序依赖于服务并与之通信,而服务依赖于存储库(通常一次一个存储库对应一个服务)来存储和检索数据。
例如,考虑一个将新书的数据保存到内存数据库的应用程序:
func CreateBook(w http.ResponseWriter, r *http.Request) {
requestBody := createBookRequestBody{}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
book := models.Book{
ID: bson.NewObjectId(),
Title: requestBody.Title,
CreatedAt: time.Now().UTC(),
}
err := db.Update(func(tx *Tx) error {
b, err := json.Marshal(&book)
if err != nil {
return err
}
_, _, err = tx.Set("books::"+book.ID.Hex(), string(b), nil)
return err
})
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
writeSuccess(w, book)
}
这个处理函数执行了一系列操作:解码 HTTP 请求主体、创建新书籍、将其保存到数据库,然后响应客户端。我们将其拆分为处理程序、服务和存储库。
// handlers/book/handler.go
func (h bookHandler) CreateBook(w http.ResponseWriter, r *http.Request) {
requestBody := createBookRequestBody{}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
book, err := h.bookService.CreateBook(requestBody.Title)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
writeSuccess(w, book)
}
// services/book/service.go
func (s service) CreateBook(title string) (*models.Book, error) {
book := models.Book{
ID: bson.NewObjectId(),
Title: title,
CreatedAt: time.Now().UTC(),
}
if err := s.repository.CreateBook(book); err != nil {
return nil, err
}
return &book, nil
}
// repository/inmemory.go
func (r inMemoryRepository) CreateBook(book models.Book) error {
return r.db.Update(func(tx *buntdb.Tx) error {
b, err := json.Marshal(&book)
if err != nil {
return err
}
_, _, err = tx.Set("books::"+book.ID.Hex(), string(b), nil)
return err
})
}
现在,我们创建的组件每个只执行一项功能:一个组件解码 HTTP 请求并写入响应,另一个组件创建数据模型,最后一个组件将数据保存到数据库。
诚然,这会使代码更加冗长,但它也带来了许多好处。每个组件都易于理解、易于维护且可重用。
针对接口编程,而不是针对实现
不要依赖模块的具体实现,而是使用接口。将模块的内部工作原理隐藏在接口后面,这样在不破坏其他模块的情况下修改模块会变得更加容易。
在我们的应用程序中,图书服务隐藏在Service
接口后面,内存数据库存储库也隐藏在Repository
接口后面:
// handlers/book/handler.go
func NewBookHandler(bookService book.Service) BookHandler {
return bookHandler{bookService}
}
// services/book/service.go
type Service interface {
CreateBook(title string) (*models.Book, error)
}
func NewService(repository repository.Repository) Service {
return service{repository}
}
// repository/repository.go
type Repository interface {
CreateBook(book models.Book) error
}
// repository/inmemory.go
func NewInMemoryRepository(db *db.Client) Repository {
return inMemoryRepository{db}
}
通过这种方式放松不同组件之间的耦合,只要接口满足要求,对存储库模块的更改就不会影响服务层。这使得应用程序更易于维护。
基于接口编程也使得独立测试应用程序的不同层变得更加容易。例如,在图书服务的单元测试中,我们可以提供一个图书存储库的模拟实现,而不是具体的实现inMemoryDatabaseRepository
。
接口也使得替换依赖项更加容易。如果我们决定将数据存储更改为 MongoDB,我们只需要编写适配器(mongoRepository
),然后更改要在运行时使用的存储库实现。
// repository/mongo.go
func NewMongoRepository(db *mgo.Database) Repository {
return mongoRepository{coll: db.C("books")}
}
func (m mongoRepository) CreateBook(book models.Book) error {
return m.coll.Insert(book)
}
// server.go
bookRepository := repository.NewMongoRepository(mongoDB)
// bookRepository := repository.NewInMemoryRepository(inMemoryDB)
bookService := book.NewService(bookRepository)
bookHandler = books.NewBookHandler(bookService)
使用自定义 HTTP 处理程序简化错误处理
让我们重新回顾一下书籍处理函数。当发生错误时,处理程序会将错误消息以及 HTTP 状态代码返回给用户。
func (h bookHandler) CreateBook(w http.ResponseWriter, r *http.Request) {
requestBody := createBookRequestBody{}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
book, err := h.bookService.CreateBook(requestBody.Title)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
writeSuccess(w, book)
}
随着我们添加更多 HTTP 处理程序,这种显式的错误处理变得重复,令人不快。为了保持应用程序的 DRY 原则,我们可以定义一个返回错误的自定义 HTTP 处理程序类型。
type Handler func(w http.ResponseWriter, r *http.Request) error
然后我们可以更改createBook
处理程序以返回错误:
func (h bookHandler) CreateBook(w http.ResponseWriter, r *http.Request) error {
requestBody := createBookRequestBody{}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
return err
}
book, err := h.bookService.CreateBook(requestBody.Title)
if err != nil {
return err
}
writeSuccess(w, book)
return nil
}
Handler
要将我们的类型与 http 包一起使用,我们需要实现http.Handler
接口的ServeHTTP
方法:
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
}
}
最后,为了将处理程序注册到其路由,我们将处理程序函数转换为以下Handler
类型:
http.Handle("/book", handlers.Handler(bookHandler.CreateBook))
使用自定义错误来处理客户端错误
为了使应用程序更加用户友好,我们应该使用适当的 HTTP 状态代码返回错误消息。
我们还需要区分应该返回给客户端的错误(客户端错误)和不应该返回给客户端的错误(服务器错误)。
客户端错误是与请求相关的错误,例如验证、身份验证和权限错误。
服务器错误是应用程序内部工作的问题,例如,连接到数据库或外部远程服务时发生的错误。
服务器错误可能包含有关数据库或文件系统的敏感信息,因此,当发生此类错误时,我们希望使用 HTTP 500 内部服务器错误进行响应。
为此,我们可以创建一个包含消息和类型的自定义错误类型。
type Type string
type AppError struct {
text string
errType Type
}
func (e AppError) Error() string {
return e.text
}
要创建类似 HTTP 400 的错误,我们使用一个AppError
带有TypeBadRequestError
类型的 new :
// handlers/book/handler.go
func (u handler) GetBook(w http.ResponseWriter, r *http.Request) error {
// ...
if ... {
return errors.Error("invalid vendor ID")
}
// ...
}
// errors/error.go
const (
TypeBadRequest Type = "bad_request_error"
TypeNotFound Type = "not_found_error"
)
func Error(text string) error {
return &AppError{text: text, errType: TypeBadRequest}
}
在该方法中ServeHTTP
,我们现在可以改进错误处理。如果错误与自定义AppError
类型匹配,我们将返回错误消息以及与错误类型对应的 HTTP 状态码。如果错误不是AppError
,我们将假定它是服务器错误,返回 HTTP 500 响应,并记录完整的错误以供调试。
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
appError := new(errors2.AppError)
if errors.As(err, &appError) { // client error
writeError(w, err.Error(), errTypeStatusCode(appError.Type()))
return
}
// server error
log.Println("server error:", err)
writeError(w, "Internal Server Error", http.StatusInternalServerError)
}
标准化 HTTP 响应格式
使用响应结构使服务器响应一致且可预测。
简单的响应格式可能包含“成功”标志、显示/错误消息以及要返回给客户端的数据。根据您的应用程序,您可能需要在响应正文中添加更多字段。
type response struct {
Body *responseBody
StatusCode int
}
type responseBody struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
func (r response) ToJSON(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(r.StatusCode)
return json.NewEncoder(w).Encode(r.Body)
}
func OK(message string, data interface{}) *response {
return &response{&responseBody{Message: message, Data: data}, http.StatusOK}
}
func Fail(message string, statusCode int) *response {
return &response{&responseBody{Message: message}, statusCode}
}
要在处理程序中使用响应结构:
// handlers/book/handler.go
type getBookResponse struct {
Book *models.Book `json:"book"`
}
func (u handler) GetBook(w http.ResponseWriter, r *http.Request) error {
// ...
return responses.OK("We found your book!", getBookResponse{retrievedBook}).ToJSON(w)
}
// handlers/handler.go
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
if errors.As(err, &appError) { // client error
responses.Fail(err.Error(), errTypeStatusCode(appError.Type())).ToJSON(w)
return
}
// server error
log.Println("server error:", err)
responses.Fail("Internal Server Error", http.StatusInternalServerError).ToJSON(w)
}
结论
本文中的技巧并非详尽无遗。根据您的应用,可能存在其他更好的解决方案,但这些建议可以作为编写更高效的 Go 服务器的指南和起点。