整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

我是如何在 Go 中构建 Web 服务的

用了近十年的 C# 转到 Go 是一个有趣的旅程。有时,我陶醉于 Go 的简洁[1];也有些时候,当熟悉的 OOP (面向对象编程)模式[2]无法在 Go 代码中使用的时候会感到沮丧。幸运的是,我已经摸索出了一些写 HTTP 服务的模式,在我的团队中应用地很好。

当在公司项目上工作时,我倾向把可发现性放在最高的优先级上。这些应用会在接下来的 20 年运行在生产环境中,必须有众多的开发人员和网站可靠性工程师(可能是指运维)来进行热补丁,维护和调整工作。因此,我不指望这些模式能适合所有人。

Mat Ryer 的文章[3]是我使用 Go 试验 HTTP 服务的起点之一,也是这篇文章的灵感来源。

代码组成

Broker

一个 Broker 结构是将不同的 service 包绑定到 HTTP 逻辑的胶合结构。没有包作用域结级别的变量被使用。依赖的接口得益于了 Go 的组合[4]的特点被嵌入了进来。

type Broker struct {
    auth.Client             // 从外部仓库导入的身份验证依赖(接口)
    service.Service         // 仓库的业务逻辑包(接口)

    cfg    Config           // 该 API 服务的配置
    router *mux.Router      // 该 API 服务的路由集
}

broker 可以使用阻塞[5]函数 New() 来初始化,该函数校验配置,并且运行所有需要的前置检查。

func New(cfg Config, port int) (*Broker, error) {
    r := &Broker{
        cfg: cfg,
    }

    ...

    r.auth.Client, err = auth.New(cfg.AuthConfig)
    if err != nil {
        return nil, fmt.Errorf("Unable to create new API broker: %w", err)
    }

    ...

    return r, nil
}

初始化后的 Broker 满足了暴露在外的 Server 接口,这些接口定义了所有的,被 route 和 中间件(middleware)使用的功能。service 包接口被嵌入,这些接口与 Broker 上嵌入的接口相匹配。

type Server interface {
    PingDependencies(bool) error
    ValidateJWT(string) error

    service.Service
}

web 服务通过调用 Start() 函数来启动。路由绑定通过一个闭包函数[6]进行绑定,这种方式保证循环依赖不会破坏导入周期规则。

func (bkr *Broker) Start(binder func(s Server, r *mux.Router)) {
    ...

    bkr.router = mux.NewRouter().StrictSlash(true)
    binder(bkr, bkr.router)

    ...

    if err := http.Serve(l, bkr.router); errors.Is(err, http.ErrServerClosed) {
        log.Warn().Err(err).Msg("Web server has shut down")
    } else {
        log.Fatal().Err(err).Msg("Web server has shut down unexpectedly")
    }
}

那些对故障排除(比如,Kubernetes 探针[7])或者灾难恢复方案方面有用的函数,挂在 Broker 上。如果被 routes/middleware 使用的话,这些仅仅被添加到 webserver.Server 接口上。

func (bkr *Broker) SetupDatabase() { ... }
func (bkr *Broker) PingDependencies(failFast bool)) { ... }

启动引导

整个应用的入口是一个 main 包。默认会启动 Web 服务。我们可以通过传入一些命令行参数来调用之前提到的故障排查功能,方便使用传入 New() 函数的,经过验证的配置来测试代理权限以及其他网络问题。我们所要做的只是登入运行着的 pod 然后像使用其他命令行工具一样使用它们。

func main() {
    subCommand := flag.String("start", "", "start the webserver")

    ...

    srv := webserver.New(cfg, 80)

    switch strings.ToLower(subCommand) {
    case "ping":
        srv.PingDependencies(false)
    case "start":
        srv.Start(BindRoutes)
    default:
        fmt.Printf("Unrecognized command %q, exiting.", subCommand)
        os.Exit(1)
    }
}

HTTP 管道设置在 BindRoutes() 函数中完成,该函数通过 ser.Start() 注入到服务(server)中。

func BindRoutes(srv webserver.Server, r *mux.Router) {
    r.Use(middleware.Metrics(), middleware.Authentication(srv))
    r.HandleFunc("/ping", routes.Ping()).Methods(http.MethodGet)

    ...

    r.HandleFunc("/makes/{makeID}/models/{modelID}", model.get(srv)).Methods(http.MethodGet)
}

中间件

中间件(Middleware)返回一个带有 handler 的函数,handler 用来构建需要的 http.HandlerFunc。这使得 webserver.Server 接口被注入,同时所有的安静检查只在启动时执行,而不是在所有路由调用的时候。

func Authentication(srv webserver.Server) func(h http.Handler) http.Handler {
    if srv == nil || !srv.Client.IsValid() {
        log.Fatal().Msg("a nil dependency was passed to authentication middleware")
    }

    // additional setup logic
    ...

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := strings.TrimSpace(r.Header.Get("Authorization"))
            if err := srv.ValidateJWT(token); err != nil {
                ...
                w.WriteHeader(401)
                w.Write([]byte("Access Denied"))

                return
            }

            next.ServeHTTP(w, r)
        }
    }
}

路由

路由有着与中间件有着类似的套路——简单的设置,但是有着同样的收益。

func GetLatest(srv webserver.Server) http.HandlerFunc {
    if srv == nil {
        log.Fatal().Msg("a nil dependency was passed to the `/makes/{makeID}/models/{modelID}` route")
    }

    // additional setup logic
    ...

    return func(w http.ResponseWriter, r *http.Request) {
        ...

        makeDTO, err := srv.Get
    }
}

目录结构

代码的目录结构对可发现性进行了高度优化。

├── app/
|   └── service-api/**
├── cmd/
|   └── service-tool-x/
├── internal/
|   └── service/
|       └── mock/
├── pkg/
|   ├── client/
|   └── dtos/
├── (.editorconfig, .gitattributes, .gitignore)
└── go.mod
  • app/ 用于项目应用——这是新来的人了解代码倾向的切入点。dd
    • ./service-api/ 是该仓库的微服务 API;所有的 HTTP 实现细节都在这里。
  • cmd/ 是存放命令行应用的地方。
  • internal/ 是不可以被该仓库以外的项目引入的一个特殊目录[8]
    • ./service/ 是所有领域逻辑(domain logic)所在的地方;可以被 service-apiservice-tool-x,以及任何未来直接访问这个目录可以带来收益的应用或者包所引入。
  • pkg/ 用于存放鼓励被仓库以外的项目所引入的包。
    • ./client/ 是用于访问 service-api 的 client 库。其他团队可以使用而不是自己写一个 client,并且我们可以借助我们在 cmd/ 里面的 CI/CD 工具来 “dogfood it[9]” (使用自己产品的意思)。
    • ./dtos/ 是存放项目的数据传输对象,不同包之间共享的数据且以 json 形式在线路上编码或传输的结构体定义。没有从其他仓库包导出的模块化的结构体。/internal/service 负责 这些 DTO (数据传输对象)和自己内部模型的相互映射,避免实现细节的遗漏(如,数据库注释)并且该模型的改变不破坏下游客户端消费这些 DTO。
  • .editorconfig,.gitattributes,.gitignore 因为所有的仓库必须使用 .editorconfig,.gitattributes,.gitignore[10]
  • go.mod 甚至可以在有限制的且官僚的公司环境[11]工作。

最重要的:每个包只负责意见事情,一件事情!

HTTP 服务结构

└── service-api/
    ├── cfg/
    ├── middleware/
    ├── routes/
    |   ├── makes/
    |   |   └── models/**
    |   ├── create.go
    |   ├── create_test.go
    |   ├── get.go
    |   └── get_test.go
    ├── webserver/
    ├── main.go
    └── routebinds.go
  • ./cfg/ 用于存放配置文件,通常是以 JSON 或者 YAML 形式保存的纯文本文件,它们也应该被检入到 Git 里面(除了密码,秘钥等)。
  • ./middleware 用于所有的中间件。
  • ./routes 采用类似应用的类 RESTFul 形式的目录对路由代码进行分组和嵌套。
  • ./webserver 保存所有共享的 HTTP 结构和接口(Broker,配置,Server等等)。
  • main.go 启动应用程序的地方(New()Start())。
  • routebinds.go BindRoutes() 函数存放的地方。

你觉得呢?

如果你最终采用了这种模式,或者有其他的想法我们可以讨论,我乐意听到这些想法!


via: https://www.dudley.codes/posts/2020.05.19-golang-structure-web-servers/

作者:James Dudley[12]译者:dust347[13]校对:unknwon[14]

本文由 GCTT[15] 原创编译,Go 中文网[16] 荣誉推出

参考资料

[1]

简洁: https://www.youtube.com/watch?v=rFejpH_tAHM

[2]

模式: https://en.wikipedia.org/wiki/Software_design_pattern

[3]

Mat Ryer 的文章: https://pace.dev/blog/2018/05/09/how-I-write-http-services-after-eight-years.html

[4]

Go 的组合: https://www.ardanlabs.com/blog/2015/09/composition-with-go.html

[5]

阻塞: https://stackoverflow.com/questions/2407589/what-does-the-term-blocking-mean-in-programming

[6]

闭包函数: https://gobyexample.com/closures

[7]

Kubernetes 探针: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)0

[8]

特殊目录: https://dave.cheney.net/2019/10/06/use-internal-packages-to-reduce-your-public-api-surface

[9]

dogfood it: https://en.wikipedia.org/wiki/Eating_your_own_dog_food

[10]

所有的仓库必须使用 .editorconfig,.gitattributes,.gitignore: https://www.dudley.codes/posts/2020.02.16-git-lost-in-translation/

[11]

有限制的且官僚的公司环境: https://www.dudley.codes/posts/2020.04.02-golang-behind-corporate-firewall/

[12]

James Dudley: https://www.dudley.codes/

[13]

dust347: https://github.com/dust347

[14]

unknwon: https://github.com/unknwon

[15]

GCTT: https://github.com/studygolang/GCTT

[16]

Go 中文网: https://studygolang.com/

oahead是一种轻量级嵌入式web服务器,全部代码开源,可以在多种平台编译运行,gahead具备以下典型特性:

·支持虚拟服务器
·可使用 SNMP 代理
·支持 SSL v3
·具有搜索引擎
·支持 ASP、JavaScript、CGI、DHTML
·容易移植和系统集成
·可不使用文件系统

下载源路径如下:

https://gitee.com/mirrors/GoAhead

goahead在linux下的安装和简单使用可以参考以下链接:

https://blog.csdn.net/weihan0208/article/details/118483839

以linux环境为例

goahead的源码在简易使用如仅仅用于请求文件时,基本无需修改源代码,按照流程编译运行即可,比如将网页文件(index.html)放在 /var/www/goahead路径下,服务器的IP地址为192.168.0.20,绑定80端口。

在浏览器内输入 192.168.0.20/index.html,即可访问成功。

在大多数情况下,简单的网页尽可以在本地直接使用浏览器打开,但是当网页代码中存在ifreme时,加载子网页就会存在报错,此时goahead便可以发挥作用,如下图:

当然网页可执行的功能不仅包括文件请求,还有大量纷繁复杂的操作,大多数操作goahead也是支持的,但是需要使用路由文件定义相关的操作,部分操作需要修改服务器代码才可以实现,goahead源码提供了基本的route.txt文件,路径为 ./src/route.txt,用于支持基础访问,其内容如下:

路由文件的基本使用方法可以参链接:

https://blog.csdn.net/weixin_44074105/article/details/124930732

Handler

作用

action

用于将URL的请求与C函数绑定的处理程序。

continue

不进行任何操作的伪处理程序,常用于登录验证。

cgi

为 CGI 程序提供服务的处理程序。

file

用于提供网页、图像和静态资源的处理程序。

jst

为动态内容提供 Javascript 模板的处理程序。

options

用于提供 HTTP 选项和跟踪方法的处理程序。

redirect

处理route重定向的处理程序。

upload

处理文件上传的处理程序。

在源码文件中提供的例程中

1.“route uri=/”

可以认为是万能文件路由,浏览器的任何文件访问,均可以通过此请求到相应的资源文件。

2.“route uri=/action handler=action”

Action请求需要在服务器的源文件中添加对应的action代码实现,例如用户登录、服务器参数设置等操作,均可以用action实现。

3.“route uri=/cgi-bin dir=cgi-bin handler=cgi”

cgi请求常用于调用CGI子程序,一般来说,cgi-bin只是用于标记请求为cgi,dir=cgi-bin用于标记CGI程序的存储位置,cgi的编译及安装。

4.“route uri=/jst extensions=jst handler=jst”

Jst实际是JSP中的(JavaServer? Pages Standard Tag Library)。

5.Goahead同时提供了简单的用户登录验证过程

使用以下路由可以完成表单验证形式:

route uri=/pub/

Route uri=/action/login methods=POST handler=action redirect=200@/ redirect=401@/pub/login.html

route uri=/action/logout methods=POST handler=action redirect=200@/pub/login.html

route uri=/ auth=form handler=continue redirect=401@/pub/login.html

其中第一行为登录前可访问的公共文件;

第二行用于登录验证操作,第三行用于退出登录操作;

第四行为万能路由,可以访问一切文件,但是前提是完成登录验证,否侧将被重定向只至登录界面。

基本验证形式:

route uri=/auth/basic/ auth=basic abilities=manage

route uri=/auth/digest/ auth=digest abilities=manage

总结

GOahead的路由文件可以视作一种顺序执行,逐行匹配的脚本,可访问的资源文件,由根据不同的method handler redirect auth等操作,可以产生条件执行的简单逻辑,根据此思路结合表单用户登录过程,用户可以自行修改route文件实现对应的操作,最终完成整个服务器,当然,goahead不仅仅只有上边的几个操作,还可以实现文件下载、文件上传等操作,感兴趣的读者可以自行查找相关的资料。

本文诞生在项目开发实践中,用于某型号的物联网设备,以实现参数配置,在实际使用中,还发现如果要实现直接使用192.168.0.20进入网页还需要在源码中添加部分代码,否则,浏览器的访问会出现以下问题:

在浏览器输入192.168.0.20,实际被服务器重定向到192.168.0.43/index.html(浏览器所在电脑的ip),经过一番查找,最终找到问题所在。

在源码中使用了auth=form登录验证,或者只有uri=/路由时,浏览器仅使用IP地址访问服务器,服务器找不到浏览器找不到具体的请求,将会直接进入void websRedirect(Webs *wp, cchar *uri)(位于http.c文件)函数,重新向浏览器的访问位置,实际上由于未更改任何源码,所以代码中:

host = websHostUrl ? websHostUrl : wp->ipaddr;

websHostUrl = NULL,host实际为wp->ipaddr,即浏览器所在终端的IP地址,如果要解决此问题,可以在goahead.c文件中

函数:MAIN(goahead, int argc, char **argv, char **envp)

中添加如下内容

websSetHostUrl("192.168.0.20");

websSetIndex("index.html");

"192.168.0.20"为服务器ip,实际使用中需要使用系统的接口获取之后转化为字符串,或者存在公网ip或者域名时(外网访问),填写公网ip或者域名index.html为默认重定向文件。


快节奏的 Web 开发世界中,在最新框架的热潮中,最好的解决方案往往在于简单。 在最近的一篇文章中,我们谈到了使用 Go 和 htmx 进行动态 Web 内容的本地化。 在这篇文章中,我们将进一步探讨 htmx 和 Go 的结合、最佳实践和可维护性。 采用“Lindy”方法进行 Web 开发。 林迪效应是一个概念,它断言一个不易腐烂的想法或技术的未来预期寿命与其当前的年龄成正比; 它越老,它存在的时间可能就越长。

回顾一下,htmx 是一个轻量级的 Javascript 库,它允许我们直接从 HTML 访问现代浏览器功能,从而消除了对繁重的客户端框架,尤其是客户端状态的需求。 IE。 编写 Javascript(htmx 库)以避免编写 Javascript(在您的网站上)。 htmx 的总体理念与 Go 对简单性的关注非常吻合。 尽管如此,这种组合似乎是“现代”网络开发所构成的一种逆向立场。

htmx 哲学

htmx 的核心在于对精益代码和敏捷开发的信念。 专注于创建无状态客户端:

HTML 在线:htmx 通过在线发送 HTML 来实现动态用户界面,简化服务器端开发和维护,并最大限度地减少客户端状态。 htmx 遵循 HATEOAS 约束,通过利用 HTML,使用超媒体作为应用程序状态的引擎。

渐进增强:使用 htmx,您可以从基本的 HTML 页面开始,然后根据需要对其进行增强。 这种方法提高了可访问性和可用性,这也意味着如果 Javascript 由于任何原因失败,您的网站仍然可以工作。

简单性:htmx 相信尽可能降低复杂性。 没有构建过程,没有依赖关系,没有特殊的服务器端技术,并且需要学习的 API 面积也很小。

互操作性:htmx 旨在与您现有的 HTML 和服务器端代码配合使用。 它不需要您学习新的模板语言或更改服务器端架构。

恢复简单性

htmx 和 Go 有着共同的理念,都倾向于极简主义和组合而不是继承。 该原则有利于通过组合简单对象而不是使用分层继承结构来创建复杂对象。 组合允许对象通过合并实现所需功能的其他对象来实现复杂的行为,从而形成更灵活和可维护的代码结构。 htmx 简化了服务器端渲染,通过 AJAX 增强了 HTML,而 Go 则因其编写后端服务的品质而受到赞赏。 它们共同为网站与后端服务的交互提供了全新的视角,统一了连贯开发体验的原则。

使用 html

最基本的是,htmx 允许您只需向 HTML 添加一些属性即可发出 AJAX 请求。 例如,如果您有一个按钮,并且希望在单击该按钮时发出 AJAX 请求,您可以这样做:

<button hx-post="/clicked" hx-target="#message">Click me</button>
<div id="message"></div>

当您单击按钮时,htmx 会向 /clicked URL 发送 POST 请求。 当服务器使用一些 HTML 进行响应时,该 HTML 会被放置到 #message div 中。 htmx 提供了许多其他属性和选项,用于发出 AJAX 请求、处理事件、处理响应等。 这些功能使您能够使用最少的 JavaScript 构建复杂的动态界面。 添加等待填充内容的空 <div> 元素感觉像 jQuery 风格,但让人回想起 jQuery 和 PHP 时代开发的敏捷性。 可以说是一种更清洁的方法。 对于事件类型,服务器发送的事件可以简单地实现如下:

<div hx-sse="connect:/user/updates swap:userUpdate">
    Updates will appear here.
</div>

使用 Go 模板,我们从后端数据注入变量:

<a href="/"
   hx-trigger="click"
   hx-indicator="#spinner"
   hx-get="/data/{{$sport.Key}}"
   hx-target="#data-table">Fetch</a>

htmx Go 模板最佳实践

我们发现了在 Web 应用程序中使用 htmx 时的一些最佳实践:

服务器端状态管理:除了身份验证等必要的cookie之外,所有状态都应该在服务器端进行管理。

HATEOAS with HTML:使用 HTML(而不是 JSON)实现超媒体作为应用程序状态引擎 (HATEOAS)。 在根页面加载时,呈现页面上可以动态的所有元素并为它们提供服务,而无需额外的请求。 这可以通过渲染到 Go 缓冲区中来实现,并在需要时将它们与 template.HTML 一起注入到根模板中。 适当利用缓存。 这符合完全服务器端渲染 (SSR) 登陆页面并增强 SEO。 用户体验非常好,因为所有元素都很活泼,而且似乎没有任何内容异步加载。 一个附带的好处是,即使禁用了 Javascript,第一页加载也始终有效,因为 html 完全从服务器呈现。 更改元素时,它们会从各自的端点动态加载。 例如。 登录和注销状态的区域。

动态元素的 ID 可寻址性:所有动态元素都应具有唯一的 ID。 这使得它们可寻址,允许从各个 Go 端点和 hx-target 进行寻址。

动态元素的单独模板和端点:每个动态元素应该有一个相应的 Go 模板和端点。 这导致了模块化且可维护的代码。

使用 go:embed 进行发布,从磁盘加载进行开发:利用 Go 的嵌入功能进行生产,同时允许在开发过程中进行磁盘加载。 这确保了高效的性能和热重载。

跳过开发中的 API 步骤:明智地选择 ORM,最好是代码生成。 开发过程中直接从数据库过渡到HTML,省略API层。 这是执行中的乘数。 对于我们在访问服务数据时自然需要的 API,我们使用 Go ent。

为动态端点实施服务器端事件 (SSE):利用 SSE 处理动态数据。 根据数据的不同,动态部分直接是 SSE 元素,并且应该在加载时订阅自身。 Go 处理程序将在第一次调用时推送初始状态。 或者,第一个调用照常从 GET 请求呈现,然后 GET 请求提供订阅自身的模板。

项目结构

有组织的项目结构不仅仅是美观或偏好的问题; 这是直接影响开发过程的一个基本方面。 结构良好的项目可以促进团队成员之间的协作,增强可维护性,并加快新开发人员的入职速度。 我们遵循一种简单、可能常见的方法,我们内部称之为“golean”。 使用 Go ent 生成模型、文档和 API,布局类似如下。 我们将在以后的文章中讨论这一点。

我们将 Go Web 服务器和其他服务分离到不同的存储库中,以充分利用 Go 的模块系统进行依赖管理和版本控制。 我们的存储库将遵循典型的结构:

.
├── cmd
│   └── web                     // The main application for this project
│       ├── handlers.go
│       ├── l10n
│       │   └── translations.json
│       ├── main.go             // The entry point for the server application
│       ├── session.go
│       ├── static              // Directory for static files
│       │   └── favicon.svg
│       └── templates           // Directory for HTML/X template files
│           ├── base.html
│           ├── head.html
│           ├── partials
│           │   └── welcome.html
│           └── root.html
├── docs
│   └── openapi.json            // OpenAPI spec generated by entoas via Go ent
├── internal                    // Internal packages used by the server application
│   └── ...
├── go.mod
├── go.sum
└── ... // Optionally Dockerfile, drone.yml, dotenv etc.

这种分离确保了应用程序的不同部分之间的清晰区分,遵循许多 Go 最佳实践。 cmd 目录用于应用程序,它们是程序的入口点。 内部目录用于在此存储库内的多个程序之间共享的包,但不打算在此存储库之外使用。 Web 目录包含由 Web 服务器提供的静态文件和模板。

与服务交互

我们的 Go Web 服务器就位后,该服务可能会或可能不会附带数据库。 如果存在,我们希望从其他服务访问它。 htmx 方法自然会跳过构建前端使用的 RESTful JSON API。 然而,我们确实利用 Go ent 并首先编写我们的 Go 模型,使用 entoas 和 ogent 生成一个完全连接的 CRUD API。 我们之前在 Go 中基于模型的生成微服务的文章中谈到了这种注释驱动的方法。

然后我们安装代理 API 并使用中间件保护它,例如 /api 路由前缀,我们可以与我们的服务数据库进行交互,而无需额外的工作。 这允许在可能使用或操作 Web 服务数据的前端和后端服务之间清楚地分离关注点。

结论——林迪效应的作用

林迪效应以纽约市的林迪餐厅命名,百老汇演出的未来受欢迎程度与其当前的演出长度直接相关,其应用范围远远超出了单纯的演出。 它成为技术领域的一个令人心酸的观察,强调了经过验证的方法的可靠性和持久性。

通过 htmx 和 Go 的结合,这一理念得以体现,回归到 Web 开发的本质基础。 通过强调服务器端状态管理、在线 HTML 以及让人想起 jQuery 和 PHP 等早期 Web 技术的简单性,htmx 和 Go 不仅拥抱 Lindy 效应中蕴藏的智慧,而且在经典原则和当代技术之间取得了平衡。 这种新旧结合、经过测试和创新的结合,为现代 Web 应用程序提供了强大的解决方案,并设定了一个立足于历史但面向未来发展的方向,引导开发人员走向既创新又植根于经过时间考验的实践的未来。