# Go 电影 API 教程

> 构建一个 Go 命令行工具，按类型和上映年份列出评分最高的电影。它把 JSON 解码为类型化结构体，在 Retry-After 延迟后重试 429，并把响应缓存到磁盘。只用标准库，需要 Go 1.22 或更新版本，约 20 分钟。

你将构建 `topfilms`，一个 Go 命令行工具。运行 `go run . -genre 878 -year 2010 -n 10`，它会打印 2010 年上映的评分最高的十部科幻电影，包含评分和投票数。它使用类型化结构体，只用标准库，需要 Go 1.22 或更新版本。预计用时 20 分钟。

## 步骤 1：获取 API 密钥

从[定价](https://amodb.dev/zh/pricing/)页开始，选择一个预付周期和每分钟 20、40 或 120 个请求的套餐之一。然后在账户后台创建密钥并导出：

```bash
export AMODB_KEY="YOUR_API_KEY"
```

程序通过 `os.Getenv("AMODB_KEY")` 读取它，所以密钥不会出现在源代码里。

## 步骤 2：创建模块

新建一个文件夹并初始化模块。无需下载任何东西。

```bash
mkdir topfilms && cd topfilms
go mod init topfilms
go version
```

`go version` 应打印 1.22 或更高版本。下面的所有代码都放在名为 `main.go` 的文件中。

## 步骤 3：发送第一个请求

工具调用 `GET /3/discover/movie`，它负责筛选和排序目录。类型 `878` 是科幻，完整的 ID 列表见[类型](https://amodb.dev/zh/genres/)。这个完整程序只打印状态行。

```go
package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	url := "https://api.amodb.dev/3/discover/movie?with_genres=878"
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AMODB_KEY"))
	res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

API 调用绝不要使用 `http.DefaultClient`，因为它没有超时。

## 步骤 4：解码为类型化结构体

只声明你要读取的字段。`encoding/json` 会忽略其余字段，所以响应中新增的字段不会让工具出错。

```go
type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}
```

查询使用 `sort_by=vote_average.desc` 和 `vote_count.gte=500`。没有投票数下限的话，只有一个 10 分投票的作品会排在榜首。所有筛选参数都记录在[发现](https://amodb.dev/zh/discover/)页。

## 步骤 5：对 429 重试并报告错误

只读取一次响应体，然后根据状态码决定。

| 状态           | 客户端的做法                                            |
| ------------ | ------------------------------------------------- |
| 200          | 写入缓存文件并解码响应体。                                     |
| 401          | 返回一个指明 `AMODB_KEY` 的错误。不重试，因为下一次调用同样会失败。          |
| 429          | 休眠 `Retry-After` 秒（缺失时为 1，最多 30），然后重试，最多 3 次。     |
| 其他 4xx 和 5xx | 返回 TMDB 错误响应体中的 `status_code` 和 `status_message`。 |
| 传输错误         | 返回用 `%w` 包装的 `network error`。                     |

延迟辅助函数很短：

```go
func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}
```

所有错误码见[错误](https://amodb.dev/zh/errors/)，滑动窗口见[速率限制](https://amodb.dev/zh/rate-limits/)。

## 步骤 6：把响应缓存到磁盘

边缘节点会把 discover 列表缓存 30 分钟。客户端把每次成功的响应体保存到用户缓存文件夹，文件名取自完整 URL 的 SHA-256，并在文件比 TTL 更新时再次使用它。这样重复执行同一条命令就不会发出请求。

## 步骤 7：完整程序

用下面的代码替换 `main.go`，然后运行 `go run . -genre 878 -year 2010 -n 10`。

```go
package main

import (
	"crypto/sha256"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const baseURL = "https://api.amodb.dev/3"

type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}

type apiError struct {
	StatusCode    int    `json:"status_code"`
	StatusMessage string `json:"status_message"`
}

type Client struct {
	key      string
	hc       *http.Client
	cacheDir string
}

func NewClient(key string) (*Client, error) {
	base, err := os.UserCacheDir()
	if err != nil {
		base = os.TempDir()
	}
	dir := filepath.Join(base, "amodb-top-films")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	return &Client{key: key, hc: &http.Client{Timeout: 10 * time.Second}, cacheDir: dir}, nil
}

func (c *Client) Get(path string, query url.Values, ttl time.Duration, out any) error {
	endpoint := baseURL + path + "?" + query.Encode()
	cacheFile := filepath.Join(c.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte(endpoint))))
	if info, err := os.Stat(cacheFile); err == nil && time.Since(info.ModTime()) < ttl {
		if body, err := os.ReadFile(cacheFile); err == nil {
			return json.Unmarshal(body, out)
		}
	}

	for attempt := 1; attempt <= 3; attempt++ {
		req, err := http.NewRequest(http.MethodGet, endpoint, nil)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.key)
		res, err := c.hc.Do(req)
		if err != nil {
			return fmt.Errorf("network error: %w", err)
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		if err != nil {
			return fmt.Errorf("reading reply: %w", err)
		}

		switch {
		case res.StatusCode == http.StatusOK:
			_ = os.WriteFile(cacheFile, body, 0o644)
			return json.Unmarshal(body, out)
		case res.StatusCode == http.StatusTooManyRequests && attempt < 3:
			time.Sleep(retryDelay(res.Header.Get("Retry-After")))
		default:
			return statusError(res.StatusCode, body)
		}
	}
	return errors.New("retries exhausted")
}

func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}

func statusError(status int, body []byte) error {
	if status == http.StatusUnauthorized {
		return errors.New("401: the key is invalid or suspended, check AMODB_KEY")
	}
	var e apiError
	if json.Unmarshal(body, &e) == nil && e.StatusMessage != "" {
		return fmt.Errorf("amodb returned %d (status_code %d): %s", status, e.StatusCode, e.StatusMessage)
	}
	return fmt.Errorf("amodb returned %d", status)
}

func yearOf(date string) string {
	if len(date) >= 4 {
		return date[:4]
	}
	return "n/a"
}

func main() {
	genre := flag.Int("genre", 878, "genre id, 878 is Science Fiction")
	year := flag.Int("year", 2010, "primary release year")
	limit := flag.Int("n", 10, "how many movies to print, 1 to 20")
	flag.Parse()

	key := os.Getenv("AMODB_KEY")
	if key == "" {
		fmt.Fprintln(os.Stderr, "Set AMODB_KEY first.")
		os.Exit(1)
	}
	client, err := NewClient(key)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	query := url.Values{}
	query.Set("language", "en-US")
	query.Set("with_genres", strconv.Itoa(*genre))
	query.Set("primary_release_year", strconv.Itoa(*year))
	query.Set("sort_by", "vote_average.desc")
	query.Set("vote_count.gte", "500")

	var page DiscoverPage
	if err := client.Get("/discover/movie", query, 30*time.Minute, &page); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(page.Results) == 0 {
		fmt.Println("No movies matched.")
		return
	}
	fmt.Printf("%d movies match, best first:\n", page.TotalResults)
	for i, m := range page.Results {
		if i >= *limit {
			break
		}
		fmt.Printf("%2d. %s (%s) %.1f from %d votes\n", i+1, m.Title, yearOf(m.ReleaseDate), m.VoteAverage, m.VoteCount)
	}
}
```

可以用参数尝试其他筛选。`go run . -genre 18 -year 1999 -n 5` 会列出 1999 年评分最高的五部剧情片。同一条命令运行两次，第二次会直接从缓存文件回答而不接触网络，直到 30 分钟过去。如果你的组合下没有电影达到 500 票，工具会打印 `No movies matched.` 并正常退出。

重试循环在 `switch` 内的每种情况下都会返回，只有一种例外：还有剩余次数的 429。所以函数以一个普通错误结尾：循环之后编译器需要一个 return，而这条消息只有在修改了循环逻辑后才会出现。

## 下一步

- 添加 `-page` 参数并把它作为 `page` 参数传入，以继续翻页浏览列表。
- 用[电影](https://amodb.dev/zh/movies/)解析每个电影 ID，并加上 `append_to_response=ratings`，从 `ratings.imdb.value` 读取 IMDb 评分。
- 用 goroutine 同时运行多个类型，并保持在你套餐的每分钟请求数之内。
- 把同样的思路移植到 [Java](https://amodb.dev/zh/java-movie-api/)，或阅读[快速开始](https://amodb.dev/zh/quickstart/)。

## 常见问题

### 代码需要哪个 Go 版本？

Go 1.22 或更新版本。它使用 Go 1.21 的内置 min 函数、any 别名和标准库，所以 go.mod 不列出任何依赖，go mod init 之后即可直接 go run .。

### 为什么用类型化结构体而不是 map[string]any？

结构体提供编译期的字段名和真正的数值类型，encoding/json 还会跳过你没有声明的字段。这很适合会随时间新增字段的 API，因为旧代码仍可继续工作。

### 客户端如何等待 429？

它把 Retry-After 响应头按整数秒读取，缺失时回退到 1，把等待时间限制在 30 秒以内，总共最多尝试 3 次。之后把状态消息返回给调用方。

### 可以从多个 goroutine 发送请求吗？

可以，但每次调用仍计入你的密钥每分钟 20、40 或 120 个请求。用 time.Ticker 或带缓冲的 channel 分散调用，让 429 重试兜住偶尔的溢出。

### 工具把缓存放在哪里？

放在 os.UserCacheDir 下名为 amodb-top-films 的文件夹中，每个请求一个文件，文件名取自 URL 的 SHA-256 哈希。文件复用 30 分钟，与边缘缓存 discover 列表的时间相同。

## 相关页面

- [发现 API](https://amodb.dev/zh/discover/): 与 TMDB v3 筛选条件相同的 discover API。按类型、演员、公司、关键词、年份、评分、时长和平台列出电影和剧集，并可排序。
- [类型与查找列表](https://amodb.dev/zh/genres/): 采用 TMDB v3 路径的类型列表 API：带译名的电影和电视剧类型 ID，以及国家、语言、职务、年龄分级和流媒体平台列表。
- [速率限制](https://amodb.dev/zh/rate-limits/): amodb 按滑动 60 秒窗口内每分钟请求数限制每个密钥：按套餐为 20、40 或 120。读取速率限制响应头，并用 Retry-After 处理 429。
- [错误](https://amodb.dev/zh/errors/): amodb 的每个错误及其 HTTP 状态、TMDB status_code 和消息，以及 OMDb 错误结构。每个错误的含义和客户端下一步该做什么。
- [Java 电影 API 教程](https://amodb.dev/zh/java-movie-api/): 用 HttpClient 和 Jackson 构建 Java 剧集指南：获取一季、按 Retry-After 重试 429、缓存到文件。需 Java 17。
- [PHP 电影 API 教程](https://amodb.dev/zh/php-movie-api/): 构建一个 PHP 函数和一个小型电影页面，用 cURL 调用 amodb、遇到 429 重试、转义输出并缓存到文件。需 PHP 8.2。

更新于: 2026-10-18. Source: https://amodb.dev/zh/go-movie-api/
