首頁  >  文章  >  後端開發  >  使用 Go、PostgreSQL、Google Cloud 和 CockroachDB 建立 API

使用 Go、PostgreSQL、Google Cloud 和 CockroachDB 建立 API

Linda Hamilton
Linda Hamilton原創
2024-10-24 07:02:30758瀏覽

我使用 Go 和 PostgreSQL 建立了一個 API,使用 Google Cloud Run、Cloud Build、Secret Manager 和 ArtifactRegistry 設定了 CI/CD 管道,並將 Cloud Run 實例連接到 CockroachDB。

此API基於遊戲《核心危機:最終幻想VII》,模擬「物質融合」。本文的目標受眾是只想了解如何建置和部署 API 的開發人員。我還有另一篇文章,其中討論了我在從事該專案時學到的所有內容、哪些內容不起作用,以及理解和翻譯遊戲的材質融合規則(連結即將推出)。

方便參考的鏈接

  • GitHub 儲存庫與自述文件
  • Swagger (OpenAPI) 文件與測驗
  • 公共郵差收藏
  • 領域模型來源

API目標

3 個端點 - 健康檢查 (GET)、所有材料清單 (GET) 和模擬材料融合 (POST)

領域模型

物質(單數和複數)是一個水晶球,作為魔法的來源。遊戲中有 144 種不同的材質,大致分為 4 類:「魔法」、「指令」、「支援」和「獨立」。然而,為了弄清楚物質融合的規則,根據它們的融合行為,更容易有32個內部類別,以及這些類別內的8個等級(參見參考資料) .

一種材料在使用一定時間後就會變得「精通」。持續時間在這裡並不重要。

最重要的是,兩種材質可以融合產生一種新材質。融合規則受以下因素影響:

  • 是否掌握其中一種或兩種材料。
  • 哪一種材料先出現(如 X Y 不一定等於 Y X)。
  • 材質內部類別。
  • 材質等級。

Building an API with Go, PostgreSQL, Google Cloud and CockroachDB

並且有許多例外,其中一些規則具有 3 層嵌套的 if-else 邏輯。這消除了在資料庫中建立一個簡單表格並將 1000 條規則儲存到其中的可能性,或想出一個公式來規則所有規則的可能性。

簡而言之,我們需要:

  1. 一個表材質,其中包含列name(string)、materia_type(ENUM)(32 個內部類別)、grade(integer)、display_materia_type(ENUM)(遊戲中使用的4 個類別)、description(string) 和id(整數)作為自增主鍵。
  2. 封裝基本規則格式MateriaTypeA MateriaTypeB = MateriaTypeC的資料結構。
  3. 使用基本和複雜規則來決定輸出材料的內部類別和等級的代碼。

1. 設定本機 PostgreSQL 資料庫

理想情況下,您可以從網站本身安裝資料庫。但是pgAdmin工具因為某些原因無法連接到DB,所以我使用了Homebrew。

安裝

brew install postgresql@17

這將安裝一大堆 CLI 二進位檔案以幫助使用資料庫。

可選:將 /opt/homebrew/opt/postgresql@17/bin 加入 $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);

新增數據

建立包含表頭和資料的 Excel 工作表,並將其匯出為 CSV 檔案。然後運行命令:

COPY materia(name,materia_type,grade,display_materia_type,description) FROM
 '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;

2. 建立Go伺服器

使用 autostrada.dev 建立樣板程式碼。新增 api、postgresql、httprouter、env var config、tintedlogging、git、live reload、makefile 的選項。我們最後得到這樣的文件結構:

? 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

.env 文件

樣板產生器已建立程式碼來取得環境變數並將它們新增至程式碼中,但我們可以更輕鬆地追蹤和更新值。

建立 /.env 檔案。新增以下值:

HTTP_PORT=4444
DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable
API_TIMEOUT_SECONDS=5
API_CALLS_ALLOWED_PER_SECOND=1

新增 godotenv 庫:

go get github.com/joho/godotenv

將以下內容加入 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

中介軟體和路由

樣板已經有一個中間件可以從恐慌中恢復。我們將新增另外 3 個:Content-Type 檢查、速率限制和 API 逾時保護。

新增收費站庫:

go get github.com/didip/tollbooth

更新

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
  }
 })
}

中間件需要加入到路由中。它們可以添加到所有路由,也可以添加到特定路由。在我們的範例中,僅 POST 請求需要 Content-Type 檢查(即強制輸入標頭包含 Content-Type: application/json)。所以修改routes.go如下:

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
}

錯誤處理

/api/errors.go 中加入以下方法來幫助中間件功能:

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)
}

請求和響應結構文件

/api/dtos.go :

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"`
}

/api/requests.go :

brew install postgresql@17

產生程式碼中的驗證器稍後將用於驗證融合端點的輸入欄位。

組合規則的資料結構

建立檔案 /internal/crisis-core-materia-fusion/constants.go

加入以下內容:

# create the DB
createdb materiafusiondb
# step into the DB to perform SQL commands
psql materiafusiondb

32 種 MateriaType 的完整清單可以在此處找到。

建立檔案 /internal/crisis-core-materia-fusion/models.go

加入以下內容:

-- 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;

完整的規則清單可以在這裡找到。

api/handlers.go 中材質的處理程序

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);

伺服器內緩存

我們使用伺服器內緩存,因為:

  1. 從資料庫取得的資料永遠不會改變。
  2. 材質和融合端點使用相同的資料。

更新main.go:

COPY materia(name,materia_type,grade,display_materia_type,description) FROM
 '<path_to_csv_file>/materiadata.csv' DELIMITER ',' CSV HEADER;

更新 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

api/handlers.go 中的融合處理程序

HTTP_PORT=4444
DB_DSN=go_client:<password>@localhost:5432/materiafusiondb?sslmode=disable
API_TIMEOUT_SECONDS=5
API_CALLS_ALLOWED_PER_SECOND=1

完整的處理程序程式碼可以在這裡找到。

Swagger UI 和 OpenAPI 定義文檔

新增 Swagger 庫:

go get github.com/joho/godotenv

在routes.go中取消註解Swagger行,並新增導入:

// 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

在處理程序、DTO 和模型檔案中,新增 Swagger 文件的註解。請參閱此以了解所有選項。

在終端機中,運作:

go get github.com/didip/tollbooth

這將建立一個 api/docs 資料夾,其中包含可用於 Go、JSON 和 YAML 的定義。

要測試它,請啟動本機伺服器並開啟 http://localhost:4444/docs。


最終資料夾結構:

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
  }
 })
}

3. 在 CockroachDB 中設定遠端 PostgreSQL 實例

  1. 使用此處的步驟。
  2. 建立憑證後,在專案中建立 /certs/root.crt 並在其中新增憑證。我們稍後將在 Google Run 配置中引用此文件。
  3. 警告! 我們將此資料夾推送到遠端儲存庫。將 certs/ 資料夾新增至 .gitignore。如果您願意,我們在本地創建證書只是為了測試連接。
  4. 現在,當您前往 CockroachDB → 儀表板 → 左側選單 → 資料庫時,您應該可以看到您建立的資料庫。

遷移

從本機資料庫執行個體中,執行:

brew install postgresql@17
  1. 到 CockroachDB → 左側選單 → 遷移 → 新增架構 → 拖曳剛剛取得的 SQL 檔案。除表資料插入之外的所有步驟都將運行。它還會向您顯示已執行的步驟清單。
  2. 在撰寫本文時,CockroachDB 中的 PostgreSQL 實例不支援 IMPORT INTO 等語句。因此,我必須在本機 SQL 檔案中建立 270 行的 INSERT 語句(我們可以從剛剛獲得的 pg_dump 輸出得出)。
  3. 登入遠端實例,並執行SQL檔案。

登入遠端實例:

# create the DB
createdb materiafusiondb
# step into the DB to perform SQL commands
psql materiafusiondb

4. 部署 Google Cloud Run 實例

  1. 像這樣建立一個 Dockerfile。
  2. 前往 Google Cloud Run 並為 API 建立一個新專案。
  3. 建立服務→ 從儲存庫持續部署使用雲端建置進行設定儲存庫提供者 = Github → 選擇您的儲存庫→ 建置類型 = Dockerfile → 儲存。
  4. 驗證 = 允許未經授權的呼叫
  5. 大多數預設值應該就可以了。
  6. 向下捲動至貨櫃 → 貨櫃港口 = 4444。
  7. 選擇變數和秘密選項卡,然後加入與本地 .env 檔案相同的環境變數。

價值觀:

  1. HTTP_PORT = 4444
  2. DB_DSN = ?sslmode=verify-full&sslrootcert=/app/certs/root.crt
  3. API_TIMEOUT_SECONDS = 5
  4. API_CALLS_ALLOWED_PER_SECOND = 1

使用 Google Secret Manager 取得證書

最後一塊拼圖。

  1. 搜尋 Secret Manager → 建立 Secret → Name = ‘DB_CERT’ → 上傳 CockroachDB 的 .crt 憑證。
  2. 在 Cloud Run →(您的服務)→ 點選 編輯持續部署 → 向下捲動至設定 → 開啟編輯器。
  3. 將此新增為第一步:
-- 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;

這將使 Cloud Build 在建置開始之前在我們的專案中建立檔案 certs/root.crt,以便 Dockerfile 可以存取它,即使我們從未將其推送到我們的 Github 儲存庫。


就是這樣。嘗試推送提交並檢查建置是否觸發。 Cloud Run 儀表板將顯示您所託管的 Go 伺服器的 URL。


有關「為什麼你做了 X 而不是 Y?」的問題讀這個。

對於您想了解或討論的任何其他內容,請訪問此處,或在下面發表評論。

以上是使用 Go、PostgreSQL、Google Cloud 和 CockroachDB 建立 API的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn