מדריך API לסרטים ב-Go
בנו כלי שורת פקודה ב-Go שמציג את הסרטים בעלי הדירוג הגבוה ביותר לפי ז'אנר ושנת יציאה. הוא מפענח JSON למבנים עם טיפוסים, מנסה שוב אחרי 429 לפי ההשהיה ב-Retry-After ושומר תגובות בדיסק. הוא משתמש רק בספרייה הסטנדרטית, דורש Go 1.22 ומעלה ולוקח כ-20 דקות.
בונים את topfilms, כלי שורת פקודה ב-Go. הריצו go run . -genre 878 -year 2010 -n 10 והוא ידפיס את עשרת סרטי המדע הבדיוני בעלי הדירוג הגבוה ביותר שיצאו ב-2010, עם ציון ומספר הצבעות. הוא משתמש במבנים עם טיפוסים, בספרייה הסטנדרטית בלבד ובגרסת Go 1.22 ומעלה. תכננו 20 דקות.
שלב 1: קבלת מפתח API
התחילו בעמוד תמחור, שם בוחרים תקופה בתשלום מראש ואחד ממסלולי 20, 40 או 120 בקשות לדקה. אחר כך צרו מפתח בלוח הבקרה של החשבון וייצאו אותו:
התוכנית קוראת אותו עם os.Getenv("AMODB_KEY"), ולכן הוא אף פעם לא נמצא בקוד המקור.
export AMODB_KEY="YOUR_API_KEY"שלב 2: יצירת המודול
צרו תיקייה ואתחלו מודול. אין מה להוריד.
go version אמור להדפיס 1.22 ומעלה. את כל הקוד שלמטה שימו בקובץ בשם main.go.
mkdir topfilms && cd topfilms
go mod init topfilms
go versionשלב 3: שליחת הבקשה הראשונה
הכלי קורא ל-GET /3/discover/movie, שמסנן וממיין את הקטלוג. ז'אנר 878 הוא מדע בדיוני, ורשימת המזהים המלאה נמצאת בעמוד ז'אנרים. התוכנית המלאה הזו רק מדפיסה את שורת הסטטוס.
לעולם אל תשתמשו ב-http.DefaultClient לקריאות API, כי אין לו פסק זמן.
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)
}שלב 4: פענוח למבנים עם טיפוסים
הצהירו רק על השדות שאתם קוראים. encoding/json מתעלם מהשאר, ולכן שדות חדשים בתגובה לא יכולים לשבור את הכלי.
השאילתה מבקשת sort_ ו-vote_count.gte=500. בלי רף של הצבעות, כותרים עם הצבעה אחת של 10 כוכבים היו מובילים את הרשימה. כל מסנן מתועד בעמוד גילוי.
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"`
}שלב 5: ניסיון חוזר ב-429 ודיווח על שגיאות
קראו את הגוף פעם אחת, ואז החליטו לפי קוד הסטטוס.
| סטטוס | מה הלקוח עושה |
|---|---|
| 200 | כותב את קובץ המטמון ומפענח את הגוף. |
| 401 | מחזיר שגיאה שמזכירה את AMODB_KEY. בלי ניסיון חוזר, כי הקריאה הבאה תיכשל גם היא. |
| 429 | ישן Retry-After שניות (1 אם חסר, 30 לכל היותר) ומנסה שוב, עד 3 ניסיונות. |
| 4xx ו-5xx אחרים | מחזיר את status_code ואת status_message מגוף השגיאה של TMDB. |
| שגיאת תעבורה | מחזיר network error עטוף עם %w. |
פונקציית העזר להשהיה קטנה:
את כל הקודים ראו בעמוד שגיאות, ואת החלון הנע בעמוד מגבלות קצב.
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
}שלב 6: שמירת תגובות במטמון בדיסק
הקצה מחזיק רשימות discover 30 דקות. הלקוח שומר כל גוף מוצלח בתיקיית המטמון של המשתמש, בשם שנגזר מ-SHA-256 של הכתובת המלאה, ומגיש אותו שוב כל עוד הקובץ צעיר מה-TTL. פקודה חוזרת אז לא שולחת בקשה.
שלב 7: התוכנית המלאה
החליפו את main.go בזה והריצו go run . -genre 878 -year 2010 -n 10.
נסו מסננים אחרים עם הדגלים. go run . -genre 18 -year 1999 -n 5 מציג את חמש הדרמות בעלות הדירוג הגבוה ביותר מ-1999. הריצו את אותה פקודה פעמיים והריצה השנייה עונה מקובץ המטמון בלי לגעת ברשת, עד שיעברו 30 דקות. אם אין סרט עם 500 הצבעות לשילוב שלכם, הכלי מדפיס No movies matched. ויוצא כרגיל.
לולאת הניסיון החוזר חוזרת מתוך ה-switch בכל מקרה מלבד 429 שעדיין נשארו לו ניסיונות. לכן הפונקציה מסתיימת בשגיאה פשוטה: הקומפיילר צריך return אחרי הלולאה, וההודעה תופיע רק אם לוגיקת הלולאה תשתנה.
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)
}
}הצעדים הבאים
- הוסיפו דגל
-pageוהעבירו אותו כפרמטרpageכדי להתקדם בעומק הרשימה. - פתרו כל מזהה סרט עם סרטים והוסיפו
append_כדי לקרוא את ציון IMDb מ-to_ response= ratings ratings.imdb.value. - הריצו כמה ז'אנרים בבת אחת עם goroutines, ושמרו אותם מתחת לבקשות לדקה של המסלול שלכם.
- העבירו את אותו רעיון ל-Java, או קראו את המדריך למתחילים.