面向 .NET 8 的 C# 电影 API 教程

本教程构建一个 .NET 8 控制台应用:接收片名,搜索 amodb,加载最佳匹配及其评分,并打印卡片。只用 HttpClient 和 System.Text.Json,从 AMODB_KEY 读取密钥,429 时重试并缓存到文件。配置约 15 分钟。

.cs C# 更新于 以 Markdown 查看

你将构建什么

你将构建 MovieLookup,一个控制台应用:从命令行接收片名,搜索 amodb,加载最佳匹配及其评分,并打印带有海报链接的简短卡片。你需要 .NET 8 SDK、一个 amodb 密钥和大约 15 分钟。程序只使用 HttpClient 和 System.Text.Json,因此无需安装任何 NuGet 包。

步骤 1:获取 API 密钥

在定价页选择套餐。套餐只在速率上不同,分别为每分钟 20、40 或 120 个请求。在账户后台创建密钥,它是由 32 个小写十六进制字符组成的字符串。导出它,并用一次调用测试:

200 表示密钥可用。401 表示密钥错误或缺失。在 PowerShell 中用 $env:AMODB_KEY = "YOUR_API_KEY" 设置该变量。认证页介绍了用 api_key 查询参数替代请求头的方式。

Shell
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 行要在文件中自己添加。

Shell
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup

步骤 3:发送第一个请求

创建一个 HttpClient,设置基础地址、10 秒超时和 Bearer 请求头。基础地址以斜杠结尾,相对路径不要以斜杠开头。开头的斜杠会替换基础路径中的 /3 部分。

C#
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_to_response=ratings。它的五个来源各是一个包含 value 和 votes 的对象,评分缺失时为 null。votes 也可能是 null:《搏击俱乐部》的实际响应中,Kinopoisk、Metacritic 和 Rotten Tomatoes 都没有投票数。其余字段见电影页。

C#
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 之前放慢速度。完整列表见速率限制和错误。

C#
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 之内就算新鲜。

C#
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 选项组合在一起。

C#
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 直接交给浏览器或图片控件。尺寸列表见图片。

Shell
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

下一步

  • 在搜索中按年份或分页搜索,并用发现筛选列表。
  • 用评分显示所有评分及其 null 规则。
  • 把控制台代码迁移到使用 AddHttpClient 和 IMemoryCache 的 ASP.NET Core 服务。
  • 在 Java 或 Go 中比较同一个任务。