标签 Gopher 下的文章

htmx:Gopher走向全栈的完美搭档?

本文永久链接 – https://tonybai.com/2024/09/20/htmx-gopher-perfect-partner-for-full-stack

在传统的Web开发领域,前端和后端开发通常被明确划分。前端主要负责用户界面的交互和视觉呈现,运用HTML、CSS和JavaScript等技术;后端则专注于服务器逻辑、数据库管理和核心功能实现,常用Go、Java、PHP、Ruby等语言。

然而,随着技术的不断演进和开发流程的优化,全栈开发逐渐成为一种趋势。全栈开发者能够在项目的不同阶段灵活转换角色,有效降低沟通成本和缩短开发周期。他们对系统的整体架构和工作原理有更深入的理解,从而能更高效地解决问题。此外,全栈技能也使得开发者在就业市场上更具竞争力,能够承担更多样化的职责。

尽管如此,对于许多专注后端的工程师(包括众多Gopher)来说,前端开发仍然是一个不小的挑战。它不仅要求熟悉JavaScript等语言,还需要理解复杂的前端框架和工具链。这使得不少后端开发者在面对全栈开发时感到力不从心。

幸运的是,技术的进步为我们提供了更简单、高效的开发途径。Go语言以其简洁和高效著称,而htmx库则通过HTML属性实现丰富的前端交互。将两者结合,开发者可以在无需深入学习JavaScript的情况下,轻松实现全栈开发。这种组合不仅能够显著提升开发效率,还能充分利用服务器端渲染(SSR)的优势,在性能和用户体验方面取得显著提升。

那么,htmx是否真的是Gopher走向全栈的完美搭档呢?在本文中,我们就将探讨一下这个问题,介绍一下htmx的核心理念和工作原理,并结合代码示例和使用场景,详细分析Go和htmx如何协同工作。至于Go+htmx究竟有多能打,相信在本文最后,你会得出自己的评价!

1. htmx:为简化前端开发而生

传统的前端开发通常依赖于JavaScript框架,例如React、Vue或Angular。这些框架虽然功能强大,但往往伴随着高昂的学习成本和复杂的开发流程。对于那些主要从事后端开发的程序员来说,学习和掌握这些框架不仅需要花费大量时间,还需要深入理解前端生态系统中的各种概念和工具链。这种学习曲线和开发复杂性成为了许多后端开发者的阻碍,同时也成为了阻碍Go开发者迈向全栈的绊脚石。

htmx的诞生正是为了简化前端开发,特别是对于那些不愿意或没有时间深入学习JavaScript的开发者。

htmx的核心理念是通过扩展HTML,使其具备更强大的功能,从而减少对JavaScript的依赖。它遵循了”HTML优先”的设计原则,允许开发者直接在HTML元素中添加特殊的属性来定义与服务器交互的行为,比如动态加载、表单处理、局部刷新等,从而实现动态交互,而无需编写任何JavaScript代码。可以说,htmx的出现为后端开发者(包括Gopher)提供了一种新的选择,使得Web应用的开发变得更加直观和简便。

不过,htmx自身却是一个轻量级的JavaScript库,这与Go的设计哲学有些“异曲同工”,即简单留给大家,复杂留给自己。作为js库,它提供了一组简洁而强大的API,通过设置HTML属性,开发者就可以实现多种交互功能。以下是htmx的一些核心特性:

  • 请求类型(hx-get、hx-post、hx-put和hx-delete)

通过指定请求类型,htmx可以在用户触发事件时向服务器发送请求,并处理响应。

  • 目标更新(hx-target)

支持指定服务器响应数据要插入的DOM元素,支持部分页面更新而无需刷新整个页面。

  • 触发条件(hx-trigger)

支持定义请求触发的条件,例如点击、鼠标悬停、表单提交等事件。

  • 交换方式(hx-swap)

支持定义响应内容插入DOM的方式,可以选择替换、插入、删除等操作。

这些API的设计目标是让开发者能够通过声明式的方式来实现前端逻辑,而不必依赖JavaScript代码,以简化开发过程。

由于几乎无需后端开发者写JavaScript,HTMX很容易被认为是SSR(服务器端渲染)的一种实现。它们看似很相似,但它们的思路并不完全一致。SSR的渲染过程是在服务器上完成的,服务器生成整个HTML页面的内容,并将其发送给客户端。客户端接收到完整的HTML直接展示给用户。这也使得SSR通常可以提供更快的初始加载体验,因为用户可以立即看到页面内容,而不必等待JavaScript加载和执行。此外,由于HTML内容在服务器上渲染,搜索引擎更容易抓取和索引内容。

而HTMX的大部分渲染也是在服务端完成的,但它支持在客户端通过AJAX请求动态更新页面的某些部分,而不需要重新加载整个页面,只是它是通过简单的HTML属性(外加自身js)实现这些功能的,而无需用户手工写JavaScript实现。HTMX还使得页面能够更具交互性,用户可以在不离开当前页面的情况下与应用程序进行交互。

因此,htmx可以视为一种结合SSR和局部CSR(客户端渲染)的技术,它让你通过服务器端渲染HTML,同时在客户端实现灵活的动态交互功能。这使得开发者能够在SSR提供的性能优势和SEO友好性基础上,提升用户体验而不必依赖完整的客户端框架。

虽然保留了CSR,但与传统的JavaScript框架(如 React、Vue、Angular)相比,htmx非常轻量,体积非常小,以撰写本文时的最新2.0.2版本htmx为例,它的js包大小如下,压缩版才10几k:

此外,传统框架虽然功能强大,但往往需要复杂的配置和较高的学习成本,尤其对于习惯后端开发的开发者来说,更是如此。而使用HTMX,只需掌握HTML和少量的htmx API即可开始开发,适合后端开发者快速上手。

说了这么多htmx的优点,那基于htmx的开发究竟是怎样的呢?下面我们就以htmx的几个核心特性为例,看看如何基于htmx开发简单web应用。

2. htmx的基本用法

在前面我们了解了htmx的几个核心特性,包括请求类型、目标更新等。下面我们就针对这些核心特性,举几个例子,大家初步了解一下基于htmx的开发web应用的流程。

我们先从请求类型开始,了解一下基于htmx如何向后端发起POST/GET/PUT/DELETE等请求。

2.1 示例1:请求类型

在这第一个示例中,我们使用Go语言创建一个简单的服务器,并使用htmx在前端实现不同类型的请求。下面是我们定义的html模板,其中包含了htmx的自定义属性:

// go-htmx/demo1/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Go Example</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .row {
            margin-bottom: 10px;
        }
        button {
            width: 120px;
            margin-right: 10px;
        }
        .result {
            display: inline-block;
            width: 300px;
            border: 1px solid #ccc;
            padding: 5px;
            min-height: 20px;
        }
    </style>
</head>
<body>
    <h1>HTMX Request Types Demo</h1>

    <div class="row">
        <button hx-get="/api/get" hx-target="#get-result">GET Request</button>
        <span id="get-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-post="/api/post" hx-target="#post-result">POST Request</button>
        <span id="post-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-put="/api/put" hx-target="#put-result">PUT Request</button>
        <span id="put-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-delete="/api/delete" hx-target="#delete-result">DELETE Request</button>
        <span id="delete-result" class="result"></span>
    </div>
</body>
</html>

在这个HTML模板文件中包含了四个按钮,每个按钮对应一种http请求类型(GET、POST、PUT、DELETE),具体的实现方式是每个按钮都使用了相应的htmx属性(hx-get、hx-post、hx-put、hx-delete)来指定请求类型和目标URL。此外,所有按钮都使用了hx-target来设置服务器的响应将被显示的元素id。以get请求button为例,响应的值将被放到id为get-result的span中。

对应的Go后端程序就非常简单了,下面是代码摘录:

// go-htmx/demo1/main.go

package main

import (
    "fmt"
    "net/http"
    "os"
    "path/filepath"
)

func main() {
    http.HandleFunc("/", handleIndex)
    http.HandleFunc("/api/get", handleGet)
    http.HandleFunc("/api/post", handlePost)
    http.HandleFunc("/api/put", handlePut)
    http.HandleFunc("/api/delete", handleDelete)

    fmt.Println("Server is running on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    currentDir, _ := os.Getwd()
    filePath := filepath.Join(currentDir, "index.html")
    http.ServeFile(w, r, filePath)
}

func handleGet(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Received a GET request")
}

func handlePost(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Received a POST request")
}

func handlePut(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Received a PUT request")
}

func handleDelete(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Received a DELETE request")
}

运行该server后,用浏览器打开localhost:8080,我们将看到下面页面:

逐一点击各个Button,htmx会将从服务器收到的响应内容放入对应的span中:

2.2 示例2:触发条件

在这个示例2中,我们将基于htmx实现对各种触发条件的响应与处理,htmx提供了hx-trigger属性来应对这些不同的事件触发,包括点击、鼠标悬停和表单提交等。我们看下面html模板代码:

// go-htmx/demo2/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Trigger Demo</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .demo-section {
            margin-bottom: 20px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .result {
            margin-top: 10px;
            padding: 5px;
            background-color: #f0f0f0;
            min-height: 20px;
        }
    </style>
</head>
<body>
    <h1>HTMX Trigger Demo</h1>

    <div class="demo-section">
        <h2>Click Trigger</h2>
        <button hx-get="/api/click" hx-trigger="click" hx-target="#click-result">
            Click me
        </button>
        <div id="click-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Hover Trigger</h2>
        <div hx-get="/api/hover" hx-trigger="mouseenter" hx-target="#hover-result" style="display: inline-block; padding: 10px; background-color: #e0e0e0;">
            Hover over me
        </div>
        <div id="hover-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Form Submit Trigger</h2>
        <form hx-post="/api/submit" hx-trigger="submit" hx-target="#form-result">
            <input type="text" name="message" placeholder="Enter a message">
            <button type="submit">Submit</button>
        </form>
        <div id="form-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Custom Delay Trigger</h2>
        <input type="text" name="search"
               hx-get="/api/search"
               hx-trigger="keyup changed delay:500ms"
               hx-target="#search-result"
               placeholder="Type to search...">
        <div id="search-result" class="result"></div>
    </div>
</body>
</html>

通过模板代码,我们可以看到hx-trigger 的多种用法:

  • 点击触发(Click Trigger):使用 hx-trigger=”click”,当按钮被点击时触发请求。
  • 悬停触发(Hover Trigger):使用 hx-trigger=”mouseenter”,当鼠标悬停在元素上时触发请求。
  • 表单提交触发(Form Submit Trigger):使用 hx-trigger=”submit”,当表单提交时触发请求。
  • 自定义延迟触发(Custom Delay Trigger):使用 hx-trigger=”keyup changed delay:500ms”,在输入框中输入时,等待500毫秒后触发请求。这对于实现搜索建议等功能很有用。

下面是该示例的后端go代码,逻辑非常简单,针对每个事件调用,简单返回一个字符串:

// go-htmx/demo2/main.go

... ...

func main() {
    http.HandleFunc("/", handleIndex)
    http.HandleFunc("/api/click", handleClick)
    http.HandleFunc("/api/hover", handleHover)
    http.HandleFunc("/api/submit", handleSubmit)
    http.HandleFunc("/api/search", handleSearch)

    fmt.Println("Server is running on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    currentDir, _ := os.Getwd()
    filePath := filepath.Join(currentDir, "index.html")
    http.ServeFile(w, r, filePath)
}

func handleClick(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Button was clicked!")
}

func handleHover(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "You hovered over the element!")
}

func handleSubmit(w http.ResponseWriter, r *http.Request) {
    message := r.FormValue("message")
    fmt.Fprintf(w, "Form submitted with message: %s", message)
}

func handleSearch(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("search")
    fmt.Fprintf(w, "Searching for: %s", query)
}

运行该server后,用浏览器打开localhost:8080,我们将看到下面页面:

接下来,我们可以尝试点击按钮、悬停在元素上、提交表单和在搜索框中输入,看看每个操作如何触发HTMX 请求并更新页面的相应部分,下面是触发后的结果:

2.3 示例3:交换方式

在示例3中,我们将展示如何使用htmx的hx-swap属性实现不同的内容更新方式,包括替换、插入和删除操作,其中还包含多种替换方式。下面是html模板:

// go-htmx/demo3/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Swap Demo - All Attributes</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .demo-section {
            margin-bottom: 20px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .content-box {
            margin-top: 10px;
            padding: 10px;
            border: 1px solid #ddd;
            min-height: 50px;
        }
        .item {
            margin: 5px 0;
            padding: 5px;
            background-color: #f0f0f0;
        }
    </style>
</head>
<body>
    <h1>HTMX Swap Demo - All Attributes</h1>

    <div class="demo-section">
        <h2>innerHTML (Default)</h2>
        <button hx-get="/api/swap/inner" hx-target="#inner-content">
            Swap innerHTML
        </button>
        <div id="inner-content" class="content-box">
            <p>This is the original content. The entire inner HTML will be replaced.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>outerHTML</h2>
        <button hx-get="/api/swap/outer" hx-target="#outer-content" hx-swap="outerHTML">
            Swap outerHTML
        </button>
        <div id="outer-content" class="content-box">
            <p>This entire div will be replaced, including its container.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>textContent</h2>
        <button hx-get="/api/swap/text" hx-target="#text-content" hx-swap="textContent">
            Swap textContent
        </button>
        <div id="text-content" class="content-box">
            <p>This <strong>text</strong> will be replaced, but HTML tags will be treated as plain text.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>beforebegin</h2>
        <button hx-get="/api/swap/before" hx-target="#before-content" hx-swap="beforebegin">
            Insert before
        </button>
        <div id="before-content" class="content-box">
            <p>New content will be inserted before this div.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>afterbegin</h2>
        <button hx-get="/api/swap/afterbegin" hx-target="#afterbegin-content" hx-swap="afterbegin">
            Insert at beginning
        </button>
        <div id="afterbegin-content" class="content-box">
            <p>New content will be inserted at the beginning of this div, before this paragraph.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>beforeend</h2>
        <button hx-get="/api/swap/beforeend" hx-target="#beforeend-content" hx-swap="beforeend">
            Insert at end
        </button>
        <div id="beforeend-content" class="content-box">
            <p>New content will be inserted at the end of this div, after this paragraph.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>afterend</h2>
        <button hx-get="/api/swap/after" hx-target="#after-content" hx-swap="afterend">
            Insert after
        </button>
        <div id="after-content" class="content-box">
            <p>New content will be inserted after this div.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>delete</h2>
        <button hx-get="/api/swap/delete" hx-target="#delete-content" hx-swap="delete">
            Delete content
        </button>
        <div id="delete-content" class="content-box">
            <p>This content will be deleted when the button is clicked.</p>
        </div>
    </div>
</body>
</html>

这个示例略复杂,它涵盖了hx-swap的所有属性:

  • innerHTML(默认):替换目标元素的内部HTML。
  • outerHTML:用响应替换整个目标元素。
  • textContent:替换目标元素的文本内容,不解析HTML。
  • beforebegin:在目标元素之前插入响应。
  • afterbegin:在目标元素的第一个子元素之前插入响应。
  • beforeend:在目标元素的最后一个子元素之后插入响应。
  • afterend:在目标元素之后插入响应。
  • delete:删除目标元素,忽略响应内容。

为了配合这个演示,我们编写了一个简单的go后端程序:

// go-htmx/demo3/main.go
... ...

func main() {
    http.HandleFunc("/", handleIndex)
    http.HandleFunc("/api/swap/inner", handleInner)
    http.HandleFunc("/api/swap/outer", handleOuter)
    http.HandleFunc("/api/swap/text", handleText)
    http.HandleFunc("/api/swap/before", handleBefore)
    http.HandleFunc("/api/swap/afterbegin", handleAfterBegin)
    http.HandleFunc("/api/swap/beforeend", handleBeforeEnd)
    http.HandleFunc("/api/swap/after", handleAfter)
    http.HandleFunc("/api/swap/delete", handleDelete)

    fmt.Println("Server is running on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    currentDir, _ := os.Getwd()
    filePath := filepath.Join(currentDir, "index.html")
    http.ServeFile(w, r, filePath)
}

func handleInner(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<p>This content replaced the inner HTML at %s</p>", time.Now().Format(time.RFC1123))
}

func handleOuter(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<div id=\"outer-content\" class=\"content-box\"><p>This div replaced the entire outer HTML at %s</p></div>", time.Now().Format(time.RFC1123))
}

func handleText(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "This replaced the text content at %s. <strong>HTML tags</strong> are not parsed.", time.Now().Format(time.RFC1123))
}

func handleBefore(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<p class=\"item\">This content was inserted before the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleAfterBegin(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<p class=\"item\">This content was inserted at the beginning of the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleBeforeEnd(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<p class=\"item\">This content was inserted at the end of the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleAfter(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "<p class=\"item\">This content was inserted after the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleDelete(w http.ResponseWriter, r *http.Request) {
    // For delete, we don't need to send any content back
    w.WriteHeader(http.StatusOK)
}

运行该server后,用浏览器打开localhost:8080,你应该能看到一个包含八个不同部分的页面,每个部分演示了hx-swap的一种属性。你可以点击每个部分的按钮,观察内容如何以不同的方式更新或变化。这个综合示例展示了hx-swap的强大功能和灵活性,让你可以精确控制如何更新页面的不同部分。下面是你可以看到的效果呈现:

以上就是htmx核心属性的用法,基于这些核心属性,我们可以实现更多更为复杂和高级的场景功能。在下一节,我们会举两个复杂一些的示例,供大家参考。

3. 高级用法

3.1 基于token的身份认证

在使用HTMX作为前端与后端进行交互时,通常会涉及到用户身份认证鉴权,其中一个常见场景是通过前端获取的Token(如JWT)去访问后端的受保护的API。下面我们看看使用HTMX该如何实现这一常见功能。

下面是网站首页的html模板,包含用户登录的Form:

// go-htmx/demo4/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Auth Example - Login</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <script>
        htmx.on('htmx:afterRequest', function(event) {
            if (event.detail.elt.id === 'login-form') {
                var xhr = event.detail.xhr;
                if (xhr.status === 200) {
                    var response = JSON.parse(xhr.responseText);
                    if (response.success) {
                        localStorage.setItem('auth_token', response.token);
                        window.location.href = response.redirect;
                    } else {
                        document.getElementById('message').innerText = response.message;
                    }
                } else {
                    document.getElementById('message').innerText = "An error occurred. Please try again.";
                }
            }
        });
    </script>
</head>
<body>
    <h1>HTMX Auth Example - Login</h1>
    <form id="login-form" hx-post="/login" hx-target="#message">
        <label for="username">Username:</label>
        <input type="text" id="username" name="username" required><br><br>
        <label for="password">Password:</label>
        <input type="password" id="password" name="password" required><br><br>
        <button type="submit">Login</button>
    </form>
    <div id="message"></div>
</body>
</html>

这个代码片段结合了HTMX和JavaScript,处理登录表单的提交,以及登录成功后将令牌(Token)存储到浏览器的本地存储中,并在登录成功后重定向到dashboard页面。

这段代码监听了HTMX的htmx:afterRequest事件。此事件在HTMX请求完成(即请求已经发出并接收到响应)后触发,event.detail.elt表示触发事件的元素。代码检查该元素的id是否为login-form,确认这次请求来自登录表单。如果是其他表单或元素触发的请求,它将忽略。如果服务器的身份验证成功,它以json格式返回token和重定向地址,前端会解析响应,并将Token存储到本地存储,然后自动跳转到登录后的dashboard页面。

下面是dashboard页面的html模板:

// go-htmx/demo4/dashboard.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Auth Example - Dashboard</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            htmx.on('htmx:configRequest', function(event) {
                var token = localStorage.getItem('auth_token');
                if (token) {
                    event.detail.headers['Authorization'] = 'Bearer ' + token;
                }
            });
        });
    </script>
</head>
<body>
    <h1>Welcome to Your Dashboard</h1>
    <button hx-get="/protected" hx-target="#protected-content">Access Protected Content</button>
    <div id="protected-content"></div>
</body>
</html>

这段代码最值得关注的地方就是在后续发出的Request中自动加入之前获取到的token。这里是使用了htmx:configRequest事件实现的。监听HTMX的htmx:configRequest事件,该事件在HTMX发出请求之前触发,它允许你修改即将发出的请求。这里的configRequest的处理逻辑是:如果Token存在,将它添加到即将发出的请求的Authorization头中,并格式化为标准的Bearer Token形式(即 “Authorization: Bearer your_token_here”)。这样,后端在处理请求时可以从请求头中提取出Token,用于验证用户身份。

整个示例的后端go程序如下:

// go-htmx/demo4/main.go
package main

import (
    "encoding/json"
    "fmt"
    "html/template"
    "net/http"
    "strings"
    "sync"

    "github.com/google/uuid"
)

var (
    tokens   = make(map[string]bool)
    tokensMu sync.Mutex
)

type LoginResponse struct {
    Success  bool   `json:"success"`
    Token    string `json:"token,omitempty"`
    Message  string `json:"message"`
    Redirect string `json:"redirect,omitempty"`
}

func main() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/dashboard", dashboardHandler)
    http.HandleFunc("/protected", protectedHandler)
    fmt.Println("Server is running on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    http.ServeFile(w, r, "index.html")
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    username := r.FormValue("username")
    password := r.FormValue("password")

    response := LoginResponse{}

    if username == "admin" && password == "password" {
        token := uuid.New().String()

        tokensMu.Lock()
        tokens[token] = true
        tokensMu.Unlock()

        response.Success = true
        response.Token = token
        response.Message = "Login successful"
        response.Redirect = "/dashboard"
    } else {
        response.Success = false
        response.Message = "Login failed. Please check your credentials and try again."
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("dashboard.html")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    tmpl.Execute(w, nil)
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
    authHeader := r.Header.Get("Authorization")
    if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    token := strings.TrimPrefix(authHeader, "Bearer ")

    tokensMu.Lock()
    valid := tokens[token]
    tokensMu.Unlock()

    if !valid {
        http.Error(w, "Invalid token", http.StatusUnauthorized)
        return
    }

    fmt.Fprintf(w, `<div>
        <h2>Protected Content</h2>
        <p>This is sensitive information only for authenticated users.</p>
        <p>Your token: %s</p>
    </div>`, token)
}

注:这里仅是示例,因此只是用了一个uuid作为token,没有使用通用的jwt。

运行程序,登录并在Dashboard中点击访问protected data,我们会看到下面图中呈现的效果:

下面我们再来看一个略复杂一些的示例,这次我们基于htmx来实现SSE(Server-Sent Event),即服务端事件。

3.2 SSE

Server-Sent Events (SSE) 是一种轻量级的实时通信技术,允许服务器通过HTTP协议持续向客户端推送更新数据。与WebSocket不同,SSE是单向通信,服务器可以推送数据到客户端,但客户端无法通过同一连接向服务器发送数据。这种机制非常适合需要频繁更新数据但对双向通信要求不高的场景,如股票价格、新闻推送、社交媒体通知等。

htmx对SSE的支持是通过扩展包实现的,下面就是本示例的index.html模板代码:

// go-htmx/demo5/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX SSE Notifications</title>
    <script src="https://unpkg.com/htmx.org@1.9.6"></script>
    <script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
</head>
<body>
    <h1>实时通知</h1>
    <div hx-ext="sse" sse-connect="/events" sse-swap="message">
        <ul id="notifications">
            <!-- 通知将在这里动态添加 -->
        </ul>
    </div>

    <script>
        htmx.on("htmx:sseMessage", function(event) {
            var ul = document.getElementById("notifications");
            var li = document.createElement("li");
            li.innerHTML = event.detail.message;
            ul.insertBefore(li, ul.firstChild);
        });
    </script>
</body>
</html>

这个代码片段通过HTMX和Server-Sent Events (SSE) 实现了实时通知的功能。它会动态将服务器端发送的通知添加到页面的通知列表中。具体来说:

  • hx-ext=”sse”:启用了HTMX的SSE扩展,用于处理 Server-Sent Events(服务器发送事件),使得浏览器可以保持与服务器的长连接,实时接收更新。
  • sse-connect=”/events”:指定了SSE连接的URL。浏览器会向/events这个路径发起SSE连接,服务器可以通过这个连接持续向客户端推送消息。
  • sse-swap=”message”:指示HTMX在收到SSE消息时触发事件处理,消息内容将使用JavaScript进行处理而不是自动更新HTML。
  • htmx.on(“htmx:sseMessage”, function(event)):监听HTMX的htmx:sseMessage事件,每当服务器通过SSE推送新消息时,该事件会触发。event.detail.message包含从服务器接收到的消息内容。
  • var ul = document.getElementById(“notifications”);:获取页面上ID为notifications的\<ul>元素,表示存放通知的容器。收到的通知通过htmx:sseMessage事件处理,将消息动态添加到通知列表中,并显示在网页上。

下面是示例对应的Go后端程序:

// go-htmx/demo5/main.go

func main() {
    http.HandleFunc("/", serveHTML)
    http.HandleFunc("/events", handleSSE)

    fmt.Println("Server starting on http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func serveHTML(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "index.html")
}

func handleSSE(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
        return
    }

    notificationCount := 1

    for {
        notification := fmt.Sprintf("新通知 #%d: %s", notificationCount, time.Now().Format("15:04:05"))
        fmt.Fprintf(w, "data: <li>%s</li>\n\n", notification)
        flusher.Flush()

        notificationCount++
        time.Sleep(3 * time.Second)

        if r.Context().Err() != nil {
            return
        }
    }
}

运行程序,打开浏览器访问localhost:8080,在加载的页面中会自动建立sse连接,页面上的通知消息区便会如下面这样每3秒一变化:

不过这个示例的程序有个“瑕疵”,那就是如果将htmx的版本从1.9.6换作最新的2.0.2,那么示例就将不工作了,翻看了一下htmx文档,应该是sseMessage这个htmx扩展属性被删除了。

如果要让示例更具通用性,可以将index.html换成下面的代码:

// go-htmx/demo6/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX SSE Notifications</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        #notification {
            padding: 10px;
            border: 1px solid #ccc;
            background-color: #f8f8f8;
            margin-top: 20px;
        }
    </style>
</head>
<body>
    <h1>实时通知</h1>
    <div id="notification-container">
        <div id="notification">等待通知...</div>
    </div>

    <script>
        document.body.addEventListener('htmx:load', function() {
            var notificationDiv = document.getElementById('notification');
            var evtSource = new EventSource("/events");

            evtSource.onmessage = function(event) {
                notificationDiv.textContent = event.data;
            };

            evtSource.onerror = function(err) {
                console.error("EventSource failed:", err);
            };
        });
    </script>
</body>
</html>

当然这个代码更多使用js来实现事件的处理。

4. 小结

本文探讨了Go与htmx这一全栈组合的简洁优势。对于后端开发者而言,这一组合提供了一种无需深入掌握前端技术即可开发现代Web应用的高效途径。

然而,从两个高级示例中可以看出,JavaScript代码仍难以完全避免,虽然数量不多,但在稍复杂的场景下依然不可或缺。

因此,htmx目前更多被中小型团队或个人开发者所青睐。这类开发者通常没有专职的前端人员,但希望快速构建并部署功能完善的Web应用。

综上所述,在我这个对前端开发了解甚少的Go开发者看来,Go与htmx的组合的确降低了开发门槛,同时提供了性能和SEO优势,使其成为现代Web开发中值得推荐的技术栈之一。不过,对于复杂的Web应用,开发者可能需要结合htmx和JavaScript,或更可能直接采用vue、react或angular等框架。

目前Go社区对htmx的支持也越来越多,比如html模板引擎templ可以用于生成htmx模板,当然也有专有的htmx框架,比如:ghtmxpagodago-htmx等。

本文涉及的源码可以在这里下载。

5. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Gopher的Rust第一课:Rust代码组织

本文永久链接 – https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code

在上一章的讲解中,我们编写了第一个Rust示例程序”hello, world”,并给出了rustc版和cargo版本。在真实开发中,我们都会使用cargo来创建和管理Rust包。不过,Hello, world示例非常简单,仅仅由一个Rust源码文件组成,而且所有源码文件都在同一个目录中。但真实世界中的实用Rust程序,无论是公司商业项目,还是一些知名的开源项目,甚至是一些稍复杂一些的供教学使用的示例程序,它们通常可不会这么简单,都有着复杂的代码结构。

Rust初学者在阅读这些项目源码时便仿佛进入了迷宫,不知道该走哪条(阅读代码的)路径,不知道每个目录代表的含义,也不知道自己想看的源码究竟在哪个目录下。但目前市面上的Rust入门教程大多没有重视初学者的这一问题,要么没有对Rust项目代码组织结构进行针对性的讲解,要么是将讲解放到书籍的后面章节。

根据我个人的学习经验来看,理解一个实用Rust项目的代码组织结构越早,对后续的Rust学习越有益处。同时,掌握Rust项目的代码组织结构也是Rust开发者走向编写复杂Rust程序的必经的一步。并且,初学者在了解项目的代码组织结构后,便可以自主阅读一些复杂的Rust项目的源码,可提高Rust学习的效率,提升学习效果。因此,我决定在介绍Rust基础语法之前先在本章中系统地介绍Rust的代码组织结构,以满足很多Rust初学者的述求。

但在介绍Rust代码组织结构之前,我们需要先来系统说明一下Rust代码组织结构中的几个重要概念,它们是了解Rust项目代码组织结构的前提。

4.1 回顾Go代码组织

Go项目代码组织由module和package两级组成。通常来说,每个Go repo就是一个module,由repo根目录下的go.mod定义,go.mod文件所在目录也被称为module root。go.mod中典型内容如下:

// go.mod
module github.com/user/mymodule[/vN]

go 1.22.1

... ...

go.mod中的module directive一行后面的github.com/user/mymodule/[vN]是module path。module path一来可以反映该module的具体网络位置,同时也是该module下面的Go package导入(import)路径的组成部分。module root下的子目录中通常存放着该module下面的Go package,比如module root/foo目录下存放的Go包的导入路径为github.com/user/mymodule[/vN]/foo。

Go package是Go的编译单元,也是功能单元,代码内外部导入和引用的单位也都是包。而go module是后加入的,更多用于管理包的版本(一个module下的所有包都统一进行版本管理)以及构建时第三方依赖和版本的管理。

更多关于Go module和package管理以及Go项目布局的内容,可以详见我的极客时间《Go语言第一课》专栏。

个人认为Go的module和package的两级管理还是很好理解和管理的,在这方面Rust的代码组织形式又是怎样的呢?接下来,我们就来正式看看Rust的代码组织。

4.2 rustc-only的Rust项目

Rust是系统编程语言,这让我想起了当初在Go成为我个人主力语言之前使用C/C++进行开发的岁月。C/C++是没有像go或Rust的cargo那样的统一的包依赖管理器和项目构建管理工具的。编译器(如gcc等)是核心工具,而项目构建管理则经常由其他工具负责,如Makefile、CMake,或者是Google的Bazel等。在Windows上开发应用的,则往往使用微软或其他开发者工具公司提供的IDE,如当年炙手可热的Visual Studio系列。

下面表格展示了各语言的编译器/链接器和构建管理工具的关系:

像cargo、go这样的“一站式”工具链都旨在为开发者提供体验更为友好的交互接口的,在幕后,它们仍然依赖于底层的编译器和链接器(如rustc和go tool compile/link)来执行实际的代码编译。

不过,像cargo这样的高级工具也给开发人员带来了额外的抽象,或是叫“掩盖”了一些真相,这有时候让人看不清构建过程的本质,比如:很多Gopher用了很多年Go,但却不知道go tool compile/link的存在。

本着只有in hard way,才能看到和抓住本质的思路,以及之前学习用系统编程语言C/C++时经验,这里我们先来看一些rustc-only的Rust项目。Rustc-only的Rust项目是指不使用Cargo创建和管理的Rust项目,而是直接使用rustc编译器来编译和构建项目。这意味着开发者需要编写自己的构建脚本,例如使用Makefile或其他构建工具来管理项目的构建过程。

不过,请注意:这类项目极少用于生产,即便是那些不需要复杂的依赖管理的小型项目。这里使用rustc-only的Rust项目仅仅是为了学习和了解Rustc编译器的主要功能机制以及Rust语言在代码组织上的一些抽象,比如module等。

下面我们就从最简单的rustc-only项目开始,先来看看只有一个Rust源文件且无其他依赖项的“最简项目”。

4.2.1 单文件项目

所谓单文件项目,即只有一个Rust源文件,例如前面章节中的hello_world.rs,这种项目可以直接使用rustc编译器来编译和运行:

// rust-guide-for-gopher/organizing-rust-code/rustc-only/single/hello-world/hello_world.rs
fn main() {
    println!("Hello, world!");
}

对于顶层带有main函数的源文件,rustc会默认将其视为binary crate类型的源文件,并将其编译为可执行二进制文件hello_world。

我们当然也可以强制的让rustc将该源文件视为library crate类型的源文件,并将其编译为其他类型的crate输出文件,rustc支持多种crate type:

      --crate-type [bin|lib|rlib|dylib|cdylib|staticlib|proc-macro]
                        Comma separated list of types of crates
                        for the compiler to emit

rustc的文档中,各种crate类型的含义如下:

lib — Generates a library kind preferred by the compiler, currently defaults to rlib.
rlib — A Rust static library.
staticlib — A native static library.
dylib — A Rust dynamic library.
cdylib — A native dynamic library.
bin — A runnable executable program.
proc-macro — Generates a format suitable for a procedural macro library that may be loaded by the compiler.

不过,如果强制将带有顶层main函数的rust源文件视为lib crate型的,那么rustc将会报warning,提醒你函数main将是死代码,永远不会被用到:

$rustc --crate-type lib hello_world.rs
warning: function `main` is never used
 --> hello_world.rs:1:4
  |
1 | fn main() {
  |    ^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: 1 warning emitted

但即便如此,一个名为libhello_world.rlib的文件依然会被rustc生成出来!(目前–crate-type lib等同于–create-type rlib)。

4.2.2 有外部依赖项的单文件项目

日常开发中,像上面的Hello, World级别的trivial应用是极其少见的,一个non-trivial的Rust应用或多或少都会有一些依赖。这里我们也来看一下如何基于rustc来构建带有外部依赖的单文件项目。下面是一个带有外部依赖的示例:

// organizing-rust-code/rustc-only/single/hello-world-with-deps/hello_world.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}

这个示例程序依赖一个名为rand的crate,要编译该程序,我们必须先手动下载rand的crate源码,并在本地将rand源码编译为示例程序所需的rust library。下面步骤展示了如何下载和构建rand crate:

$curl -LO https://crates.io/api/v1/crates/rand/0.8.5/download
$tar -xvf download

解压后,我们将看到rand-0.8.5这样的一个crate目录,进入该目录,我们执行cargo build来构建rand crate:

$cd rand-0.8.5
$cargo build
... ...
   Finished dev [unoptimized + debuginfo] target(s) in 0.19s

cargo构建出的librand.rlib就在rand-0.8.5/target/debug下。

注:rlib的命名方式:lib+{crate_name}.rlib

接下来,我们就来构建一下依赖rand crate的hello_world.rs:

// 在organizing-rust-code/rustc-only/single/hello-world-with-deps下面执行

$rustc --verbose  -L ./rand-0.8.5/target/debug  --extern rand=librand.rlib hello_world.rs
error[E0463]: can't find crate for `rand_core` which `rand` depends on
 --> hello_world.rs:1:1
  |
1 | extern crate rand;
  | ^^^^^^^^^^^^^^^^^^ can't find crate

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0463`.

我们看到rustc的编译错误提示:无法找到rand crate依赖的rand_core crate!也就是说我们除了向rustc提供hello_world.rs依赖的rand crate之外,还要向rustc提供rand crate的各种依赖!

rand crate的各种依赖在哪里呢?我们在构建rand crate时,cargo build将各种依赖都放在了rand-0.8.5/target/debug/deps目录下了:

$ls -l|grep ".rlib"
-rw-r--r--   1 tonybai  staff     6896  4 29 06:45 libcfg_if-cd6bebf18fb9c234.rlib
-rw-r--r--   1 tonybai  staff   204072  4 29 06:45 libgetrandom-df6a8e95e188fc56.rlib
-rw-r--r--   1 tonybai  staff  1651320  4 29 06:45 liblibc-f16531562d07b476.rlib
-rw-r--r--   1 tonybai  staff   959408  4 29 06:45 libppv_lite86-f1d97d485bc43617.rlib
-rw-r--r--   1 tonybai  staff  1784376  4 29 06:45 librand-9a91ea8db926e840.rlib
-rw-r--r--   1 tonybai  staff   987936  4 29 06:45 librand_chacha-6fe22bd8b3bb228c.rlib
-rw-r--r--   1 tonybai  staff   256768  4 29 06:45 librand_core-fc905f6ca5f8533b.rlib

我们看到其中还包含了librand自身:librand-9a91ea8db926e840.rlib。我们来试试基于deps目录下的这些依赖rlib编译一下:

$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  hello_world.rs

我们用rustc成功编译了带有外部依赖的Rust源码。不过这里要注意的是rustc对直接依赖和间接依赖的crate的定位方式有所不同。

对于直接依赖的crate,比如这里的rand crate,我们需要给出具体路径,它不依赖-L的位置指示,所以这里我们使用了–extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib。

对于间接依赖的crate,比如rand crate依赖的rand_core,rust会结合-L指示的位置以及–extern一起来定位,这里-L指示路径为rand-0.8.5/target/debug/deps,–extern rand_core=librand_core-fc905f6ca5f8533b.rlib,那么rustc就会在rand-0.8.5/target/debug/deps下面搜索librand_core-fc905f6ca5f8533b.rlib是否存在。

我们运行rustc构建出的可执行文件,输出如下:

$./hello_world
Random number: 431751199

4.2.3 有外部依赖的多文件项目

在Go中,如果某个目录下有多个源文件,那么通常这几个源文件均归属于同一个Go包(可能的例外的是*_test.go文件的包名)。但在Rust中,情况就会变得复杂了一些,我们来看一个例子:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps

$tree -F -L 2
.
├── main.rs
├── sub1/
│   ├── bar.rs
│   ├── foo.rs
│   └── mod.rs
└── sub2.rs

在这个示例中,我们看到除了main.rs之外,还有一个sub2.rs以及一个目录sub1,sub1下面还有三个rs文件。我们从main.rs开始,逐一看一下各个源文件的内容:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/main.rs
 1 extern crate rand;
 2 use rand::Rng;
 3
 4 mod sub1;
 5 mod sub2;
 6
 7 mod sub3 {
 8     pub fn func1() {
 9         println!("called {}::func1()", module_path!());
10     }
11     pub fn func2() {
12         self::func1();
13         println!("called {}::func2()", module_path!());
14         super::func1();
15     }
16 }
17
18 fn func1() {
19     println!("called {}::func1()", module_path!());
20 }
21
22 fn main() {
23     println!("current module: {}", module_path!());
24     let mut rng = rand::thread_rng();
25     let num: u32 = rng.gen();
26     println!("Random number: {}", num);
27
28     sub1::func1();
29     sub2::func1();
30     sub3::func2();
31 }

在main.rs中,我们除了看到了第1~2行的对外部rand crate的依赖外,我们还看到了一种新的语法元素:rust module。这里涉及sub1~sub3三个module,我们分别来看一下。先来看一下最直观的、定义在main.rs中的sub3 module。

第7行~第16行的代码定义了一个名为sub3的module,它包含两个函数func1和func2,这两个函数前面的pub关键字表明他们是sub3 module的publish函数,可以被module之外的代码所访问。任何未标记为pub的函数都是私有的,只能在模块内部及其子模块中使用。

在sub3 module的func2函数中,我们调用了self::func1()函数,self指代是模块自身,因此这个self::func1()函数就是sub3的func1函数。而接下来调用的super::func1()调用的语义你大概也能猜到。super指代的是sub3的父模块,而super::func1()就是sub3的父模块中的func1函数。

sub3的父模块就是这个项目的顶层模块,我们在main函数的入口处使用module_path!宏输出了该顶层模块的名称。

和sub3在main.rs中定义不同,sub1和sub2也分别代表了另外两种module的定义方式。

当Rust编译器看到第4行mod sub1后,它会寻找当前目录下是否有名为sub1.rs的源文件或是sub1/mod.rs源文件。在这个示例中,sub1定义在sub1目录下的mod.rs中:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/mod.rs

pub mod bar;
pub mod foo;

pub fn func1() {
    println!("called {}::func1()", module_path!());
    foo::func1();
    bar::func1();
}

我们看到sub1/mod.rs中定义了一个公共函数func1,同时也在最开始处又嵌套定义了bar和foo两个module,并在func1中调用了两个嵌套子module的函数:

bar和foo两个module都是使用单文件module定义的,编译器会在sub1目录下搜寻foo.rs和bar.rs:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/foo.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/bar.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

而main.rs中的sub2也是一个单文件的module,其源码位于顶层目录下的sub2.rs文件中:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub2.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

现在我们来编译和执行一下这个既有外部依赖,又是多文件且有多个module的rustc-only项目:

$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  main.rs 

$./main
current module: main
Random number: 2691905579
called main::sub1::func1()
called main::sub1::foo::func1()
called main::sub1::bar::func1()
called main::sub2::func1()
called main::sub3::func1()
called main::sub3::func2()
called main::func1()

上面示例演示了三种rust module的定义方法:

  1. 直接将定义嵌入在某个rust源文件中:
mod module_name {

}
  1. 通过module_name.rs
  2. 通过module_name/mod.rs

在一个单crate的项目中,通过rust module可以满足项目内部代码组织的需要。

最后,我们再来看一个有多个crate的项目形式。

4.2.4 有多个crate的项目

下面是一个有着多个crate项目的示例:

// organizing-rust-code/rustc-only/workspace

$tree -L 2 -F
.
├── main.rs
├── my_local_crate1/
│   └── lib.rs
└── my_local_crate2/
    └── lib.rs

在这个示例中有三个crate,一个是顶层的binary类型的crate,入口为main.rs,另外两个都是lib类型的crate,入口都在lib.rs中,我们贴一下他们的源码:

// organizing-rust-code/rustc-only/workspace/main.rs
extern crate my_local_crate1;
extern crate my_local_crate2;

fn main() {
    let x = 5;
    let y = my_local_crate1::add_one(x);
    let z = my_local_crate2::multiply_two(y);
    println!("Result: {}", z);
}

// organizing-rust-code/rustc-only/workspace/my_local_crate1/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}

// organizing-rust-code/rustc-only/workspace/my_local_crate2/lib.rs
pub fn multiply_two(x: i32) -> i32 {
    x * 2
}

要构建这个带有三个crate的项目,我们需要首先编译my_local_crate1和my_local_crate2这两个lib crates:

$rustc --crate-type lib --crate-name my_local_crate1 my_local_crate1/lib.rs
$rustc --crate-type lib --crate-name my_local_crate2 my_local_crate2/lib.rs

这会在项目顶层目录下生成两个rlib文件:

$ls  |grep rlib
libmy_local_crate1.rlib
libmy_local_crate2.rlib

之后,我们就可以用之前学到的方法编译binary crate了:

$rustc --extern my_local_crate1=libmy_local_crate1.rlib --extern my_local_crate2=libmy_local_crate2.rlib main.rs

上述的几个rustc-only的rust项目都是hard模式的,即一切都需要手工去做,包括下载crate、编译crate时传入各种路径等。在真正的生产中,Rustacean们是不会这么做的,而是会直接使用cargo对rust项目进行管理。接下来,我们就来系统地看一下使用cargo进行rust项目管理以及对应的rust代码组织形式。

4.3 使用cargo管理的Rust项目

在前面的章节中,我们见识过了:Rust的包管理器Cargo是一个强大的工具,可以帮助我们轻松地管理Rust项目,cargo才是生产类项目的项目构建管理工具标准,它可以让Rustacean避免复杂的手工rustc操作。Cargo提供了许多功能,包括依赖项管理、构建和测试等。不过在这篇文章中,我不会介绍这些功能,而是看看使用cargo管理的Rust项目都有哪些代码组织模式。

Rust项目的代码组织结构可以分为两类:单一package和多个package。

什么是package?在之前的rust-only项目中,我们可从未见到过package!package是cargo引入的一个管理单元概念,它指的是一个独立的Rust项目,包含了源代码、依赖项和配置信息。每个Package都有一个唯一的名称和版本号,用于标识和管理项目。因此,在the cargo book中,cargo也被称为“Rust package manager”,crates.io也被称为“the Rust community’s package registry”。

最能直观体现package存在的就是下面Cargo.toml中的配置了:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]

下面我们就来看看不同类型的rust package的代码组织形式。我们先从单一package形态的项目来开始。

4.3.1 单一package的rust项目

单一package项目是指整个项目只有一个Cargo.toml文件。这种项目还可以进一步分为三类:

  1. 单一Binary Crate
  2. 单一Library Crate
  3. 多个Binary Crate和一个Library Crate

下面我们分别举例来说明一下这三类项目。

4.3.1.1 单一Binary Crate

我们进入organizing-rust-code/cargo/single-package/single-binary-crate,然后执行下面命令来创建一个单一Binary Crate的项目:

$cargo new hello_world --bin
     Created binary (application) `hello_world` package

这个例子我们在之前的章节中也是见过的,它的结构如下:

$tree hello_world
hello_world
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

默认生成的Cargo.toml内容如下:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

使用cargo build即可完成该项目的构建:

$cargo build
   Compiling hello_world v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/single-binary-crate/hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 1.16s

为了更显式地体现这是一个binary crate,我们可以在Cargo.toml增加如下内容:

[[bin]]
name = "hello_world"
path = "src/main.rs"

这不会影响cargo的构建结果!

通过cargo run可以查看构建出的可执行文件的运行结果:

$cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/hello_world`
Hello, world!

接下来,我们再来看看单一library crate的rust项目。

4.3.1.2 单一Library Crate

我们进入organizing-rust-code/cargo/single-package/single-library-crate,然后执行下面命令来创建一个单一Library Crate的项目:

$cargo new my_library --lib
     Created library `my_library` package

创建后的my_library项目的结构如下:

$tree
.
├── Cargo.toml
└── src
    └── lib.rs

默认生成的Cargo.toml如下:

[package]
name = "my_library"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

和binary crate的一样,我们也可以显式指定target:

[lib]
name = "my_library"
path = "src/lib.rs"

注意,这里是[lib]而不是[[lib]],这是因为在一个carge package中最多只能存在一个library crate,但binary crate可以有多个。

接下来,我们就看看一个由多个binary crate和一个library crate混合构成的rust项目。

4.3.1.3 多个Binary Crate和一个Library Crate

我们在organizing-rust-code/cargo/single-package/hybrid-crates下面执行如下命令创建这个多crates混合项目:

$cargo new my_project
     Created binary (application) `my_project` package

上述命令默认创建了一个binary crate的project,我们需要配置一下Cargo.toml,将其改造为多个crates并存的project:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "cmd1"
path = "src/main1.rs"

[[bin]]
name = "cmd2"
path = "src/main2.rs"

[lib]
name = "my_library"
path = "src/lib.rs"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

这里定义了三个crates。两个binary crates: cmd1、cmd2以及一个library crate:my_library。

如果我们执行cargo build,cargo会将三个crate都构建出来:

$cargo build
   Compiling my_project v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/hybrid-crates/my_project)
    Finished dev [unoptimized + debuginfo] target(s) in 0.80s

我们可以在target/debug下找到构建出的crates:cmd1、cmd2和libmy_library.rlib:

$ls target/debug
build/          cmd1.d          cmd2.d          examples/       libmy_library.d
cmd1*           cmd2*           deps/           incremental/        libmy_library.rlib

我们也可以通过cargo分别运行两个binary crate:

$cargo run --bin cmd1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/cmd1`
cmd1

$cargo run --bin cmd2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/cmd2`
cmd2

4.3.1.4 典型的cargo package

在The cargo book中,有一个典型的cargo package的示例:

.
├── Cargo.lock
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── named-executable.rs
│       ├── another-executable.rs
│       └── multi-file-executable/
│           ├── main.rs
│           └── some_module.rs
├── benches/
│   ├── large-input.rs
│   └── multi-file-bench/
│       ├── main.rs
│       └── bench_module.rs
├── examples/
│   ├── simple.rs
│   └── multi-file-example/
│       ├── main.rs
│       └── ex_module.rs
└── tests/
    ├── some-integration-tests.rs
    └── multi-file-test/
        ├── main.rs
        └── test_module.rs

在这样一个典型的项目中:

  • Cargo.toml和Cargo.lock文件存储在包的根目录(包根目录)中。
  • 源代码位于src目录中。
  • 默认的库文件是src/lib.rs。
  • 默认的可执行文件是src/main.rs。
  • 其他可执行文件可以放在src/bin/目录中。
  • 基准测试位于benches目录中。
  • 示例位于examples目录中。
  • 集成测试位于tests目录中。

4.3.2 多package的rust项目

一些中大型的Rust项目都是多package的,比如rust的异步编程事实标准tokio库、刚刚升级为Apache基金会顶级项目的SQL查询引擎datafusion等。以tokio为例,这些项目的顶层Cargo.toml都是这样的:

// https://github.com/tokio-rs/tokio/blob/master/Cargo.toml
[workspace]
resolver = "2"
members = [
  "tokio",
  "tokio-macros",
  "tokio-test",
  "tokio-stream",
  "tokio-util",

  # Internal
  "benches",
  "examples",
  "stress-test",
  "tests-build",
  "tests-integration",
]

[workspace.metadata.spellcheck]
config = "spellcheck.toml"

上面这个Cargo.toml示例与我们在前面见到的Cargo.toml都不一样,它并不包含package配置,其主要的配置为workspace。我们看到workspace的members字段中配置了该项目下的其他package。正是通过这个配置,cargo可以在一个项目里管理和构建多个package。

工作空间(Workspace)是一组一个或多个包(Package)的集合,这些包称为工作空间成员(Workspace Members),它们一起被管理。接下来,我们就来创建一个多package的cargo项目。

4.3.2.1 cargo管理的多package项目

由于cargo并没有提供cargo new my-pakcage –workspace这样的命令行参数,项目的顶层Cargo.toml需要我们手动创建和编辑。

$cd organizing-rust-code/cargo/multi-packages
$mkdir my-workspace
$cd my-workspace
$cargo new package1 --bin
     Created binary (application) `package1` package
$cargo new package2 --lib
     Created library `package2` package
$cargo new package3 --lib
     Created library `package3` package

接下来,我们手工创建和编辑一下项目顶层的Cargo.toml如下:

// organizing-rust-code/cargo/multi-packages/my-workspace/Cargo.toml
[workspace]
resolver = "2"
members = [
    "package1",
    "package2",
    "package3",
]

保存后,我们可以在项目顶层目录下使用下面命令检查整个工作空间(workspace)中的所有包(package),确保它们的代码正确无误,不包含任何编译错误:

$cargo check --workspace
    Checking package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Checking package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
    Checking package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s

在顶层目录执行cargo build,cargo会build工作空间中的所有package:

$cargo build
   Compiling package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
   Compiling package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.64s

构建后,该项目的目录结构变成下面这个样子:

$tree -L 2 -F
.
├── Cargo.lock
├── Cargo.toml
├── package1/
│   ├── Cargo.toml
│   └── src/
├── package2/
│   ├── Cargo.toml
│   └── src/
├── package3/
│   ├── Cargo.toml
│   └── src/
└── target/
    ├── CACHEDIR.TAG
    └── debug/

我们看到该项目下的所有package共享一个共同的 Cargo.lock 文件,该文件位于工作空间的根目录下。并且,所有包共享一个共同的输出目录,默认情况下是工作空间根目录下的一个名为target的目录,该target目录下的布局如下:

$tree -F -L 2 ./target
./target
├── CACHEDIR.TAG
└── debug/
    ├── build/
    ├── deps/
    ├── examples/
    ├── incremental/
    ├── libpackage2.d
    ├── libpackage2.rlib
    ├── libpackage3.d
    ├── libpackage3.rlib
    ├── package1*
    └── package1.d

我们在这下面可以找到所有package的编译输出结果,比如package1、libpackage2.rlib以及libpackage3.rlib。

当然,你也可以指定一个package来构建或运行:

$cargo build -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo build -p package2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/package1`
Hello, world!

4.3.2.2 带有外部依赖和内部依赖的多package项目

我们复制一份my-workspace,改名为my-workspace-with-deps,修改一下package1/src/main.rs,为其增加外部依赖rand crate:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}

接下来,我们需要修改一下package1/Cargo.toml,手工加上对rand crate的依赖配置:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"

保存后,我们执行package1的构建:

$cargo build -p package1
  Downloaded getrandom v0.2.14 (registry `rsproxy`)
  Downloaded libc v0.2.154 (registry `rsproxy`)
  Downloaded 2 crates (780.6 KB) in 1m 07s
   Compiling libc v0.2.154
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.17
   Compiling getrandom v0.2.14
   Compiling rand_core v0.6.4
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 46s

我们看到:cargo会自动下载package1的直接外部依赖以及相关间接依赖。构建成功后,可以执行一下package1的编译结果:

$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/package1`
Random number: 3840180495

接下来,我们再为package1添加内部依赖,比如依赖package2的编译结果:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs

extern crate package2;
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
    let result = package2::add(2, 2);
    println!("result: {}", result);
}

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"
package2 = { path = "../package2" }

我们看到:package1的main.rs依赖package2这个crate中的add函数,我们在package1的Cargo.toml中为package1添加了新依赖package2,由于package2仅仅存放在本地,所以这里我们使用了path方式指定package2的位置。

我们执行一下添加内部依赖后的package1:

$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/package1`
Random number: 2485645524
result: 4

4.4 小结

本文循序渐进地讨论了在Rust项目中如何组织代码的问题,这对于Rust初学者来说尤为有用。

我们首先回顾了Go语言中的代码组织方式,介绍了Go项目代码组织的两个层级:module和package。然后,我们将Rust项目可以分为两种类型:使用rustc编译器的项目和使用Cargo的项目。

对于rustc-only的项目,开发者需要编写自己的构建脚本来管理项目的构建过程。

文章从最简单的单文件rustc-only项目开始介绍,展示了如何使用rustc编译器来编译和运行这种项目,并逐步介绍了带有外部依赖的rustc-only项目以及多文件项目的情况,引出了rust module概念。

rustc-only项目很少用于生产环境,这种方式主要用于学习和了解Rustc编译器的功能机制以及Rust语言的代码组织抽象。

在实际开发中,使用Cargo来创建和管理Rust包是常见的做法。在本章的后半段,我们介绍了使用cargo管理的rust项目的代码组织情况,包括单package项目和多package项目以及如何为项目引入外部和内部依赖。

总体而言,本文旨在帮助初学者理解和掌握Rust项目的代码组织结构,以提高学习效率和学习效果。通过介绍rustc-only项目和cargo管理的项目,读者可以逐步了解Rust代码组织的基本概念和实践方法。

本文涉及的源码可以在这里下载。

4.5 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats