J'ai construit une API avec Go et PostgreSQL, mis en place un pipeline CI/CD avec Google Cloud Run, Cloud Build, Secret Manager et Artifact Registry, et connecté l'instance Cloud Run à CockroachDB.
L'API est basée sur le jeu Crisis Core : Final Fantasy VII, pour simuler « Materia Fusion ». Le public cible de cet article est destiné aux développeurs qui souhaitent simplement savoir comment créer et déployer l’API. J'ai un autre article dans lequel je parle de tout ce que j'ai appris en travaillant sur ce projet, de ce qui n'a pas fonctionné, ainsi que de la compréhension et de la traduction des règles de fusion de matières du jeu (lien à venir).
3 points de terminaison : bilan de santé (GET), liste de tous les matériaux (GET) et simulation de fusion de matériaux (POST)
La Materia (au singulier et au pluriel) est un orbe de cristal qui sert de source de magie. Il existe 144 matériaux distincts dans le jeu, et ils sont globalement classés en 4 catégories : « Magie », « Commande », « Support » et « Indépendant ». Cependant, dans le but de comprendre les règles de fusion des matières, il était plus facile d'avoir 32 catégories internes basées sur leur comportement de fusion, et 8 grades au sein de ces catégories (voir référence) .
Une matière devient « Maîtrisée » lorsqu’elle est utilisée pendant une certaine durée. La durée n'est pas importante ici.
Plus important encore, 2 materia peuvent être fusionnées pour produire une nouvelle materia. Les règles régissant la fusion sont influencées par :
Et il y a BEAUCOUP d'exceptions, certaines règles ayant 3 niveaux de logique if-else imbriquée. Cela élimine la possibilité de créer une table simple dans la base de données et d'y conserver 1 000 règles, ou de proposer une formule unique pour les gouverner toutes.
En bref, il nous faut :
Idéalement, vous pouvez installer la base de données à partir du site Web lui-même. Mais l'outil pgAdmin n'a pas pu se connecter à la base de données pour une raison quelconque, j'ai donc utilisé Homebrew.
brew install postgresql@17
Cela installera tout un tas de fichiers binaires CLI pour faciliter l'utilisation de la base de données.
Facultatif : ajoutez /opt/homebrew/opt/postgresql@17/bin à la variable $PATH.
# create the DB createdb materiafusiondb # step into the DB to perform SQL commands psql materiafusiondb
-- create an SQL user to be used by the Go server CREATE USER go_client WITH PASSWORD 'xxxxxxxx'; -- The Go server doesn't ever need to add data to the DB. -- So let's give it just read permission. CREATE ROLE readonly_role; GRANT USAGE ON SCHEMA public TO readonly_role; -- This command gives SELECT access to all future created tables. ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role; -- If you want to be more strict and give access only to tables that already exist, use this: -- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role; GRANT readonly_role TO go_client;
CREATE TYPE display_materia_type AS ENUM ('Magic', 'Command', 'Support', 'Independent'); CREATE TYPE materia_type AS ENUM ('Fire', 'Ice', 'Lightning', 'Restore', 'Full Cure', 'Status Defense', 'Defense', 'Absorb Magic', 'Status Magic', 'Fire & Status', 'Ice & Status', 'Lightning & Status', 'Gravity', 'Ultimate', 'Quick Attack', 'Quick Attack & Status', 'Blade Arts', 'Blade Arts & Status', 'Fire Blade', 'Ice Blade', 'Lightning Blade', 'Absorb Blade', 'Item', 'Punch', 'SP Turbo', 'HP Up', 'AP Up', 'ATK Up', 'VIT Up', 'MAG Up', 'SPR Up', 'Dash', 'Dualcast', 'DMW', 'Libra', 'MP Up', 'Anything'); CREATE TABLE materia ( id integer NOT NULL, name character varying(50) NOT NULL, materia_type materia_type NOT NULL, grade integer NOT NULL, display_materia_type display_materia_type, description text CONSTRAINT materia_pkey PRIMARY KEY (id) ); -- The primary key 'id' should auto-increment by 1 for every row entry. CREATE SEQUENCE materia_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE materia_id_seq OWNED BY materia.id; ALTER TABLE ONLY materia ALTER COLUMN id SET DEFAULT nextval('materia_id_seq'::REGCLASS);
Créez une feuille Excel avec l'en-tête et les données du tableau, et exportez-la sous forme de fichier CSV. Exécutez ensuite la commande :
COPY materia(name,materia_type,grade,display_materia_type,description) FROM '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;
Créez le code passe-partout à l'aide d'autostrada.dev. Ajoutez les options api, postgresql, httprouter, env var config, tinted logging, git, live reload, makefile. Nous finissons par obtenir une structure de fichier comme celle-ci :
? codebase ├─ cmd │ └─ api │ ├─ errors.go │ ├─ handlers.go │ ├─ helpers.go │ ├─ main.go │ ├─ middleware.go │ └─ server.go ├─ internal │ ├─ database --- db.go │ ├─ env --- env.go │ ├─ request --- json.go │ ├─ response --- json.go │ └─ validator │ ├─ helpers.go │ └─ validators.go ├─ go.mod ├─ LICENSE ├─ Makefile ├─ README.md └─ README.html
Le générateur passe-partout a créé du code pour récupérer les variables d'environnement et les ajouter au code, mais nous pouvons faciliter le suivi et la mise à jour des valeurs.
Créez le fichier
HTTP_PORT=4444 DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable API_TIMEOUT_SECONDS=5 API_CALLS_ALLOWED_PER_SECOND=1
Ajouter la bibliothèque godotenv :
go get github.com/joho/godotenv
Ajoutez ce qui suit à main.go :
// At the beginning of main(): err := godotenv.Load(".env") // Loads environment variables from .env file if err != nil { // This will be true in prod, but that's fine. fmt.Println("Error loading .env file") } // Modify config struct: type config struct { baseURL string db struct { dsn string } httpPort int apiTimeout int apiCallsAllowedPerSecond float64 } // Modify run() to use the new values from .env: cfg.httpPort = env.GetInt("HTTP_PORT") cfg.db.dsn = env.GetString("DB_DSN") cfg.apiTimeout = env.GetInt("API_TIMEOUT_SECONDS") cfg.apiCallsAllowedPerSecond = float64(env.GetInt("API_CALLS_ALLOWED_PER_SECOND")) // cfg.baseURL = env.GetString("BASE_URL") - not required
Le passe-partout dispose déjà d'un middleware pour se remettre des paniques. Nous en ajouterons 3 autres : vérification du type de contenu, limitation du débit et protection contre le délai d'expiration de l'API.
Ajouter une bibliothèque de péages :
go get github.com/didip/tollbooth
Mise à jour
func (app *application) contentTypeCheck(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Content-Type") != "application/json" { app.unsupportedMediaType(w, r) return } next.ServeHTTP(w, r) }) } func (app *application) rateLimiter(next http.Handler) http.Handler { limiter := tollbooth.NewLimiter(app.config.apiCallsAllowedPerSecond, nil) limiter.SetIPLookups([]string{"X-Real-IP", "X-Forwarded-For", "RemoteAddr"}) return tollbooth.LimitHandler(limiter, next) } func (app *application) apiTimeout(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { timeoutDuration := time.Duration(app.config.apiTimeout) * time.Second ctx, cancel := context.WithTimeout(r.Context(), timeoutDuration) defer cancel() r = r.WithContext(ctx) done := make(chan struct{}) go func() { next.ServeHTTP(w, r) close(done) }() select { case <-done: return case <-ctx.Done(): app.gatewayTimeout(w, r) return } }) }
Le middleware doit être ajouté aux routes. Ils peuvent être ajoutés soit à tous les itinéraires, soit à des itinéraires spécifiques. Dans notre cas, la vérification Content-Type (c'est-à-dire obliger les en-têtes d'entrée à inclure Content-Type: application/json) n'est nécessaire que pour les requêtes POST. Modifiez donc routes.go comme suit :
func (app *application) routes() http.Handler { mux := httprouter.New() mux.NotFound = http.HandlerFunc(app.notFound) mux.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowed) // Serve the Swagger UI. Uncomment this line later // mux.Handler("GET", "/docs/*any", httpSwagger.WrapHandler) mux.HandlerFunc("GET", "/status", app.status) mux.HandlerFunc("GET", "/materia", app.getAllMateria) // Adding content-type check middleware to only the POST method mux.Handler("POST", "/fusion", app.contentTypeCheck(http.HandlerFunc(app.fuseMateria))) return app.chainMiddlewares(mux) } func (app *application) chainMiddlewares(next http.Handler) http.Handler { middlewares := []func(http.Handler) http.Handler{ app.recoverPanic, app.apiTimeout, app.rateLimiter, } for _, middleware := range middlewares { next = middleware(next) } return next }
Ajoutez les méthodes suivantes à
func (app *application) unsupportedMediaType(w http.ResponseWriter, r *http.Request) { message := fmt.Sprintf("The %s Content-Type is not supported", r.Header.Get("Content-Type")) app.errorMessage(w, r, http.StatusUnsupportedMediaType, message, nil) } func (app *application) gatewayTimeout(w http.ResponseWriter, r *http.Request) { message := "Request timed out" app.errorMessage(w, r, http.StatusGatewayTimeout, message, nil) }
package main // MateriaDTO provides Materia details - Name, Description and Type (Magic / Command / Support / Independent) type MateriaDTO struct { Name string `json:"name" example:"Thunder"` Type string `json:"type" example:"Magic"` Description string `json:"description" example:"Shoots lightning forward dealing thunder damage."` } // StatusDTO provides status of the server type StatusDTO struct { Status string `json:"Status" example:"OK"` } // ErrorResponseDTO provides Error message type ErrorResponseDTO struct { Error string `json:"Error" example:"The server encountered a problem and could not process your request"` }
brew install postgresql@17
Le validateur, à partir du code généré, sera utilisé ultérieurement pour valider les champs de saisie du point final de fusion.
Créez le fichier
Ajoutez ce qui suit :
# create the DB createdb materiafusiondb # step into the DB to perform SQL commands psql materiafusiondb
La liste complète des 32 MateriaTypes peut être trouvée ici.
Créez le fichier
Ajoutez ce qui suit :
-- create an SQL user to be used by the Go server CREATE USER go_client WITH PASSWORD 'xxxxxxxx'; -- The Go server doesn't ever need to add data to the DB. -- So let's give it just read permission. CREATE ROLE readonly_role; GRANT USAGE ON SCHEMA public TO readonly_role; -- This command gives SELECT access to all future created tables. ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role; -- If you want to be more strict and give access only to tables that already exist, use this: -- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role; GRANT readonly_role TO go_client;
La liste complète des règles peut être trouvée ici.
CREATE TYPE display_materia_type AS ENUM ('Magic', 'Command', 'Support', 'Independent'); CREATE TYPE materia_type AS ENUM ('Fire', 'Ice', 'Lightning', 'Restore', 'Full Cure', 'Status Defense', 'Defense', 'Absorb Magic', 'Status Magic', 'Fire & Status', 'Ice & Status', 'Lightning & Status', 'Gravity', 'Ultimate', 'Quick Attack', 'Quick Attack & Status', 'Blade Arts', 'Blade Arts & Status', 'Fire Blade', 'Ice Blade', 'Lightning Blade', 'Absorb Blade', 'Item', 'Punch', 'SP Turbo', 'HP Up', 'AP Up', 'ATK Up', 'VIT Up', 'MAG Up', 'SPR Up', 'Dash', 'Dualcast', 'DMW', 'Libra', 'MP Up', 'Anything'); CREATE TABLE materia ( id integer NOT NULL, name character varying(50) NOT NULL, materia_type materia_type NOT NULL, grade integer NOT NULL, display_materia_type display_materia_type, description text CONSTRAINT materia_pkey PRIMARY KEY (id) ); -- The primary key 'id' should auto-increment by 1 for every row entry. CREATE SEQUENCE materia_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE materia_id_seq OWNED BY materia.id; ALTER TABLE ONLY materia ALTER COLUMN id SET DEFAULT nextval('materia_id_seq'::REGCLASS);
Nous utilisons un cache intégré au serveur car :
Mettre à jour main.go :
COPY materia(name,materia_type,grade,display_materia_type,description) FROM '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;
Mettre à jour api/helpers.go :
? codebase ├─ cmd │ └─ api │ ├─ errors.go │ ├─ handlers.go │ ├─ helpers.go │ ├─ main.go │ ├─ middleware.go │ └─ server.go ├─ internal │ ├─ database --- db.go │ ├─ env --- env.go │ ├─ request --- json.go │ ├─ response --- json.go │ └─ validator │ ├─ helpers.go │ └─ validators.go ├─ go.mod ├─ LICENSE ├─ Makefile ├─ README.md └─ README.html
HTTP_PORT=4444 DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable API_TIMEOUT_SECONDS=5 API_CALLS_ALLOWED_PER_SECOND=1
Le code complet du gestionnaire peut être trouvé ici.
Ajouter la bibliothèque Swagger :
go get github.com/joho/godotenv
Dans routes.go, décommentez la ligne Swagger et ajoutez l'importation :
// At the beginning of main(): err := godotenv.Load(".env") // Loads environment variables from .env file if err != nil { // This will be true in prod, but that's fine. fmt.Println("Error loading .env file") } // Modify config struct: type config struct { baseURL string db struct { dsn string } httpPort int apiTimeout int apiCallsAllowedPerSecond float64 } // Modify run() to use the new values from .env: cfg.httpPort = env.GetInt("HTTP_PORT") cfg.db.dsn = env.GetString("DB_DSN") cfg.apiTimeout = env.GetInt("API_TIMEOUT_SECONDS") cfg.apiCallsAllowedPerSecond = float64(env.GetInt("API_CALLS_ALLOWED_PER_SECOND")) // cfg.baseURL = env.GetString("BASE_URL") - not required
Dans les fichiers de gestionnaire, DTO et modèle, ajoutez des commentaires pour la documentation Swagger. Référez-vous à ceci pour toutes les options.
Dans le terminal, exécutez :
go get github.com/didip/tollbooth
Cela crée un dossier api/docs, avec la définition disponible pour Go, JSON et YAML.
Pour le tester, démarrez le serveur local et ouvrez http://localhost:4444/docs.
Structure finale des dossiers :
func (app *application) contentTypeCheck(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Content-Type") != "application/json" { app.unsupportedMediaType(w, r) return } next.ServeHTTP(w, r) }) } func (app *application) rateLimiter(next http.Handler) http.Handler { limiter := tollbooth.NewLimiter(app.config.apiCallsAllowedPerSecond, nil) limiter.SetIPLookups([]string{"X-Real-IP", "X-Forwarded-For", "RemoteAddr"}) return tollbooth.LimitHandler(limiter, next) } func (app *application) apiTimeout(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { timeoutDuration := time.Duration(app.config.apiTimeout) * time.Second ctx, cancel := context.WithTimeout(r.Context(), timeoutDuration) defer cancel() r = r.WithContext(ctx) done := make(chan struct{}) go func() { next.ServeHTTP(w, r) close(done) }() select { case <-done: return case <-ctx.Done(): app.gatewayTimeout(w, r) return } }) }
Depuis votre instance de base de données locale, exécutez :
brew install postgresql@17
Connexion à l'instance distante :
# create the DB createdb materiafusiondb # step into the DB to perform SQL commands psql materiafusiondb
Valeurs :
La dernière pièce du puzzle.
-- create an SQL user to be used by the Go server CREATE USER go_client WITH PASSWORD 'xxxxxxxx'; -- The Go server doesn't ever need to add data to the DB. -- So let's give it just read permission. CREATE ROLE readonly_role; GRANT USAGE ON SCHEMA public TO readonly_role; -- This command gives SELECT access to all future created tables. ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly_role; -- If you want to be more strict and give access only to tables that already exist, use this: -- GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role; GRANT readonly_role TO go_client;
Cela obligera Cloud Build à créer le fichier certs/root.crt dans notre projet avant le début de la build, afin que le Dockerfile y ait accès même si nous ne l'avons jamais poussé vers notre référentiel Github.
Et c’est tout. Essayez de pousser un commit et vérifiez si la build se déclenche. Le tableau de bord Cloud Run affichera l'URL de votre serveur Go hébergé.
Pour les questions liées à « Pourquoi avez-vous fait X et pas Y ? » lisez ceci.
Pour tout ce que vous souhaitez savoir ou discuter, allez ici ou commentez ci-dessous.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!