本章涵盖内容:
-
理解工作节点 API 的用途
-
实现处理 API 请求的方法
-
创建一个用于监听 API 请求的服务器
-
通过 API 启动、停止和列出任务
在第 4 章中,我们实现了工作节点的核心功能:从队列中取出任务,然后启动或停止它们。然而,仅靠这些核心功能还不足以让工作节点完整可用。我们需要一种方式来公开这些核心功能,以便运行在另一台机器上的管理节点(它将是唯一的使用者)能够利用这些功能。为此,我们要将工作节点的核心功能封装在一个应用程序编程接口(即 API)中。
正如你在图 5.1 中看到的,这个 API 将非常简单,它为管理节点提供了执行以下基本操作的途径:
- 向工作节点发送一个任务(这会使工作节点将该任务作为容器启动)
- 获取工作节点的任务列表
- 停止一个正在运行的任务
图 5.1 我们的编排器的应用程序接口为 Worker 提供了一个简单的接口。
5.1 工作节点 API 概述
我们已经列举了工作节点 API 将支持的操作:向工作节点发送一个任务以启动它、获取任务列表以及停止一个任务。但我们要如何实现这些操作呢?我们将使用 Web API 来实现这些操作。这一选择意味着工作节点的 API 可以通过网络公开,并且它将使用 HTTP 协议。
和大多数 Web API 一样,工作节点的 API 将使用三个主要组件:
-
处理程序(Handlers) :能够对请求做出响应的函数。
-
路由(Routes) :可用于匹配传入请求的 URL 的模式。
-
路由器(Router) :一个使用路由将传入请求与相应处理程序匹配的对象。
我们本可以仅使用 Go 标准库中的 http 包来实现我们的 API。然而,这可能会很繁琐,因为 http 包缺少一个关键功能:定义参数化路由的能力。什么是参数化路由呢?它是一种定义 URL 的路由,其中 URL 路径的一个或多个部分是未知的,并且可能因请求而异。这对于诸如标识符之类的东西特别有用。例如,如果使用 HTTP GET 请求调用像 /tasks 这样的路由会返回所有任务的列表,那么像 /tasks/5 这样的路由则会返回单个项目,即标识符为整数 5 的任务。然而,由于每个任务都应该有一个唯一的标识符,所以当我们在 Web API 中定义这种路由时,我们需要提供一个模式。实现这一点的方法是为 URL 路径中每个请求可能不同的部分使用一个参数。对于任务来说,我们可以使用定义为 /tasks/{taskID} 的路由。
由于标准库中的 http 包没有提供一种强大且简单的方法来定义参数化路由,所以我们将使用一个轻量级的第三方路由器,名为 chi(github.com/go-chi/chi)。从概念上讲,我们的 API 将如图 5.2 所示。请求将被发送到一个 HTTP 服务器,你可以在图中的框中看到它。这个服务器提供了一个名为 ListenAndServe 的函数,它是我们堆栈中的最底层,将处理监听传入请求的底层细节。接下来的三层 —— 路由、路由器和处理程序 —— 全部由 chi 提供。
图 5.2 从内部来看,工作节点的 API 由 Go 标准库中的一个 HTTP 服务器、chi 包中的路由、路由器和处理程序,以及我们自己的工作节点组成。
对于我们的工作节点 API,我们将使用表 5.1 中定义的路由。由于我们是通过 Web API 来公开工作节点的功能,这些路由将涉及像 GET、POST 和 DELETE 这样的标准 HTTP 方法。
表中的第一条路由 /tasks 将使用 HTTP GET 方法,并将返回一个任务列表。第二条路由与第一条相同,但它使用 POST 方法,该方法将启动一个任务。第三条路由 /tasks/{taskID} 将停止由参数 {taskID} 所标识的正在运行的任务。
表 5.1 我们 Worker API 使用的路由
如果你在日常工作中使用 REST(表述性状态转移)API,那么上述内容看起来应该很熟悉。如果你不熟悉 REST 或者你是 API 领域的新手,也不用担心。要理解我们在本章中构建的内容,并不需要成为 REST 专家。
大致来讲,REST 是一种基于应用程序开发的客户端 - 服务器模型的架构风格。如果你想了解更多关于 REST 的信息,可以从一些通俗易懂的介绍开始,比如博客文章《API 101:什么是 REST API?》(blog.postman.com/rest-api-de…)。
5.2 数据格式、请求与响应
在开始编写任何代码之前,我们还需要处理另一个重要事项。根据你浏览互联网的经验,你知道当你在浏览器中输入一个地址时,你会得到相应的数据。输入 espn.com,你会得到有关体育赛事的数据。输入 nytimes.com,你会得到有关时事的信息。输入 www.funnycatpix.com,你会得到猫咪图片的数据。
和这些网站一样,工作节点 API 也处理数据,包括发送和接收数据。不过,它处理的数据不是关于新闻或猫咪的,而是关于任务的。此外,工作节点 API 处理的数据将采用一种特定的格式,即 JSON(JavaScript Object Notation,JavaScript 对象表示法)。你可能已经熟悉 JSON 了,因为它是许多现代 API 的通用语言。这一决定带来了两个结果:
-
发送到 API 的任何数据(例如,对于表 5.1 中的 POST /tasks 路由)都必须在请求体中编码为 JSON 数据。
-
从 API 返回的任何数据(例如,对于我们的 GET /tasks 路由)都必须在响应体中编码为 JSON 数据。对于我们的工作节点,只有一条路由,即 POST /tasks 路由会接受请求体。但是,我们的工作节点期望在该请求的请求体中包含哪些数据呢?
如果你还记得上一章的内容,工作节点有一个 StartTask 方法,它接受一个 task.Task 类型的参数。该类型包含了我们将任务作为 Docker 容器启动所需的所有必要数据。但是,工作节点 API 将接收(来自管理节点的)的是 task.TaskEvent 类型的数据,其中包含一个 task.Task。所以 API 的工作就是从请求中提取该任务,并将其添加到工作节点的队列中。因此,对我们的 POST /tasks 路由的请求将如下所示。这里的 task.TaskEvent 是在第 4 章中使用过的。
code 5.1 The worker API receiving a task.TaskEvent from the manager
"ID": "6be4cb6b-61d1-40cb-bc7b-9cacefefa60c",
"State": 2,
"Task": {
"State": 1,
"ID": "21b23589-5d2d-4731-b5c9-a97e9832d021",
"Name": "test-chapter-5",
"Image": "strm/helloworld-http"
}
}
对我们的 POST /tasks 请求的响应状态码将为 201,并且在响应体中包含任务的 JSON 编码表示形式。为什么是 201 响应码而不是 200 呢?我们本可以使用 200 响应状态。根据 RFC 7231 中描述的 HTTP 规范,“200(成功)状态码表示请求已成功。200 响应中发送的负载取决于请求方法”(datatracker.ietf.org/doc/html/rf…)。因此,200 响应码是一种通用情况,它告诉请求者:“是的,我收到了你的请求,并且请求成功了。” 然而,201 响应码处理的是更具体的情况。对于 POST 请求,它告诉请求者:“是的,我收到了你的请求,并且我创建了一个新资源。” 在我们的例子中,那个新资源就是在请求体中发送的任务。
和 POST /tasks 路由一样,GET /tasks 路由在其响应中也会返回一个响应体。这条路由最终会调用我们工作节点上的 GetTasks 方法,该方法返回一个指向 task.Task 类型的指针切片,实际上就是一个任务列表。在这种情况下,我们的 API 会获取从 GetTasks 返回的那个切片,将其编码为 JSON 格式,然后再返回。下面的清单展示了这样一个响应可能的样子。在这个例子中,有两个任务。
code 5.2 The worker API returning a list of tasks for the GET /tasks route
[
{
"ID": "21b23589-5d2d-4731-b5c9-a97e9832d021",
"ContainerID": "4f67af51b173564ffd50a3c7f",
"Name": "test-chapter-5",
"State": 2,
"Image": "strm/helloworld-http",
"Memory": 0,
"Disk": 0,
"ExposedPorts": null,
"PortBindings": null,
"RestartPolicy": "",
"StartTime": "0001-01-01T00:00:00Z",
"FinishTime": "0001-01-01T00:00:00Z"
},
{
"ID": "266592cd-960d-4091-981c-8c25c44b1018",
"ContainerID": "180d207fa788d5261e6ccf927",
"Name": "test-chapter-5-1",
"State": 2,
"Image": "strm/helloworld-http",
"Memory": 0,
"Disk": 0,
"ExposedPorts": null,
"PortBindings": null,
"RestartPolicy": "",
"StartTime": "0001-01-01T00:00:00Z",
"FinishTime": "0001-01-01T00:00:00Z"
}
]
除了任务列表之外,响应的状态码还将是 200。
最后,让我们来谈谈 DELETE /tasks/{taskID} 路由。和 GET /tasks 路由一样,这条路由在请求中不会接受请求体。记住,我们之前说过,路由中的 {taskID} 部分是一个参数,它允许使用任意的 ID 来调用这条路由。所以这条路由使我们能够停止具有给定 taskID 的任务。这条路由只会返回一个状态码 204,它在响应中不会包含响应体。
那么,基于这些新信息,让我们更新一下表 5.1。
表 5.2 更新了表 5.1,显示了路由是否接受请求正文、是否返回响应正文以及请求成功后返回的状态代码。
5.3 API 结构体
到目前为止,我们已经为编写工作节点 API 的代码做好了铺垫。我们确定了 API 的主要组件,定义了 API 使用的数据格式,并列举了 API 将支持的路由。
我们将从代码层面把 API 表示为清单 5.3 中的结构体开始。你应该在代码的 worker/ 目录下创建一个名为 api.go 的文件,把这个结构体放在其中。
这个结构体有几个作用:
- 它包含
Address和Port字段,这两个字段分别定义了运行 API 的机器的本地 IP 地址以及 API 监听请求的端口。这些字段将用于启动 API 服务器,我们将在本章后面实现这个服务器。 - 它包含
Worker字段,这将是一个指向Worker对象实例的引用。请记住,我们说过 API 会封装工作节点,以便将工作节点的核心功能暴露给管理节点。这个字段就是实现功能暴露的途径。 - 结构体包含
Router字段,它是一个指向chi.Mux实例的指针。这个字段引入了chi路由器提供的所有功能。
code 5.3 The API struct that will power our worker
type Api struct {
Address string
Port int
Worker *Worker
Router *chi.Mux
}
术语 mux 代表多路复用器,可与请求路由器同义。
5.4 处理请求
在定义了 API 结构体之后,从高层次上来说,我们已经赋予了该 API 一个大致的架构或形式。这种架构将包含 API 的三个组成部分:处理程序、路由和路由器。让我们更深入地研究这个 API,并实现那些能够响应我们在表 5.1 中所定义路由的处理程序。
正如我们之前所说,处理程序是一个能够对请求作出响应的函数。为了让我们的 API 能够处理传入的请求,我们需要在 API 结构体上定义处理程序方法。我们将使用以下三个方法,在此列出它们的方法签名:
-
StartTaskHandler(w http.ResponseWriter, r *http.Request) -
GetTasksHandler(w http.ResponseWriter, r *http.Request) -
StopTaskHandler(w http.ResponseWriter, r *http.Request)
这些处理程序方法并没有什么特别复杂的地方。每个方法都接受相同的参数,即一个 http.ResponseWriter 类型的参数和一个指向 http.Request 类型的指针参数。这两种类型都在 Go 语言标准库的 http 包中定义。http.ResponseWriter 类型的参数 w 将包含与响应相关的数据。http.Request 类型的参数 r 将保存与请求相关的数据。
要实现这些处理程序,在项目的 worker 目录下创建一个名为 handlers.go 的文件,然后在文本编辑器中打开该文件。我们将从添加清单 5.4 中所示的 StartTaskHandler 方法开始。大致来说,这个方法从 r.Body 读取请求体,将在请求体中找到的传入数据从 JSON 格式转换为我们的 task.TaskEvent 类型的实例,然后将该 task.TaskEvent 添加到工作节点的队列中。最后,它会打印一条日志消息,然后向 http.ResponseWriter 添加一个响应状态码。它接收启动任务的传入请求,读取请求体,将其从 JSON 格式转换为 task.TaskEvent,然后将其放入工作节点的队列中。
code 5.4 The worker's StartTaskHandler method
func (a *Api) StartTaskHandler(w http.ResponseWrite, r *http.Request) {
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
te := task.TaskEvent{}
err : d.Decode(&te)
if err != nil {
msg := fmt.Sprintf("Error unmarshalling body: %v\n", err)
log.Printf(msg)
w.WriteHeader(400)
e := ErrResponse{
HTTPStatusCode: 400,
Message: msg,
}
json.NewEncoder(w).Encode(e)
return
}
a.Worker.AddTask(te.Task)
log.Pinrtf("Added task %v\n", te.Task.ID)
w.WriteHeader(201)
json.NewEncoder(w).Encode(te.Task)
}
接下来我们要实现的是清单 5.5 中的 GetTasksHandler 方法。这个方法看起来简单,但内部其实有不少操作。它首先设置 Content-Type 响应头,让客户端知道我们发送的是 JSON 数据。然后,和 StartTaskHandler 方法类似,它会添加一个响应状态码。接着就到了该方法的最后一行代码。这行代码可能看起来有点复杂,但实际上它只是一种简洁的方式来表达以下操作:
- 通过调用
json.NewEncoder()方法获取一个json.Encoder类型的实例。 - 通过调用工作节点的
GetTasks方法获取工作节点的所有任务。 - 通过调用
json.Encoder对象的Encode方法将任务列表转换为 JSON 格式。
code 5.5 The worker's GetTasksHandler
func (a *Api) GetTaskHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEnocoder(w).Encode(a.Worker.GetTasks())
}
最后要实现的处理程序是 StopTaskHandler。如果我们回头看一下表 5.2,就会发现停止一个任务是通过发送一个路径为 /tasks/{taskID} 的请求来完成的。当发出一个真实请求时,这个路径可能看起来像 /tasks/6be4cb6b61d1-40cb-bc7b-9cacefefa60c 这样。这就是停止一个任务所需要的全部信息,因为工作节点已经知晓这个任务了:它将任务存储在自己的 Db 字段中。
StopTaskHandler 要做的第一件事就是从请求路径中读取 taskID。正如你在清单 5.6 中看到的,我们通过使用 chi 包中一个名为 URLParam 的辅助函数来实现这一点。我们不必担心这个辅助函数是如何为我们获取 taskID 的;我们所关心的是它让我们的工作稍微轻松了一些,并且为我们提供了继续执行停止任务工作所需的数据。
现在我们已经得到了 taskID,我们必须将其从 chi.URLParam 返回给我们的字符串类型转换为 uuid.UUID 类型。这个转换是通过调用 uuid.Parse() 方法并传入 taskID 的字符串版本来完成的。为什么我们必须执行这一步呢?这是必要的,因为工作节点的 Db 字段是一个映射(map),其键的类型为 uuid.UUID。所以,如果我们试图使用字符串来查找一个任务,编译器会报错。
好的,现在我们有了 taskID 并且已经将其转换为了正确的类型。接下来我们要做的是检查工作节点是否知道这个任务。如果不知道,我们应该返回一个状态码为 404 的响应。如果知道,我们就将任务状态更改为 task.Completed 并将其添加到工作节点的队列中。这就是该方法剩余部分所做的事情。工作节点的 StopTaskHandler 使用请求路径中的 taskID 向工作节点的队列中添加一个任务,以停止指定的任务。
code 5.6 The worker's StopTaskHandler
func (a *Api) StopTaskHandler(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
if taskID == "" {
log.Printf("No taskID passed in request.\n")
w.WriteHeader(400)
}
tID, _ := uuid.Parse(taskID)
_, ok := a.Worker.Db[tID]
if !ok {
log.Printf("No task with ID %v found", tID)
w.WriteHeader(404)
}
taskToStop := a.Worker.Db[tID]
taskCopy := *taskToStop
taskCopy.State = task.Completed
a.Worker.AddTask(taskCopy)
log.Printf("Added task %v to stop container %v\n", taskToStop.ID, taskToStop.ContainerID)
w.WriteHeader(204)
}
在我们的 StopTaskHandler 里有一个小陷阱值得更详细地解释一下。注意,我们对工作节点数据存储中的任务进行了复制。为什么这是必要的呢?
正如我在第 4 章提到的,我们使用工作节点的数据存储来表示任务的当前状态,同时使用工作节点的队列来表示任务的期望状态。由于这一设计,API 不能简单地从工作节点的数据存储中取出任务,将其状态设置为 task.Completed,然后把任务放入工作节点的队列。原因在于数据存储中的值是指向 task.Task 类型的指针。如果我们直接在 taskToStop 上更改状态,实际上就是在更改数据存储中任务的状态字段。接着我们把同一个任务添加到工作节点的队列,当队列取出该任务进行处理时,它会报错,提示无法将任务从 task.Completed 状态转换到 task.Completed 状态。因此,我们复制一份任务,在副本上更改状态,再将其添加到队列中。
code 5.7 The initRouter() method
func (a *Api) initRouter() {
a.Router = chi.NewRouter()
a.Router.Route("/tasks", func(r chi.Router){
r.Post("/", a.StartTaskHandler)
r.Get("/", a.GetTaskHandler)
r.Route("/{taskID}", func(r chi.Router) {
r.Delete("/", a.StopTaskHandler)
})
})
}
最后一处添加是为 Api 结构体添加 Start() 方法,如清单 5.8 所示。这个方法会调用清单 5.4 中定义的 initRouter 方法,然后启动一个用于监听请求的 HTTP 服务器。ListenAndServe 函数由 Go 标准库的 http 包提供。它接受一个地址(例如 127.0.0.1:5555,我们使用 fmt.Sprintf 函数来构建这个地址)和一个处理程序,就我们的需求而言,这个处理程序就是在 initRouter() 方法中创建的路由器。
code 5.8 The Start() method: Initializes our router and starts listening for requests
func (a *Api) Start() {
a.initRouter()
http.ListenAndServe(fmt.Sprintf("%s:%d", a.Address, a.Port), a.Router)
}
5.6 整合所有代码
和前几章一样,现在是时候运行我们编写的代码了。为此,我们将继续使用 main.go 文件,并在其中编写 main 函数。你可以复用前一章的 main.go 文件,只删除 main 函数里的内容,也可以使用一个全新的文件。
在你的 main.go 文件中,添加清单 5.9 里的 main() 函数。这个函数利用了我们到目前为止所做的所有工作。它创建了一个工作节点实例 w,该实例包含一个队列(Queue)和一个数据库(Db)。接着,它创建了一个 API 实例 api,这个实例会使用从本地环境中读取的主机和端口值。最后,main() 函数执行两个操作,让一切运转起来。
第一个操作是调用 runTasks 函数,并将工作节点 w 的指针传递给它。不过,这里还有个特别之处。在调用 runTasks 函数之前有个 go 关键字,这是什么意思呢?如果你在其他语言中使用过线程,go runTasks(&w) 这行代码就类似于使用线程。在 Go 语言里,线程被称为协程(goroutine),它提供了进行并发编程的能力。我们这里不会深入探讨协程的细节,因为有专门的资料来讲解这个主题。就我们的需求而言,我们只需要知道我们正在创建一个协程,并且会在这个协程里运行 runTasks 函数。创建协程之后,我们可以继续在 main 函数中执行操作,通过调用 api.Start() 来启动我们的 API。
code 5.9 Running our worker from main.go
func main() {
host := os.Getenv("CUBE_HOST")
port, _ := strconv.Atoi(os.Getenv("CUBE_POST"))
fmt.Println("Starting Cube worker")
w := worker.Worker{
Queue: *queue.New(),
Db: make(map[uuid.UUID]*task.Task,
}
api := worker.Api{Address: host, Port: port, Worker: &w}
go runTasks(&w)
api.Start()
}
现在,让我们来谈谈 runTasks 函数,你可以在清单 5.10 中看到它。这个函数在与 main 函数不同的协程中运行,而且相当简单。它是一个持续的循环,会检查工作节点的队列中是否有任务,并且当发现有需要运行的任务时,就会调用工作节点的 RunTask 方法。为了方便起见,我们在每次循环迭代之间让程序休眠 10 秒钟。这样可以让程序运行得慢一些,以便我们能够轻松读取任何日志消息。
code 5.10 The runTasks function
func runTasks(w *worker.Worker) {
for {
if w.Queue.Len() != 0 {
result := w.RunTask()
if result.Error != nil {
log.Printf("Error running task: %v\n", result.Error)
}
} else {
log.Printf("No tasks to process currently.\n")
}
log.Println("Sleeping for 10 seconds.")
time.Sleep(10 * time.Second)
}
}
我们将 main 函数设计成这样是有原因的。回顾一下我们在本章前面编写的处理函数,它们执行的操作范围非常狭窄,具体如下:
-
读取发送到服务器的请求。
-
从工作节点获取任务列表(就
GetTasksHandler而言)。 -
将任务放入工作节点的队列。
-
向请求者发送响应。
注意,API 并没有调用任何执行任务操作的工作节点方法(即它不会启动或停止任务)。以这种方式组织代码,能让我们将处理请求的关注点与执行启动和停止任务操作的关注点分离开来。这样一来,我们就能更轻松地理解代码库。如果我们想为 API 添加新功能或修复 bug,就知道需要在 api.go 文件中进行操作。如果要对请求处理做同样的事情,就需要在 handlers.go 文件中工作。而对于任何与启动和停止任务操作相关的事情,我们则需要在 worker.go 文件中进行处理。
好了,是时候见证奇迹了。运行我们的代码后,终端应该会输出一系列日志消息,如下所示:
$ CUBE_HOST=localhost CUBE_PORT=5555 go run main.go
Starting Cube Worker
2025/03/12 10:37:58 No tasks in the queue
2025/03/12 10:37:58 Sleeping for 10 second
2025/03/12 10:38:08 No tasks in the queue
2025/03/12 10:38:08 Sleeping for 10 second
2025/03/12 10:38:18 No tasks in the queue
2025/03/12 10:38:18 Sleeping for 10 second
正如你所看到的,当我们首次启动工作节点 API 时,它并没有做太多事情。它会告诉我们它没有任何任务可处理,然后休眠 10 秒钟,接着再次醒来并告诉我们同样的信息。这并不是很令人兴奋。让我们通过与工作节点 API 进行交互来增添一些趣味。我们将在另一个终端中使用 curl 命令开始获取任务列表:
$ curl -v 127.0.0.1:5555/tasks
* Trying 127.0.0.1:5555...
* Connected to 127.0.0.1 (127.0.0.1) port 5555
> GET /tasks HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Wed, 12 Mar 2025 02:40:37 GMT
< Content-Length: 5
<
null
* Connection #0 to host 127.0.0.1 left intact
太棒了!我们可以查询该 API 来获取任务列表。不过,正如预期的那样,响应是一个空列表,因为工作节点目前还没有任何任务。让我们通过向它发送一个启动任务的请求来解决这个问题:
$ curl -v --request POST --header 'Content-Type: application/json' --data '{ "ID": "266592cd-960d-4091-981c-8c25c44b1018", "State": 2, "Task": { "State": 1, "ID": "266592cd-960d-4091-981c-8c25c44b1018", "Name": "test-chapter-5-1", "Image": "docker.io/strm/helloworld-http" }}' http://127.0.0.1:5555/tasks
当你运行 curl 命令时,你应该会看到类似以下的输出。请注意,响应中的状态码是 HTTP/1.1 201 Created,并且没有响应体:
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 127.0.0.1:5555...
* Connected to 127.0.0.1 (127.0.0.1) port 5555
> POST /tasks HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 200
>
* upload completely sent off: 200 bytes
< HTTP/1.1 201 Created
< Date: Wed, 12 Mar 2025 02:46:27 GMT
< Content-Length: 349
< Content-Type: text/plain; charset=utf-8
<
{"ID":"266592cd-960d-4091-981c-8c25c44b1018","ContainerID":"","Name":"test-chapter-5-1","State":1,"CPU":0,"Memory":0,"Disk":0,"Image":"docker.io/strm/helloworld-http","RestartPolicy":"","ExposedPorts":null,"HostPorts":null,"PortBindings":null,"StartTime":"0001-01-01T00:00:00Z","FinishTime":"0001-01-01T00:00:00Z","HealthCheck":"","RestartCount":0}
* Connection #0 to host 127.0.0.1 left intact
在你运行 curl 命令的同时,你应该会在运行 API 的终端中看到日志消息。这些日志消息看起来应该是这样的:
2025/03/12 10:46:27 Added task 266592cd-960d-4091-981c-8c25c44b1018
{"status":"Pulling from strm/helloworld-http","id":"latest"}
{"status":"Digest: sha256:bd44b0ca80c26b5eba984bf498a9c3bab0eb1c59d30d8df3cb2c073937ee4e45"}
{"status":"Status: Image is up to date for strm/helloworld-http:latest"}
好极了!至此,我们通过调用 Worker API 的 POST 任务路由创建了一个任务。现在,当我们向任务发出 GET 请求时,看到的不再是一个空列表,而是类似这样的输出:
$ curl -v 127.0.0.1:5555/tasks
* Trying 127.0.0.1:5555...
* Connected to 127.0.0.1 (127.0.0.1) port 5555
> GET /tasks HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Wed, 12 Mar 2025 02:49:20 GMT
< Content-Length: 422
<
[{"ID":"266592cd-960d-4091-981c-8c25c44b1018","ContainerID":"4a03c9be203fa445c7c0c9e34c03a76d6ec6004634ba3a666ca515fd6c0d612f","Name":"test-chapter-5-1","State":2,"CPU":0,"Memory":0,"Disk":0,"Image":"docker.io/strm/helloworld-http","RestartPolicy":"","ExposedPorts":null,"HostPorts":null,"PortBindings":null,"StartTime":"2025-03-12T02:46:37.014977Z","FinishTime":"0001-01-01T00:00:00Z","HealthCheck":"","RestartCount":0}]
* Connection #0 to host 127.0.0.1 left intact
此外,我们还应该看到一个容器正在本地机器上运行,我们可以使用 docker ps 来验证:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4a03c9be203f strm/helloworld-http "/main.sh" About a minute ago Up About a minute 0.0.0.0:55000->80/tcp test-chapter-5-1
到目前为止,我们通过向 /tasks 路由发送 GET 请求来查询工作节点 API 以获取任务列表。发现工作节点没有任务后,我们通过向 /tasks 路由发送 POST 请求创建了一个任务。随后,我们再次向 /tasks 路由发送 GET 请求查询 API,得到了一个包含我们所创建任务的列表。
现在,让我们来测试工作节点 API 的最后一项功能,即停止我们的任务。我们可以通过向 /tasks/<taskID> 路由发送 DELETE 请求来实现这一点,其中的 taskID 可以使用我们之前 GET 请求响应中的 ID 字段值。
$ curl -v --request DELETE "127.0.0.1:5555/tasks/266592cd-960d-4091-981c-8c25c44b1018"
* Trying 127.0.0.1:5555...
* Connected to 127.0.0.1 (127.0.0.1) port 5555
> DELETE /tasks/266592cd-960d-4091-981c-8c25c44b1018 HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 204 No Content
< Date: Wed, 12 Mar 2025 02:50:58 GMT
<
* Connection #0 to host 127.0.0.1 left intact
除了看到我们的请求收到 HTTP/1.1 204 No Content 响应外,我们还应该看到来自 Worker API 的日志输出,如下所示:
2025/03/12 10:50:58 Added task 266592cd-960d-4091-981c-8c25c44b1018 to stop container 4a03c9be203fa445c7c0c9e34c03a76d6ec6004634ba3a666ca515fd6c0d612f
2025/03/12 10:51:05 Attempting to stop container 4a03c9be203fa445c7c0c9e34c03a76d6ec6004634ba3a666ca515fd6c0d612f
2025/03/12 10:51:10 Stopped and removed container 4a03c9be203fa445c7c0c9e34c03a76d6ec6004634ba3a666ca515fd6c0d612f for task 266592cd-960d-4091-981c-8c25c44b1018
我们可以再次检查 docker ps 的输出,确认它已经停止:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
我们还可以通过查询 API 和检查任务状态来确认。在 GET 任务请求的响应中,我们应该看到任务的状态是 3:
$ curl -v 127.0.0.1:5555/tasks
* Trying 127.0.0.1:5555...
* Connected to 127.0.0.1 (127.0.0.1) port 5555
> GET /tasks HTTP/1.1
> Host: 127.0.0.1:5555
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Wed, 12 Mar 2025 02:52:16 GMT
< Content-Length: 429
<
[
{
"ID":"266592cd-960d-4091-981c-8c25c44b1018",
"ContainerID":"4a03c9be203fa445c7c0c9e34c03a76d6ec6004634ba3a666ca515fd6c0d612f",
"Name":"test-chapter-5-1",
"State":3,
"CPU":0,
"Memory":0,
"Disk":0,
"Image":"docker.io/strm/helloworld-http",
"RestartPolicy":"",
"ExposedPorts":null,
"HostPorts":null,
"PortBindings":null,
"StartTime":"2025-03-12T02:46:37.014977Z",
"FinishTime":"2025-03-12T02:51:10.332545Z",
"HealthCheck":"",
"RestartCount":0
}
]
* Connection #0 to host 127.0.0.1 left intact
总结
API 封装了工作节点的功能,并将其作为 HTTP 服务器对外暴露,从而使其可以通过网络访问。这种将工作节点功能作为 Web API 暴露的策略,能够让管理节点启动和停止任务,还能跨一个或多个工作节点查询任务状态。
API 由处理程序、路由和路由器组成。处理程序是一种函数,它能够接收请求,知道如何处理请求并返回响应。路由是用于匹配传入请求 URL 的模式(例如 /tasks )。最后,路由器就像是粘合剂,它利用路由将请求转发给处理程序,使整个系统得以正常工作。API 使用标准的 HTTP 方法,如 GET、POST 和 DELETE ,来定义针对特定路由会执行的操作。例如,调用 GET /tasks 会从工作节点返回一个任务列表。
虽然 API 封装了工作节点的功能,但它并不直接与这些功能交互。相反,它只是执行一些管理工作,然后将任务放入工作节点的队列中。