标签 Ruby 下的文章

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

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

通过实例理解API网关的主要功能特性

本文永久链接 – https://tonybai.com/2023/12/03/understand-api-gateway-main-functional-features-by-example

在当今的技术领域中,“下云”的概念正逐渐抬头,像David Heinemeier Hansson(37signals公司的联合创始人, Ruby on Rails的Creator)就直接将公司所有的业务都从公有云搬迁到了自建的数据中心中。虽说大多数企业不会这么“极端”,但随着企业对云原生架构采用的广泛与深入,不可避免地面临着对云服务的依赖。云服务在过去的几年中被广泛应用于构建灵活、可扩展的应用程序和基础设施,为企业提供了许多便利和创新机会。然而,随着业务规模的增长和数据量的增加,云服务的成本也随之上升。企业开始意识到,对云服务的依赖已经成为一个值得重新评估的议题。云服务的开销可能占据了企业可用的预算的相当大部分。为了保持竞争力并更好地控制成本,企业需要寻找方法来减少对云服务的依赖,寻找更经济的解决方案,同时确保仍能获得所需的性能、安全性和可扩展性。

在这样的背景下,我们的关注点是选择一款适宜的API网关,从主流功能特性的角度来评估候选者的支持。API网关作为现代云原生应用架构中的关键组件,扮演着连接前端应用和后端服务的中间层,负责管理、控制和保护API的访问。它的功能特性对于确保API的安全性、可靠性和可扩展性至关重要。

尽管API网关并不是一个新鲜事物了,但对于那些长期依赖于云供应商的服务的人来说,它似乎变得有些“陌生”。因此,本文旨在帮助我们重新理解API网关的主要特性,并获得对API网关选型的能力,以便在停止使用云供应商服务之前,找到一个合适的替代品^_^。

1. API网关回顾

API网关是现代应用架构中的关键组件之一,它的存在简化了应用程序的架构,并为客户端提供一个单一的访问入口,并进行相关的控制、优化和管理。API网关可以帮助企业实现微服务架构、提高系统的可扩展性和安全性,并提供更好的开发者体验和用户体验。

1.1 API网关的演化

随着互联网的快速发展和企业对API的需求不断增长,API网关作为一种关键的中间层技术逐渐崭露头角并经历了一系列的演进和发展。这里将API网关的演进历史粗略分为以下几个阶段:

  • API网关之前的早期阶段

在互联网发展的早期阶段,大多数应用程序都是以单体应用的形式存在。后来随着应用规模的扩大和业务复杂性的增加,单体应用的架构变得不够灵活和可扩展,面向服务架构(Service-Oriented Architecture,SOA)逐渐兴起,企业开始将应用程序拆分成一组独立的服务。这个时期,每个服务都是独立对外暴露API,客户端也是通过这些API直接访问服务,但这会导致一些安全性、运维和扩展性的问题。之后,企业也开始意识到需要一种中间层来管理和控制这种客户端到服务的通信行为,并确保服务的可靠性和安全性,于是开始有了API网关的概念。

  • API网关的兴起

早期的API网关,其主要功能就是单纯的路由和转发。API网关将请求从客户端转发到后端服务,并将后端服务的响应返回给客户端。在这个阶段,API网关的功能非常简单,主要用于解决客户端和后端服务之间的通信问题。

  • API网关的成熟

随着微服务架构的兴起和API应用的不断发展,企业开始将应用程序进一步拆分成更小的、独立部署的微服务。每个对外暴露的微服务都有自己的API,并通过API网关进行统一管理和访问。API网关在微服务架构中的作用变得更加重要,它的功能也逐渐丰富起来了。

在这一阶段,它不仅负责路由和转发请求,API网关还增加了安全和治理的功能,可以满足几个不同领域的微服务需求。比如:API网关可以通过身份认证、授权、访问控制等功能来保护API的安全;通过基于重试、超时、熔断的容错机制等来对API的访问进行治理;通过日志记录、基于指标收集以及Tracing等对API的访问进行观测与监控;支持实时的服务发现等。


API网关(图来自网络)

  • API网关的云原生化

随着云原生技术的发展,如容器化和服务网格(Service Mesh)等,API网关也在不断演进和适应新的环境。在云原生环境中,API网关实现了与容器编排系统(如Kubernetes)和服务网格集成,其自身也可以作为一个云原生服务来部署,以实现更高的可伸缩性、弹性和自动化。同时,新的技术和标准也不断涌现,如GraphQL和gRPC等,API网关也增加了对这些新技术的集成和支持。

1.2 API网关的主要功能特性

从上面的演化历史我们看到:API网关的演进使其从最初简单的请求转发角色,逐渐成为整个API管理和微服务架构中的关键组件。它不仅扮演着API管理层与后端服务层之间的适配器,也是云原生架构中不可或缺的基础设施,使微服务管理更加智能化和自动化。下面是现代API网关承担的主要功能特性,我们后续也会基于这些特性进行示例说明:

  • 请求转发和路由
  • 身份认证和授权
  • 流量控制和限速
  • 高可用与容错处理
  • 监控和可观测性

2. 那些主流的API网关

下面是来自CNCF Landscape中的主流API网关集合(截至2023.11月),图中展示了关于各个网关的一些细节,包括star数量和背后开发的公司或组织:

主流的API网关还有各大公有云提供商的实现,比如:Amazon的API GatewayGoogle Cloud的API Gateway以及上图中的Azure API Management等,但它们不在我们选择范围之内;虽然被CNCF收录,但多数API网关受到的关注并不高,超过1k star的不到30%,这些不是很受关注或dev不是那么active的项目也无法在生产环境担当关键角色;而像APISIXKong这两个受关注很高的网关,它们是建构在Nginx之上实现的,技术栈与我们不契合;而像EMISSARY INGRESS、Gloo等则是完全云原生化或者说是Kubernetes Native的,无法在无Kubernetes的基于VM或裸金属的环境下部署和运行。

好吧,剩下的只有几个Go实现的API Gateway了,在它们之中,我们选择用Tyk API网关来作为后续API功能演示的示例。

注:这并不代表Tyk API网关就要比其他Go实现的API Gateway优秀,只是它的资料比较齐全,适合在本文中作演示罢了。

3. API网关主要功能特性示例(Tyk API网关版本)

3.1 Tyk API网关简介

记得在至少5年前就知道Tyk API网关的存在,印象中它是使用Go语言开发的早期的那批API网关之一。Tyk从最初的纯开源项目,到如今由背后商业公司支持,以Open Core模式开源的网关,一直保持了active dev的状态。经过多年的演进,它已经一款功能强大的开源兼商业API管理和网关解决方案,提供了全面的功能和工具,帮助开发者有效地管理、保护和监控API。同时,Tyk API网关支持多种安装部署方式,即可以单一程序的方式放在物理机或VM上运行,也可以支持容器部署,通过docker-compose拉起,亦可以通过Kubernetes Operator将其部署在Kubernetes中,这也让Tyk API网关具备了在各大公有云上平滑迁移的能力。

关于Tyk API网关开源版本的功能详情,可以点击左边超链接到其官网查阅,这里不赘述。

3.2 安装Tyk API网关

下面我们就来安装一下Tyk API网关,我们直接在VM上安装,VM上的环境是CentOS 7.9。Tyk API提供了很多中安装方法,这里使用CentOS的yum包管理工具安装Tyk API网关,大体步骤如下(演示均以root权限操作)。

3.2.1 创建tyk gateway软件源

默认的yum repo中是不包含tyk gateway的,我们需要在/etc/yum.repos.d下面创建一个新的源,即新建一个tyk_tyk-gateway.repo文件,其内容如下:

[tyk_tyk-gateway]
name=tyk_tyk-gateway
baseurl=https://packagecloud.io/tyk/tyk-gateway/el/7/$basearch
repo_gpgcheck=1
gpgcheck=0
enabled=1
gpgkey=https://packagecloud.io/tyk/tyk-gateway/gpgkey
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
metadata_expire=300

[tyk_tyk-gateway-source]
name=tyk_tyk-gateway-source
baseurl=https://packagecloud.io/tyk/tyk-gateway/el/7/SRPMS
repo_gpgcheck=1
gpgcheck=0
enabled=1
gpgkey=https://packagecloud.io/tyk/tyk-gateway/gpgkey
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
metadata_expire=300

接下来我们执行下面命令来创建tyk_tyk-gateway这个repo的YUM缓存:

$yum -q makecache -y --disablerepo='*' --enablerepo='tyk_tyk-gateway'
导入 GPG key 0x5FB83118:
 用户ID     : "https://packagecloud.io/tyk/tyk-gateway (https://packagecloud.io/docs#gpg_signing) <support@packagecloud.io>"
 指纹       : 9179 6215 a875 8c40 ab57 5f03 87be 71bd 5fb8 3118
 来自       : https://packagecloud.io/tyk/tyk-gateway/gpgkey

repo配置和缓存完毕后,我们就可以安装Tyk API Gateway了:

$yum install -y tyk-gateway

安装后的tky-gateway将以一个systemd daemon服务的形式存在于主机上,程序意外退出或虚机重启后,该服务也会被systemd自动拉起。通过systemctl status命令可以查看服务的运行状态:

# systemctl status tyk-gateway
● tyk-gateway.service - Tyk API Gateway
   Loaded: loaded (/usr/lib/systemd/system/tyk-gateway.service; enabled; vendor preset: disabled)
   Active: active (running) since 日 2023-11-19 20:22:44 CST; 12min ago
 Main PID: 29306 (tyk)
    Tasks: 13
   Memory: 19.6M
   CGroup: /system.slice/tyk-gateway.service
           └─29306 /opt/tyk-gateway/tyk --conf /opt/tyk-gateway/tyk.conf

11月 19 20:34:54 iZ2ze18rmx2avqb5xgb4omZ tyk[29306]: time="Nov 19 20:34:54" level=error msg="Connection to Redis faile...b-sub
11月 19 20:35:04 iZ2ze18rmx2avqb5xgb4omZ tyk[29306]: time="Nov 19 20:35:04" level=error msg="cannot set key in pollerC...ured"
11月 19 20:35:04 iZ2ze18rmx2avqb5xgb4omZ tyk[29306]: time="Nov 19 20:35:04" level=error msg="Redis health check failed...=main
Hint: Some lines were ellipsized, use -l to show in full.

3.2.2 安装redis

我们看到tyk-gateway已经成功启动,但从其服务日志来看,它在连接redis时报错了!tyk gateway默认将数据存储在redis中,为了让tyk gateway正常运行,我们还需要安装redis!这里我们使用容器的方式安装和运行一个redis服务:

$docker pull redis:6.2.14-alpine3.18
$docker run -d --name my-redis -p 6379:6379 redis:6.2.14-alpine3.18
e5d1ec8d5f5c09023d1a4dd7d31d293b2d7147f1d9a01cff8eff077c93a9dab7

拉取并运行redis后,我们通过redis-cli验证一下与redis server的连接:

# docker run -it --rm redis:6.2.14-alpine3.18  redis-cli -h 192.168.0.24
192.168.0.24:6379>

我们看到可以正常连接!但此时Tyk Gateway仍然无法与redis正常连接,我们还需要对Tyk Gateway做一些配置调整!

3.2.3 配置Tyk Gateway

yum默认将Tyk Gateway安装到/opt/tyk-gateway下面,这个路径下的文件布局如下:

$tree -F -L 2 .
.
├── apps/
│   └── app_sample.json
├── coprocess/
│   ├── api.h
│   ├── bindings/
│   ├── coprocess_common.pb.go
│   ├── coprocess_mini_request_object.pb.go
│   ├── coprocess_object_grpc.pb.go
│   ├── coprocess_object.pb.go
│   ├── coprocess_response_object.pb.go
│   ├── coprocess_return_overrides.pb.go
│   ├── coprocess_session_state.pb.go
│   ├── coprocess_test.go
│   ├── dispatcher.go
│   ├── grpc/
│   ├── lua/
│   ├── proto/
│   ├── python/
│   └── README.md
├── event_handlers/
│   └── sample/
├── install/
│   ├── before_install.sh*
│   ├── data/
│   ├── init_local.sh
│   ├── inits/
│   ├── post_install.sh*
│   ├── post_remove.sh*
│   ├── post_trans.sh
│   └── setup.sh*
├── middleware/
│   ├── ottoAuthExample.js
│   ├── sampleMiddleware.js
│   ├── samplePostProcessMiddleware.js
│   ├── samplePreProcessMiddleware.js
│   ├── testPostVirtual.js
│   ├── testVirtual.js
│   └── waf.js
├── policies/
│   └── policies.json
├── templates/
│   ├── breaker_webhook.json
│   ├── default_webhook.json
│   ├── error.json
│   ├── monitor_template.json
│   └── playground/
├── tyk*
└── tyk.conf

其中tyk.conf就是tyk gateway的配置文件,我们先看看其默认的内容:

$cat /opt/tyk-gateway/tyk.conf
{
  "listen_address": "",
  "listen_port": 8080,
  "secret": "xxxxxx",
  "template_path": "/opt/tyk-gateway/templates",
  "use_db_app_configs": false,
  "app_path": "/opt/tyk-gateway/apps",
  "middleware_path": "/opt/tyk-gateway/middleware",
  "storage": {
    "type": "redis",
    "host": "redis",
    "port": 6379,
    "username": "",
    "password": "",
    "database": 0,
    "optimisation_max_idle": 2000,
    "optimisation_max_active": 4000
  },
  "enable_analytics": false,
  "analytics_config": {
    "type": "",
    "ignored_ips": []
  },
  "dns_cache": {
    "enabled": false,
    "ttl": 3600,
    "check_interval": 60
  },
  "allow_master_keys": false,
  "policies": {
    "policy_source": "file"
  },
  "hash_keys": true,
  "hash_key_function": "murmur64",
  "suppress_redis_signal_reload": false,
  "force_global_session_lifetime": false,
  "max_idle_connections_per_host": 500
}

我们看到:storage下面存储了redis的配置信息,我们需要将redis的host配置修改为我们的VM地址:

    "host": "192.168.0.24",

然后重启Tyk Gateway服务:

$systemctl daemon-reload
$systemctl restart tyk-gateway

之后,我们再查看tyk gateway的运行状态:

systemctl status tyk-gateway
● tyk-gateway.service - Tyk API Gateway
   Loaded: loaded (/usr/lib/systemd/system/tyk-gateway.service; enabled; vendor preset: disabled)
   Active: active (running) since 一 2023-11-20 06:54:07 CST; 41s ago
 Main PID: 20827 (tyk)
    Tasks: 15
   Memory: 24.8M
   CGroup: /system.slice/tyk-gateway.service
           └─20827 /opt/tyk-gateway/tyk --conf /opt/tyk-gateway/tyk.conf

11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Loading API configurations...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Tracking hostname" api_nam...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Initialising Tyk REST API ...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="API bind on custom port:0"...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Checking security policy: ...fault
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="API Loaded" api_id=1 api_n...ip=--
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Loading uptime tests..." p...k-mgr
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="Initialised API Definition...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=warning msg="All APIs are protected ...=main
11月 20 06:54:07 iZ2ze18rmx2avqb5xgb4omZ tyk[20827]: time="Nov 20 06:54:07" level=info msg="API reload complete" prefix=main
Hint: Some lines were ellipsized, use -l to show in full.

从服务日志来看,现在Tyk Gateway可以正常连接redis并提供服务了!我们也可以通过下面的命令验证网关的运行状态:

$curl localhost:8080/hello
{"status":"pass","version":"5.2.1","description":"Tyk GW","details":{"redis":{"status":"pass","componentType":"datastore","time":"2023-11-20T06:58:57+08:00"}}}

“/hello”是Tyk Gateway的内置路由,由Tyk网关自己提供服务。

到这里Tyk Gateway的安装和简单配置就结束了,接下来,我们就来看看API Gateway的主要功能特性,并借助Tyk Gateway来展示一下这些功能特性。

注:查看Tyk Gateway的运行日志,可以使用journalctl -u tyk-gateway -f命令实时follow最新日志输出。

3.3 功能特性:请求转发与路由

请求转发和路由是API Gateway的主要功能特性之一,API Gateway可以根据请求的路径、方法、查询参数等信息将请求转发到相应的后端服务,其内核与反向代理类似,不同之处在于API Gateway增加了“API”这层抽象,更加专注于构建、管理和增强API。

下面我们来看看Tyk如何配置API路由,我们首先创建一个新API。

3.3.1 创建一个新API

Tyk开源版支持两种创建API的方式,一种是通过调用Tyk的控制类API,一种则是通过传统的配置文件,放入特定目录下。无论哪种方式添加完API,最终都要通过Tyk Gateway热加载(hot reload)或重启才能生效。

注:Tyk Gateway的商业版本提供Dashboard,可以以图形化的方式管理API,并且商业版本的API定义会放在Postgres或MongoDB中,我们这里用开源版本,只能手工管理了,并且API定义只能放在文件中。

下面,我们就来在Tyk上创建一个新的API路由,该路由示例的示意图如下:

在未添加新API之前,我们使用curl访问一下该API路径:

$curl localhost:8080/api/v1/no-authn
Not Found

Tyk Gateway由于找不到API路由,返回Not Found。接下来,我们采用调用tyk gateway API的方式来添加路由:

$curl -v -H "x-tyk-authorization: {tyk gateway secret}" \
  -s \
  -H "Content-Type: application/json" \
  -X POST \
  -d '{
    "name": "no-authn-v1",
    "slug": "no-authn-v1",
    "api_id": "no-authn-v1",
    "org_id": "1",
    "use_keyless": true,
    "auth": {
      "auth_header_name": "Authorization"
    },
    "definition": {
      "location": "header",
      "key": "x-api-version"
    },
    "version_data": {
      "not_versioned": true,
      "versions": {
        "Default": {
          "name": "Default",
          "use_extended_paths": true
        }
      }
    },
    "proxy": {
      "listen_path": "/api/v1/no-authn",
      "target_url": "http://localhost:18081/",
      "strip_listen_path": true
    },
    "active": true
}' http://localhost:8080/tyk/apis | python -mjson.tool 

* About to connect() to localhost port 8080 (#0)
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> POST /tyk/apis HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
> x-tyk-authorization: {tyk gateway secret}
> Content-Type: application/json
> Content-Length: 797
>
} [data not shown]
* upload completely sent off: 797 out of 797 bytes
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Wed, 22 Nov 2023 05:38:40 GMT
< Content-Length: 53
<
{ [data not shown]
* Connection #0 to host localhost left intact
{
    "action": "added",
    "key": "no-authn-v1",
    "status": "ok"
}

从curl返回结果我们看到:API已经被成功添加。这时tyk gateway的安装目录/opt/tyk-gateway的子目录apps下会新增一个名为no-authn-v1.json的配置文件,这个文件内容较多,有300行,这里就不贴出来了,这个文件就是新增的no-authn API的定义文件

不过此刻,Tyk Gateway还需热加载后才能为新的API提供服务,调用下面API可以触发Tyk Gateway的热加载:

$curl -H "x-tyk-authorization: {tyk gateway secret}" -s http://localhost:8080/tyk/reload/group | python -mjson.tool
{
    "message": "",
    "status": "ok"
}

注:即便触发热加载成功,但如果body中的json格式错,比如多了一个结尾逗号,Tyk Gateway是不会报错的!

API路由创建完毕并生效后,我们再来访问一下API:

$ curl localhost:8080/api/v1/no-authn
{
    "error": "There was a problem proxying the request"
}

我们看到:Tyk Gateway返回的已经不是“Not Found”了!现在我们创建一下no-authn这个API服务,考虑到适配更多后续示例,这里建立这样一个http server:

// api-gateway-examples/httpserver

func main() {
    // 解析命令行参数
    port := flag.Int("p", 8080, "Port number")
    apiVersion := flag.String("v", "v1", "API version")
    apiName := flag.String("n", "example", "API name")
    flag.Parse()                                         

    // 注册处理程序
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Println(*r)
        fmt.Fprintf(w, "Welcome api: localhost:%d/%s/%s\n", *port, *apiVersion, *apiName)
    })                                                                                     

    // 启动HTTP服务器
    addr := fmt.Sprintf(":%d", *port)
    log.Printf("Server listening on port %d\n", *port)
    log.Fatal(http.ListenAndServe(addr, nil))
}

我们启动一个该http server的实例:

$go run main.go -p 18081 -v v1 -n no-authn
2023/11/22 22:02:42 Server listening on port 18081

现在我们再通过tyk gateway调用一下no-authn这个API:

$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn

我们看到这次路由通了!no-authn API返回了期望的结果!

3.3.2 负载均衡

如果no-authn API存在多个服务实例,Tyk Gateway也可以将请求流量负载均衡到多个no-authn服务实例上去,下图是Tyk Gateway进行请求流量负载均衡的示意图:

要实现负责均衡,我们需要调整no-authn API的定义,这次我们直接修改/opt/tyk-gateway/apps/no-authn-v1.json,变更的配置主要有三项:

// /opt/tyk-gateway/apps/no-authn-v1.json

  "proxy": {
    "preserve_host_header": false,
    "listen_path": "/api/v1/no-authn",
    "target_url": "",                  // (1) 改为""
    "disable_strip_slash": false,
    "strip_listen_path": true,
    "enable_load_balancing": true,     // (2) 改为true
    "target_list": [                   // (3) 填写no-authn服务实例列表
      "http://localhost:18081/",
      "http://localhost:18082/",
      "http://localhost:18083/"
    ],

修改完配置后,调用Tyk的控制类API使之生效,然后我们启动三个no-authn的API实例:

$go run main.go -p 18081 -v v1 -n no-authn
$go run main.go -p 18082 -v v1 -n no-authn
$go run main.go -p 18083 -v v1 -n no-authn

接下来,我们多次调用curl访问no-authn API:

$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18082/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18083/v1/no-authn

$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18082/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18083/v1/no-authn

我们看到:Tyk Gateway在no-authn API的各个实例之间做了等权重的轮询。如果我们停掉实例3,再来访问该API,我们将得到下面结果:

$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18082/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Bad Request

$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Welcome api: localhost:18082/v1/no-authn
$curl localhost:8080/api/v1/no-authn
Bad Request

注:Tyk Gateway商业版通过Dashboard支持配置带权重的RR负载均衡算法

我们看到:实例3已经下线,但Tyk Gateway并不会跳过该已经下线的实例,这在生产环境会给客户端带来不一致的响应。

3.3.3 服务实例存活检测(uptime test)

Tyk Gateway在开启负载均衡的时候,也提供了对后端服务实例的存活检测机制,当某个服务实例down了后,负载均衡机制会绕过该实例将请求发到下一个处于存活状态的实例;而当down机实例恢复后,Tyk Gateway也能及时检测到服务实例上线,并将其加入流量负载调度。

支持存活检测(uptime test)的API定义配置如下:

// /opt/tyk-gateway/apps/no-authn-v1.json

"uptime_tests": {
    "disable": false,
    "poller_group":"",
    "check_list": [
      {
        "url": "http://localhost:18081/"
      },
      {
        "url": "http://localhost:18082/"
      },
      {
        "url": "http://localhost:18083/"
      }
    ],
    "config": {
      "enable_uptime_analytics": true,
      "failure_trigger_sample_size": 3,
      "time_wait": 300,
      "checker_pool_size": 50,
      "expire_utime_after": 0,
      "service_discovery": {
        "use_discovery_service": false,
        "query_endpoint": "",
        "use_nested_query": false,
        "parent_data_path": "",
        "data_path": "",
        "port_data_path": "",
        "target_path": "",
        "use_target_list": false,
        "cache_disabled": false,
        "cache_timeout": 0,
        "endpoint_returns_list": false
      },
      "recheck_wait": 0
    }
}

"proxy": {
    ... ...
    "enable_load_balancing": true,
    "target_list": [
      "http://localhost:18081/",
      "http://localhost:18082/",
      "http://localhost:18083/"
    ],
    "check_host_against_uptime_tests": true,
    ... ...
}

我们新增了uptime_tests的配置,uptime_tests的check_list中的url的值要与proxy中target_list中的值完全一样,这样Tyk Gateway才能将二者对应上。另外proxy的check_host_against_uptime_tests要设置为true。

这样配置并生效后,等我们将服务实例3停掉后,后续到no-authn的请求就只会转发到实例1和实例2了。而当恢复实例3运行后,Tyk Gateway又会将流量分担到实例3上。

3.3.4 动态负载均衡

上面负载均衡示例中target_list中的目标实例的IP和端口的手工配置的,而在云原生时代,我们经常会基于容器承载API服务实例,当容器因故退出,并重新启动一个新容器时,IP可能会发生变化,这样上述的手工配置就无法满足要求,这就对API Gateway提出了与服务发现组件集成的要求:通过服务发现组件动态获取服务实例的访问列表,进而实现动态负载均衡

Tyk Gateway内置了主流服务发现组件(比如Etcd、Consul、ZooKeeper等)的对接能力,鉴于环境所限,这里就不举例了,大家可以在Tyk Gateway的服务发现示例文档页面找到与不同服务发现组件对接时的配置示例。

3.3.5 IP访问限制

针对每个API,API网关还提供IP访问限制的特性,比如Tyk Gateway就提供了IP白名单IP黑名单功能,通常二选一开启一种限制即可。

以白名单为例,即凡是在白名单中的IP才被允许访问该API。下面是白名单配置样例:

// /opt/tyk-gateway/apps/no-authn-v1.json

  "enable_ip_whitelisting": true,
  "allowed_ips": ["12.12.12.12", "12.12.12.13", "12.12.12.14"],

生效后,当我们访问no-authn API时,会得到下面错误:

$curl localhost:8080/api/v1/no-authn
{
    "error": "access from this IP has been disallowed"
}

如果开启的是黑名单,那么凡是在黑名单中的IP都被禁止访问该API,下面是黑名单配置样例:

// /opt/tyk-gateway/apps/no-authn-v1.json

  "enable_ip_blacklisting": true,
  "blacklisted_ips": ["12.12.12.12", "12.12.12.13", "12.12.12.14", "127.0.0.1"],

生效后,当我们访问no-authn API时,会得到如下结果:

$curl 127.0.0.1:8080/api/v1/no-authn
{
    "error": "access from this IP has been disallowed"
}

到目前为止,我们的API网关和定义的API都处于“裸奔”状态,因为没有对客户端进行身份认证,任何客户端都可以访问到我们的API,显然这不是我们期望的,接下来,我们就来看看API网关的一个重要功能特性:身份认证与授权。

3.4 功能特性:身份认证和授权

在《通过实例理解Go Web身份认证的几种方式》一文中,我们提到过:建立全局的安全通道是任何身份认证方式的前提

3.4.1 建立安全通道,卸载TLS证书

Tyk Gateway支持在Gateway层面统一配置TLS证书,同时也起到在Gateway卸载TLS证书的作用:

这次我们要在tyk.conf中进行配置,才能在Gateway层面生效。这里我们借用《通过实例理解Go Web身份认证的几种方式》一文中生成的几个证书(大家可以在https://github.com/bigwhite/experiments/tree/master/authn-examples/tls-authn/make_certs下载),并将它们放到/opt/tyk-gateway/certs/下面:

$ls /opt/tyk-gateway/certs/
server-cert.pem  server-key.pem

然后,我们在/opt/tyk-gateway/tyk.conf文件中增加下面配置:

// /opt/tyk-gateway/tyk.conf 

  "http_server_options": {
    "use_ssl": true,
    "certificates": [
      {
        "domain_name": "server.com",
        "cert_file": "./certs/server-cert.pem",
        "key_file": "./certs/server-key.pem"
      }
    ]
  }

之后,重启tyk gateway服务,使得tyk.conf的配置修改生效。

注:在/etc/hosts中设置server.com为127.0.0.1。

现在我们用之前的http方式访问一下no-authn的API:

$curl server.com:8080/api/v1/no-authn
Client sent an HTTP request to an HTTPS server.

由于全局启用了HTTPS,采用http方式的请求将被拒绝。我们换成https方式访问:

// 不验证服务端证书
$curl -k https://server.com:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn

// 验证服务端的自签证书
$curl --cacert ./inter-cert.pem https://server.com:8080/api/v1/no-authn
Welcome api: localhost:18081/v1/no-authn

3.4.2 Mutual TLS双向认证

在《通过实例理解Go Web身份认证的几种方式》一文中,我们介绍的第一种身份认证方式就是TLS双向认证,那么Tyk Gateway对MTLS的支持如何呢?Tyk官方文档提到它既支持client mTLS,也支持upstream mTLS

我们更关心的是client mTLS,即客户端在与Gateway建连后,Gateway会使用Client CA验证客户端的证书!我最初认为这个Client CA的配置是在tyk.conf中,但找了许久,也没有发现配置Client CA的地方。

在no-authn API的定义文件(no-authn-v1.json)中,我们做如下配置改动:

  "use_mutual_tls_auth": true,
  "client_certificates": [
      "/opt/tyk-gateway/certs/inter-cert.pem"
  ],

但使用下面命令访问API时报错:

$curl --key ./client-key.pem --cert ./client-cert.pem --cacert ./inter-cert.pem https://server.com:8080/api/v1/no-authn
{
    "error": "Certificate with SHA256 bc8717c0f2ea5a0b81813abb3ec42ef8f9bf60da251b87243627d65fb0e3887b not allowed"
}

如果将”client_certificates”的配置中的inter-cert.pem改为client-cert.pem,则是可以的,但个人感觉这很奇怪,不符合逻辑,将tyk gateway的文档、issue甚至代码翻了又翻,也没找到合理的配置client CA的位置。

Tyk Gateway支持多种身份认证方式,下面我们来看一种使用较为广泛的方式:JWT Auth。

主要JWT身份认证方式的原理和详情,可以参考我之前的文章《通过实例理解Go Web身份认证的几种方式》。

3.4.3 JWT Token Auth

下面是我为这个示例做的一个示意图:

这是我们日常开发中经常遇到的场景,即通过portal用用户名和密码登录后便可以拿到一个jwt token,然后后续的访问功能API的请求仅携带该jwt token即可。API Gateway对于portal/login API不做任何身份认证;而对后续的功能API请求,通过共享的secret(也称为static secret)对请求中携带的jwt token进行签名验证。

portal/login API由于不进行authn,这样其配置与前面的no-authn API几乎一致,只是API名称、路径和target_list有不同:

// apps/portal-login-v1.json

{
  "name": "portal-login-v1",
  "slug": "portal-login-v1",
  "listen_port": 0,
  "protocol": "",
  "enable_proxy_protocol": false,
  "api_id": "portal-login-v1",
  "org_id": "1",
  "use_keyless": true,
  ... ...
  "proxy": {
    "preserve_host_header": false,
    "listen_path": "/api/v1/portal/login",
    "target_url": "",
    "disable_strip_slash": false,
    "strip_listen_path": true,
    "enable_load_balancing": true,
    "target_list": [
      "http://localhost:28084"
    ],
    "check_host_against_uptime_tests": true,
  ... ...
}

对应的portal login API也不复杂:

// api-gateway-examples/portal-login/main.go

package main

import (
    "log"
    "net/http"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

func main() {
    // 创建一个基本的HTTP服务器
    mux := http.NewServeMux()

    username := "admin"
    password := "123456"
    key := "iamtonybai"

    // for uptime test
    mux.HandleFunc("/health", func(w http.ResponseWriter, req *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    // login handler
    mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        // 从请求头中获取Basic Auth认证信息
        user, pass, ok := req.BasicAuth()
        if !ok {
            // 认证失败
            w.WriteHeader(http.StatusUnauthorized)
            return
        }

        // 验证用户名密码
        if user == username && pass == password {
            // 认证成功,生成token
            token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
                "username": username,
                "iat":      jwt.NewNumericDate(time.Now()),
            })
            signedToken, _ := token.SignedString([]byte(key))
            w.Write([]byte(signedToken))
        } else {
            // 认证失败
            http.Error(w, "Invalid username or password", http.StatusUnauthorized)
        }
    })

    // 监听28084端口
    err := http.ListenAndServe(":28084", mux)
    if err != nil {
        log.Fatal(err)
    }
}

运行该login API服务后,我们用curl命令获取一下jwt token:

$curl -u 'admin:123456' -k https://server.com:8080/api/v1/portal/login
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDA3NTEyODEsInVzZXJuYW1lIjoiYWRtaW4ifQ.-wC8uPsLHDxSXcEMxIxJ8O2l3aWtWtWKvhtmuHmgIMA

现在我们再来建立protected API:

// apps/protected-v1.json

{
  "name": "protected-v1",
  "slug": "protected-v1",
  "listen_port": 0,
  "protocol": "",
  "enable_proxy_protocol": false,
  "api_id": "protected-v1",
  "org_id": "1",
  "use_keyless": false,    // 设置为false, gateway才会进行jwt的验证
  ... ...
  "enable_jwt": true,      // 开启jwt
  "use_standard_auth": false,
  "use_go_plugin_auth": false,
  "enable_coprocess_auth": false,
  "custom_plugin_auth_enabled": false,
  "jwt_signing_method": "hmac",        // 设置alg为hs256
  "jwt_source": "aWFtdG9ueWJhaQ==",    // 设置共享secret: base64("iamtonybai")
  "jwt_identity_base_field": "username", // 设置代表请求中的用户身份的字段,这里我们用username
  "jwt_client_base_field": "",
  "jwt_policy_field_name": "",
  "jwt_default_policies": [
     "5e189590801287e42a6cf5ce"        // 设置security policy,这个似乎是jwt auth必须的
  ],
  "jwt_issued_at_validation_skew": 0,
  "jwt_expires_at_validation_skew": 0,
  "jwt_not_before_validation_skew": 0,
  "jwt_skip_kid": false,
  ... ...
  "version_data": {
    "not_versioned": true,
    "default_version": "",
    "versions": {
      "Default": {
        "name": "Default",
        "expires": "",
        "paths": {
          "ignored": null,
          "white_list": null,
          "black_list": null
        },
        "use_extended_paths": true,
        "extended_paths": {
          "persist_graphql": null
        },
        "global_headers": {
          "username": "$tyk_context.jwt_claims_username" // 设置转发到upstream的请求中的header字段username
        },
        "global_headers_remove": null,
        "global_response_headers": null,
        "global_response_headers_remove": null,
        "ignore_endpoint_case": false,
        "global_size_limit": 0,
        "override_target": ""
      }
    }
  },
  ... ...
  "enable_context_vars": true, // 开启上下文变量
  "config_data": null,
  "config_data_disabled": false,
  "tag_headers": ["username"], // 设置header
  ... ...
}

这个配置就相对复杂许多,也是翻阅了很长时间资料才验证通过的配置。JWT Auth必须有关联的policy设置,我们在tyk gateway开源版中要想设置policy,需要现在tyk.conf中做如下设置:

// /opt/tyk-gateway/tyk.conf

  "policies": {
    "policy_source": "file",
    "policy_record_name": "./policies/policies.json"
  },

而policies/policies.json的内容如下:

// /opt/tyk-gateway/policies/policies.json
{
    "5e189590801287e42a6cf5ce": {
        "rate": 1000,
        "per": 1,
        "quota_max": 100,
        "quota_renewal_rate": 60,
        "access_rights": {
            "protected-v1": {
                "api_name": "protected-v1",
                "api_id": "protected-v1",
                "versions": [
                    "Default"
                ]
            }
        },
        "org_id": "1",
        "hmac_enabled": false
    }
}

上述设置完毕并重启tyk gateway生效后,且protected api服务也已经启动时,我们访问一下该API服务:

$curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDA3NTEyODEsInVzZXJuYW1lIjoiYWRtaW4ifQ.-wC8uPsLHDxSXcEMxIxJ8O2l3aWtWtWKvhtmuHmgIMA" -k https://server.com:8080/api/v1/protected
invoke protected api ok

我们看到curl发出的请求成功通过了Gateway的验证!并且通过protected API输出的请求信息来看,Gateway成功解析出username,并将其作为Header中的字段传递给了protected API服务实例:

http.Request{Method:"GET", URL:(*url.URL)(0xc0002f6240), Proto:"HTTP/1.1", ProtoMajor:1, ProtoMinor:1, Header:http.Header{"Accept":[]string{"*/*"}, "Accept-Encoding":[]string{"gzip"}, "Authorization":[]string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDA3NTEyODEsInVzZXJuYW1lIjoiYWRtaW4ifQ.-wC8uPsLHDxSXcEMxIxJ8O2l3aWtWtWKvhtmuHmgIMA"}, "User-Agent":[]string{"curl/7.29.0"}, "Username":[]string{"admin"}, "X-Forwarded-For":[]string{"127.0.0.1"}}, Body:http.noBody{}, GetBody:(func() (io.ReadCloser, error))(nil), ContentLength:0, TransferEncoding:[]string(nil), Close:false, Host:"localhost:28085", Form:url.Values(nil), PostForm:url.Values(nil), MultipartForm:(*multipart.Form)(nil), Trailer:http.Header(nil), RemoteAddr:"[::1]:55583", RequestURI:"/", TLS:(*tls.ConnectionState)(nil), Cancel:(<-chan struct {})(nil), Response:(*http.Response)(nil), ctx:(*context.cancelCtx)(0xc0002e34f0)}

如果不携带Authorization头字段或jwt的token是错误的,那么结果将如下所示:

$ curl -k https://server.com:8080/api/v1/protected
{
    "error": "Authorization field missing"
}

$ curl -k -H "Authorization: Bearer xxx" https://server.com:8080/api/v1/protected
{
    "error": "Key not authorized"
}

一旦通过API Gateway的身份认证,上游的API服务就会拿到客户端身份,有了唯一身份后,就可以进行授权操作了,其实policy设置本身也是一种授权访问控制。Tyk Gateway自身也支持RBAC等模型,也支持与OPA(open policy agent)等的集成,但更多是在商业版的tyk dashboard下完成的,这里也就不重点说明了。

下面的Gateway的几个主要功能特性由于试验环境受限以及文章篇幅考量,我不会像上述例子这么细致的说明了,只会简单说明一下。

3.5 功能特性:流量控制与限速

Tyk Gateway内置提供了强大的流量控制功能,可以通过全局级别和API级别的限速来管理请求流量。此外,Tyk Gateway 还支持请求配额(request quota)来限制每个用户或应用程序在一个时间周期内的请求次数。

流量不仅和请求速度和数量有关系,与请求的大小也有关系,Tyk Gateway还支持在全局层面和API层面设置Request的size limit,以避免超大包对网关运行造成不良影响。

3.6 功能特性:高可用与容错处理

在许多情况下,我们要为客户确保服务水平(service level),比如:最大往返时间、最大响应时延等。Tyk Gateway提供了一系列功能,可帮助我们确保网关的高可用运行和SLA服务水平。

Tyk支持健康检查,这对于确定Tyk Gateway的状态极为重要,没有健康检查,就很难知道网关的实际运行状态如何。

Tyk Gateway还内置了断路器(circuit breaker),这个断路器是基于比例的,因此如果y个请求中的x请求都失败了,断路器就会跳闸,例如,如果x = 10,y = 100,则阈值百分比为10%。当失败比例到达10%时,断路器就会切断流量,同时跳闸还会触发一个事件,我们可以记录和处理该事件。

当upstream的服务响应迟迟不归时,Tyk Gateway还可以设置强制超时,可以确保服务始终在给定时间内响应。这在高可用性系统中非常重要,因为在这种系统中,响应性能至关重要,这样才能干净利落地处理错误。

3.7 功能特性:监控与可观测性

微服务时代,可观测性对运维以及系统高可用的重要性不言而喻。Tyk Gateway在多年的演化过程中,也逐渐增加了对可观测的支持,

可观测主要分三大块:

  • log

Tyk Gateway支持设置输出日志的级别(log level),默认是info级别。Tyk输出的是结构化日志,这使得它可以很好的与其他日志收集查询系统集成,Tyk支持与主流的日志收集工具对接,包括:logstash、sentry、Graylog、Syslog等。

  • metrics

度量数据是反映网关系统健康状况、错误计数和类型、IT基础设施(服务器、虚拟机、容器、数据库和其他后端组件)及其他流程的硬件资源数据的重要参考。运维团队可以通过使用监控工具来利用实时度量的数据,识别运行趋势、在系统故障时设置警报、确定问题的根本原因并缓解问题。

Tyk Gateway内置了对主流metrics采集方案Prometheus+Grafana的支持,可以在网关层面以及对API进行实时度量数据采集和展示。

  • tracing

Tyk Gateway从5.2版本开始支持了与服务Tracing界的标准:OpenTelemetry的集成,这样你可以使用多种支持OpenTelemetry的Tracing后端,比如Jaeger、Datadog等。Tracing可在Gateway层面开启,也可以延展到API层面。

4. 小结

本文对已经相对成熟的API网关技术做了回顾,对API网关的演进阶段、主流特性以及当前市面上的主流API网关进行了简要说明,并以Go实现的Tyk Gateway社区开源版为例,以示例方式对API网关的主要功能做了介绍。

总体而言,Tyk Gateway是一款功能强大,社区相对活跃并有商业公司支持的产品,文档很丰富,但从实际使用层面,这些文档对Tyk社区版本的使用者来说并不友好,指导性不足(更多用商业版的Dashboard说明,与配置文件难于对应),就像本文例子中那样,为了搞定JWT认证,笔者着实花了不少时间查阅资料,甚至阅读源码。

Tyk Gateway的配置设计平坦,没有层次和逻辑,感觉是随着时间随意“堆砌”上去的。并且配置文件更新时,如果出现格式问题,Tyk Gateway并不报错,让人难于确定配置是否真正生效了,只能用Tyk Gateway的控制API去查询结果来验证,非常繁琐低效。

本文涉及的源码可以在这里下载,文中涉及的一些tyk gateway api和security policy的配置也可以在其中查看。

5. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

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语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 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