Irgendwann kommt jedes Unternehmen an einen Scheideweg, an dem es innehalten und die von ihm verwendeten Tools neu bewerten muss. Für uns kam dieser Moment, als uns klar wurde, dass die API, die unser Web-Dashboard antreibt, nicht mehr zu verwalten und schwer zu testen war und nicht den Standards entsprach, die wir für unsere Codebasis festgelegt hatten.
Arcjet ist in erster Linie ein Security-as-Code-SDK, das Entwicklern bei der Implementierung von Sicherheitsfunktionen wie Bot-Erkennung, E-Mail-Validierung und PII-Erkennung hilft. Dies kommuniziert mit unserer leistungsstarken, Entscheidungs-gRPC-API mit geringer Latenz.
Unser Web-Dashboard verwendet eine separate REST-API in erster Linie zur Verwaltung von Site-Verbindungen und zur Überprüfung der Analyse verarbeiteter Anfragen. Dazu gehört jedoch auch die Anmeldung neuer Benutzer und die Verwaltung ihres Kontos, was bedeutet, dass dies immer noch ein wichtiger Teil des Produkts ist.
Screenshot der Seite zur Anforderungsanalyse des Arcjet-Dashboards.
Deshalb haben wir uns entschieden, die Herausforderung anzunehmen, unsere API von Grund auf neu zu erstellen – dieses Mal mit Schwerpunkt auf Wartbarkeit, Leistung und Skalierbarkeit. Allerdings wollten wir uns nicht auf ein riesiges Umschreibungsprojekt einlassen – das nie gut klappt – stattdessen haben wir beschlossen, eine neue Grundlage zu schaffen und dann mit einem einzigen API-Endpunkt zu beginnen.
In diesem Beitrag werde ich besprechen, wie wir das angegangen sind.
Als Geschwindigkeit für uns oberste Priorität hatte, bot Next.js die bequemste Lösung zum Erstellen von API-Endpunkten, die unser Frontend nutzen konnte. Es ermöglichte uns eine nahtlose Full-Stack-Entwicklung innerhalb einer einzigen Codebasis und wir mussten uns nicht allzu viele Gedanken über die Infrastruktur machen, da wir auf Vercel bereitgestellt haben.
Unser Fokus lag auf unserer Sicherheit als Code-SDK und Entscheidungs-API mit geringer Latenz, sodass wir mit diesem Stack für das Frontend-Dashboard schnell und ohne Reibungsverluste Prototypen von Funktionen erstellen konnten.
Unser Stack: Next.js, DrizzleORM, useSWR, NextAuth
Als sich unser Produkt weiterentwickelte, stellten wir jedoch fest, dass die Kombination aller unserer API-Endpunkte und Frontend-Codes im selben Projekt zu einem Durcheinander führte.
Das Testen unserer API wurde umständlich (und ist mit Next.js ohnehin sehr schwierig), und wir brauchten ein System, das sowohl intern als auch externVerbrauch. Als wir uns in weitere Plattformen (wie Vercel, Fly.io und Netlify) integriert haben, wurde uns klar, dass die Entwicklungsgeschwindigkeit allein nicht ausreichte. Wir brauchten eine robustere Lösung.
Im Rahmen dieses Projekts wollten wir auch ein anhaltendes Sicherheitsproblem ansprechen, das darin besteht, dass Vercel von Ihnen verlangt, Ihre Datenbank öffentlich zugänglich zu machen. Sofern Sie nicht für die Unternehmens-„sichere Datenverarbeitung“ bezahlen, ist für die Verbindung mit einer Remote-Datenbank ein öffentlicher Endpunkt erforderlich. Wir ziehen es vor, unsere Datenbank zu sperren, sodass der Zugriff nur über ein privates Netzwerk möglich ist. Eine umfassende Verteidigung ist wichtig und dies wäre eine weitere Schutzebene.
Dies führte dazu, dass wir beschlossen, die Frontend-Benutzeroberfläche von der Backend-API zu entkoppeln.
Was ist „The Golden API“? Es handelt sich nicht um eine bestimmte Technologie oder ein bestimmtes Framework, sondern vielmehr um eine Reihe idealer Prinzipien, die eine gut aufgebaute API definieren. Während Entwickler möglicherweise ihre eigenen Vorlieben für Sprachen und Frameworks haben, gibt es bestimmte Konzepte, die für alle Tech-Stacks gültig sind und auf die sich die meisten für die Erstellung einer hochwertigen API einigen können.
Wir haben bereits Erfahrung in der Bereitstellung leistungsstarker APIs.Unsere Entscheidungs-API wird in der Nähe unserer Kunden bereitgestellt, nutzt Kubernetes zur dynamischen Skalierung und ist für Antworten mit geringer Latenz optimiert .
Wir haben serverlose Umgebungen und andere Anbieter in Betracht gezogen, aber da unsere bestehenden k8s-Cluster bereits in Betrieb waren, war es am sinnvollsten, die vorhandene Infrastruktur wiederzuverwenden: Bereitstellungen durch Octopus Deploy, Überwachung durch Grafana Jaeger, Loki, Prometheus usw.
Nach einem kurzen internen Rust-vs-Go-Test entschieden wir uns für Go aufgrund seiner Einfachheit, Geschwindigkeit und weil es seine ursprünglichen Ziele einer hervorragenden Unterstützung für den Aufbau skalierbarer Netzwerkdienste erreicht . Wir verwenden es auch bereits für die Entscheidungs-API und verstehen, wie man Go-APIs bedient, was die Entscheidung für uns finalisiert hat.
Die Umstellung der Backend-API auf Go war dank ihrer Einfachheit und der Verfügbarkeit großartiger Tools unkompliziert. Aber es gab einen Haken: Wir behielten das Next.js-Frontend bei und wollten weder TypeScript-Typen manuell schreiben noch eine separate Dokumentation für unsere neue API pflegen.
Jetzt kommt OpenAPI ins Spiel – genau das Richtige für unsere Anforderungen. OpenAPI ermöglicht es uns, einen Vertrag zwischen Frontend und Backend zu definieren und dient gleichzeitig als unsere Dokumentation. Dies löst das Problem der Pflege eines Schemas für beide Seiten der App.
Die Integration der Authentifizierung in Go war nicht allzu schwierig, da NextAuth im Backend relativ einfach nachzuahmen war. NextAuth (jetzt Auth.js) verfügt über APIs zur Überprüfung einer Sitzung.
Das bedeutete, dass wir einen TypeScript-Client im Frontend haben konnten, der aus unserer OpenAPI-Spezifikation generiert wurde und Abrufaufrufe an die Backend-API durchführte. Die Anmeldeinformationen werden automatisch in den Abrufaufruf einbezogen und das Backend kann die Sitzung mit NextAuth überprüfen.
Das Schreiben jeglicher Art von Tests in Go ist sehr einfach und es gibt viele Beispiele, die das Thema Testen von HTTP-Handlern abdecken.
Im Vergleich zur Next.js-API ist es auch viel einfacher, Tests für die neuen Go-API-Endpunkte zu schreiben, insbesondere weil wir authentifizierte Status- und echte Datenbankaufrufe testen möchten. Mit Testcontainern konnten wir problemlos Tests für den Gin-Router schreiben und echte Integrationstests für unsere Postgres-Datenbank starten.
Wir begannen mit dem Schreiben der OpenAPI 3.0-Spezifikation für unsere API. Ein OpenAPI-First-Ansatz fördert die Gestaltung des API-Vertrags vor der Implementierung und stellt sicher, dass sich alle Beteiligten (Entwickler, Produktmanager und Kunden) über das Verhalten und die Struktur der API einigen, bevor Code geschrieben wird. Es fördert eine sorgfältige Planung und führt zu einem gut durchdachten API-Design, das konsistent ist und etablierten Best Practices entspricht. Aus diesen Gründen haben wir uns dafür entschieden, zuerst die Spezifikation zu schreiben und daraus den Code zu generieren, und nicht umgekehrt.
Mein bevorzugtes Tool hierfür warAPI Fiddle, mit dem Sie schnell OpenAPI-Spezifikationen entwerfen und testen können. Allerdings unterstützt API Fiddle nur OpenAPI 3.1 (das wir nicht verwenden konnten, weil viele Bibliotheken es nicht übernommen hatten), also sind wir bei Version 3.0 geblieben und haben die Spezifikation von Hand geschrieben.
Hier ist ein Beispiel dafür, wie die Spezifikation für unsere API aussah:
openapi: 3.0.0 info: title: Arcjet Sites API description: A CRUD API to manage sites. version: 1.0.0 servers: - url: <https://api.arcjet.com/v1> description: Base URL for all API operations paths: /teams/{teamId}/sites: get: operationId: GetTeamSites summary: Get a list of sites for a team description: Returns a list of all Sites associated with a given Team. parameters: - name: teamId in: path required: true description: The ID of the team schema: type: string responses: "200": description: A list of sites content: application/json: schema: type: array items: $ref: "#/components/schemas/Site" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: Site: type: object properties: id: type: string description: The ID of the site name: type: string description: The name of the site teamId: type: string description: The ID of the team this site belongs to createdAt: type: string format: date-time description: The timestamp when the site was created updatedAt: type: string format: date-time description: The timestamp when the site was last updated deletedAt: type: string format: date-time nullable: true description: The timestamp when the site was deleted (if applicable) Error: required: - code - message - details properties: code: type: integer format: int32 description: Error code message: type: string description: Error message details: type: string description: Details that can help resolve the issue
Nachdem die OpenAPI-Spezifikation vorhanden war, verwendeten wir OAPI-codegen, ein Tool, das automatisch Go-Code aus der OpenAPI-Spezifikation generiert. Es generiert alle notwendigen Typen, Handler und Fehlerbehandlungsstrukturen, was den Entwicklungsprozess wesentlich reibungsloser macht.
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../../api.yaml
Die Ausgabe bestand aus einer Reihe von Go-Dateien, von denen eine das Servergerüst und eine andere die Handler-Implementierungen enthielt. Hier ist ein Beispiel für einen Go-Typ, der für das Site-Objekt generiert wurde:
// Site defines model for Site. type Site struct { // CreatedAt The timestamp when the site was created CreatedAt *time.Time `json:"createdAt,omitempty"` // DeletedAt The timestamp when the site was deleted (if applicable) DeletedAt *time.Time `json:"deletedAt"` // Id The ID of the site Id *string `json:"id,omitempty"` // Name The name of the site Name *string `json:"name,omitempty"` // TeamId The ID of the team this site belongs to TeamId *string `json:"teamId,omitempty"` // UpdatedAt The timestamp when the site was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty"` }
Mit dem generierten Code konnten wir die API-Handler-Logik wie folgt implementieren:
func (s Server) GetTeamSites(w http.ResponseWriter, r *http.Request, teamId string) { ctx := r.Context() // Check user has permission to access team resources isAllowed, err := s.userIsAllowed(ctx, teamId) if err != nil { slog.ErrorContext( ctx, "failed to check permissions", slogext.Err("err", err), slog.String("teamId", teamId), ) SendInternalServerError(ctx, w) return } if !isAllowed { SendForbidden(ctx, w) return } // Retrieve sites from database sites, err := s.repo.GetSitesForTeam(ctx, teamId) if err != nil { slog.ErrorContext( ctx, "list sites for team query returned an error", slogext.Err("err", err), slog.String("teamId", teamId), ) SendInternalServerError(ctx, w) return } SendOk(ctx, w, sites) }
Drizzle ist ein großartiges ORM für JS-Projekte und wir würden es wieder verwenden, aber die Verschiebung des Datenbankcodes aus Next.js bedeutete, dass wir etwas Ähnliches für Go brauchten.
Wir haben GORM als unser ORM ausgewählt und das Repository-Muster verwendet, um Datenbankinteraktionen zu abstrahieren. Dadurch konnten wir saubere, testbare Datenbankabfragen schreiben.
type ApiRepo interface { GetSitesForTeam(ctx context.Context, teamId string) ([]Site, error) } type apiRepo struct { db *gorm.DB } func (r apiRepo) GetSitesForTeam(ctx context.Context, teamId string) ([]Site, error) { var sites []Site result := r.db.WithContext(ctx).Where("team_id = ?", teamId).Find(&sites) if result.Error != nil { return nil, ErrorNotFound } return sites, nil }
Testen ist für uns von entscheidender Bedeutung. Wir wollten sicherstellen, dass alle Datenbankaufrufe ordnungsgemäß getestet wurden, deshalb haben wir Testcontainer verwendet, um eine echte Datenbank für unsere Tests aufzubauen, die unserem Produktionsaufbau genau entspricht.
openapi: 3.0.0 info: title: Arcjet Sites API description: A CRUD API to manage sites. version: 1.0.0 servers: - url: <https://api.arcjet.com/v1> description: Base URL for all API operations paths: /teams/{teamId}/sites: get: operationId: GetTeamSites summary: Get a list of sites for a team description: Returns a list of all Sites associated with a given Team. parameters: - name: teamId in: path required: true description: The ID of the team schema: type: string responses: "200": description: A list of sites content: application/json: schema: type: array items: $ref: "#/components/schemas/Site" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: Site: type: object properties: id: type: string description: The ID of the site name: type: string description: The name of the site teamId: type: string description: The ID of the team this site belongs to createdAt: type: string format: date-time description: The timestamp when the site was created updatedAt: type: string format: date-time description: The timestamp when the site was last updated deletedAt: type: string format: date-time nullable: true description: The timestamp when the site was deleted (if applicable) Error: required: - code - message - details properties: code: type: integer format: int32 description: Error code message: type: string description: Error message details: type: string description: Details that can help resolve the issue
Nach dem Einrichten der Testumgebung haben wir alle CRUD-Vorgänge wie in der Produktion getestet, um sicherzustellen, dass sich unser Code korrekt verhält.
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../../api.yaml
Zum Testen unserer API-Handler haben wir das httptest-Paket von Go verwendet und die Datenbankinteraktionen mithilfe von Mockery simuliert. Dadurch konnten wir uns auf das Testen der API-Logik konzentrieren, ohne uns um Datenbankprobleme kümmern zu müssen.
// Site defines model for Site. type Site struct { // CreatedAt The timestamp when the site was created CreatedAt *time.Time `json:"createdAt,omitempty"` // DeletedAt The timestamp when the site was deleted (if applicable) DeletedAt *time.Time `json:"deletedAt"` // Id The ID of the site Id *string `json:"id,omitempty"` // Name The name of the site Name *string `json:"name,omitempty"` // TeamId The ID of the team this site belongs to TeamId *string `json:"teamId,omitempty"` // UpdatedAt The timestamp when the site was last updated UpdatedAt *time.Time `json:"updatedAt,omitempty"` }
Nachdem unsere API getestet und bereitgestellt wurde, richteten wir unsere Aufmerksamkeit auf das Frontend.
Unsere vorherigen API-Aufrufe erfolgten über den von Next.js empfohlenen Abruf-API mit integriertem Caching. Für dynamischere Ansichten verwendeten einige Komponenten zusätzlich zum Abruf SWR könnte typsichere, automatisch nachladende Datenabrufaufrufe erhalten.
Um die API im Frontend zu nutzen, haben wir die Bibliothek openapi-typescript verwendet, die TypeScript-Typen basierend auf unserem OpenAPI-Schema generiert. Dies machte es einfach, das Backend mit unserem Frontend zu integrieren, ohne Datenmodelle manuell synchronisieren zu müssen. Darin ist Tanstack Query integriert, das Fetch unter der Haube nutzt, aber auch mit unserem Schema synchronisiert wird.
Wir migrieren die API-Endpunkte schrittweise auf den neuen Go-Server und nehmen dabei kleine Verbesserungen vor. Wenn Sie Ihren Browser-Inspektor öffnen, sehen Sie, dass diese neuen Anfragen an api.arcjet.com gehen
Screenshot des Browser-Inspektors, der einen API-Aufruf an das neue Go-Backend zeigt.
Haben wir also die schwer fassbare Golden API erreicht? Aktivieren wir das Kontrollkästchen:
Wir gingen weiter mit:
Am Ende sind wir mit den Ergebnissen zufrieden. Unsere API ist schneller, sicherer und besser getestet. Der Übergang zu Go hat sich gelohnt und wir sind jetzt besser in der Lage, unsere API zu skalieren und zu pflegen, wenn unser Produkt wächst.
Das obige ist der detaillierte Inhalt vonÜberdenken unserer REST-API: Aufbau der Golden API. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!