面向 .NET 8 的 C# 电影 API 教程
本教程构建一个 .NET 8 控制台应用:接收片名,搜索 amodb,加载最佳匹配及其评分,并打印卡片。只用 HttpClient 和 System.Text.Json,从 AMODB_KEY 读取密钥,429 时重试并缓存到文件。配置约 15 分钟。
你将构建什么
你将构建 MovieLookup,一个控制台应用:从命令行接收片名,搜索 amodb,加载最佳匹配及其评分,并打印带有海报链接的简短卡片。你需要 .NET 8 SDK、一个 amodb 密钥和大约 15 分钟。程序只使用 HttpClient 和 System.Text.Json,因此无需安装任何 NuGet 包。
步骤 1:获取 API 密钥
在定价页选择套餐。套餐只在速率上不同,分别为每分钟 20、40 或 120 个请求。在账户后台创建密钥,它是由 32 个小写十六进制字符组成的字符串。导出它,并用一次调用测试:
200 表示密钥可用。401 表示密钥错误或缺失。在 PowerShell 中用 $env:AMODB_ 设置该变量。认证页介绍了用 api_key 查询参数替代请求头的方式。
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $AMODB_KEY" \
"https://api.amodb.dev/3/movie/550"步骤 2:创建项目
.NET 8 模板默认启用隐式 using 和可空引用类型,因此已经导入了 System、System.IO、System.Linq 和 System.Net.Http。其他 using 行要在文件中自己添加。
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup步骤 3:发送第一个请求
创建一个 HttpClient,设置基础地址、10 秒超时和 Bearer 请求头。基础地址以斜杠结尾,相对路径不要以斜杠开头。开头的斜杠会替换基础路径中的 /3 部分。
using System.Net.Http.Headers;
using var http = new HttpClient
{
BaseAddress = new Uri("https://api.amodb.dev/3/"),
Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AMODB_KEY"));
var response = await http.GetAsync("search/movie?query=fight%20club&language=en-US");
Console.WriteLine((int)response.StatusCode);步骤 4:把 JSON 解析为 record
响应使用 release_date 这样的蛇形命名。设置 .NET 8 新增的 JsonNamingPolicy.SnakeCaseLower,并用 PascalCase 编写成员。未声明的字段会被忽略。
ratings 对象出现,是因为详情调用加了 append_。它的五个来源各是一个包含 value 和 votes 的对象,评分缺失时为 null。votes 也可能是 null:《搏击俱乐部》的实际响应中,Kinopoisk、Metacritic 和 Rotten Tomatoes 都没有投票数。其余字段见电影页。
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);步骤 5:处理错误和速率限制
把调用包进一个根据状态码做出反应的循环中。
| 状态 | 含义 | 应用的做法 |
|---|---|---|
| 401 | 密钥缺失、错误或已停用 | 打印消息并停止,重试没有用。 |
| 402 | 套餐周期已结束 | 打印消息并停止。请在定价页续费。 |
| 404 | 该 ID 没有记录 | 报告并退出。 |
| 429 | 超过每分钟请求数 | 等待 Retry-After 秒(上限 30 秒),最多重试两次。 |
| 500 或 504 | 服务端故障 | 报告状态,稍后再运行命令。 |
网络故障表现为 HttpRequestException,超时表现为 TaskCanceledException。请在顶层捕获两者。每个带密钥的响应还带有 X-RateLimit-Remaining,因此长时间运行的任务可以在到达 429 之前放慢速度。完整列表见速率限制和错误。
for (var attempt = 0; ; attempt++)
{
using var response = await http.GetAsync(path);
if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
{
var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
await Task.Delay(TimeSpan.FromSeconds(seconds));
continue;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
// ...handle other statuses, then read the body
}步骤 6:把响应缓存到磁盘
速率限制检查先于边缘缓存执行,因此由边缘缓存返回的响应仍计入你每分钟的请求数。想节省请求,唯一的办法是在你自己的程序里做缓存。边缘缓存详情 6 小时、搜索结果 10 分钟,所以应用使用相同的有效期。临时文件夹中的文件,写入时间在 TTL 之内就算新鲜。
class FileCache
{
readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");
string PathFor(string key) =>
Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");
public string? Get(string key, TimeSpan ttl)
{
var file = PathFor(key);
return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
? File.ReadAllText(file)
: null;
}
}步骤 7:整合起来
这是完整的 Program.cs。它给缓存加上了 Set 方法,并加入 AmodbClient 类,把缓存、重试循环和 JSON 选项组合在一起。
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; // same number format on every machine
var key = Environment.GetEnvironmentVariable("AMODB_KEY");
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine("Set AMODB_KEY first.");
return 1;
}
var title = string.Join(' ', args).Trim();
if (title.Length == 0)
{
Console.Error.WriteLine("Usage: dotnet run -- \"movie title\"");
return 1;
}
using var http = new HttpClient
{
BaseAddress = new Uri("https://api.amodb.dev/3/"),
Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var client = new AmodbClient(http, new FileCache());
try
{
var search = await client.GetAsync<SearchResponse>(
$"search/movie?query={Uri.EscapeDataString(title)}&language=en-US", TimeSpan.FromMinutes(10));
var hit = search.Results.FirstOrDefault();
if (hit is null)
{
Console.WriteLine($"No movie found for \"{title}\".");
return 0;
}
var movie = await client.GetAsync<Movie>(
$"movie/{hit.Id}?language=en-US&append_to_response=ratings", TimeSpan.FromHours(6));
Print(movie);
return 0;
}
catch (AmodbException ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
catch (HttpRequestException ex)
{
Console.Error.WriteLine($"Network error: {ex.Message}");
return 1;
}
catch (TaskCanceledException)
{
Console.Error.WriteLine("The request timed out after 10 seconds.");
return 1;
}
static void Print(Movie m)
{
var year = m.ReleaseDate is { Length: >= 4 } d ? d[..4] : "n/a";
Console.WriteLine($"{m.Title} ({year}), {m.Runtime?.ToString() ?? "?"} min");
if (!string.IsNullOrEmpty(m.Tagline)) Console.WriteLine(m.Tagline);
Console.WriteLine($"IMDb: {Describe(m.Ratings?.Imdb)}");
Console.WriteLine($"TMDB: {Describe(m.Ratings?.Tmdb)}");
if (m.PosterPath is not null)
Console.WriteLine($"Poster: https://img.amodb.dev/t/p/w342{m.PosterPath}");
}
static string Describe(Score? s) =>
s is null ? "not known" : $"{s.Value:0.0}" + (s.Votes is { } v ? $" ({v:N0} votes)" : "");
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);
class AmodbException : Exception
{
public AmodbException(string message) : base(message) { }
}
class FileCache
{
readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");
string PathFor(string key) =>
Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");
public string? Get(string key, TimeSpan ttl)
{
var file = PathFor(key);
return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
? File.ReadAllText(file)
: null;
}
public void Set(string key, string json)
{
Directory.CreateDirectory(dir);
File.WriteAllText(PathFor(key), json);
}
}
class AmodbClient(HttpClient http, FileCache cache)
{
static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public async Task<T> GetAsync<T>(string path, TimeSpan ttl)
{
var cached = cache.Get(path, ttl);
if (cached is not null) return JsonSerializer.Deserialize<T>(cached, Options)!;
for (var attempt = 0; ; attempt++)
{
using var response = await http.GetAsync(path);
if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
{
var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
await Task.Delay(TimeSpan.FromSeconds(seconds));
continue;
}
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
if (!response.IsSuccessStatusCode)
throw new AmodbException($"amodb returned status {(int)response.StatusCode}.");
var body = await response.Content.ReadAsStringAsync();
cache.Set(path, body);
return JsonSerializer.Deserialize<T>(body, Options)!;
}
}
}传入片名运行它。下面是第一条搜索结果(电影 550)的输出。投票数会随时间增长,所以你的结果会略有不同。
TMDB 那一行显示 8.4,因为代码只打印 ratings.tmdb.value 中 8.437 的一位小数。海报主机 img.amodb.dev 无需密钥,所以你可以把海报 URL 直接交给浏览器或图片控件。尺寸列表见图片。
dotnet run -- "fight club"
# Fight Club (1999), 139 min
# Mischief. Mayhem. Soap.
# IMDb: 8.8 (2,669,989 votes)
# TMDB: 8.4 (33,039 votes)
# Poster: https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg