蒼時弦也
蒼時弦也
資深軟體工程師
發表於

資料庫抽換 - SQLite(一) - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

使用 BoltDB(或者 NoSQL)類型的資料庫跟我們原生的 InMemory 模式還是相當接近,那麼利用 Repository 抽象化的特性轉換成 RDBMS(關連式資料庫)是否可行呢?答案是肯定的,我們會使用 sqlc 來支援 SQLite 或者 PostgreSQL 和 MySQL。

sqlc 設定

sqlc 跟我們前面使用的 Code Generate 工具(如:oapi-codegen、protoc)都是用於生成程式碼為目標,這次我們會讓 sqlc 幫我們根據資料庫的定義生成我們所需的實作,再封裝到 Repository 之中。

在專案根目錄加入 sqlc.yaml 並且放入以下內容

 1version: "2"
 2sql:
 3  - engine: "sqlite"
 4    schema: "db/schema.sql"
 5    queries:
 6      - "db/tokens.sql"
 7      - "db/orders.sql"
 8    gen:
 9      go:
10        package: "sqlite"
11        out: "internal/repository/sqlite"

這個設定告訴 sqlc 要針對 SQLite 產生相關的實作,並且從 db/schema.sql 來偵測資料表的樣式(Schema)以及 db/tokens.sqldb/orders.sql 來取得我們會對資料庫進行操作的方式。

在生成的 Golang 程式碼中,我們選擇生成到 internal/repository/sqlite 目錄下,作為 Repository 實作的一個子套件。

Schema

將 sqlc 設定完畢後,我們需要描述資料表的儲存方式,因此在 db/schema.sql 這個檔案寫入以下內容。

 1CREATE TABLE IF NOT EXISTS orders (
 2  id            VARCHAR(36) PRIMARY KEY,
 3  customer_name VARCHAR(255) NOT NULL
 4);
 5
 6CREATE TABLE IF NOT EXISTS order_items (
 7  id          VARCHAR(36) PRIMARY KEY,
 8  order_id    VARCHAR(36) NOT NULL,
 9  name        VARCHAR(255) NOT NULL,
10  quantity    INT NOT NULL,
11  unit_price  INT NOT NULL,
12  FOREIGN KEY (order_id) REFERENCES orders(id)
13);
14
15CREATE TABLE IF NOT EXISTS tokens (
16  id      VARCHAR(36) PRIMARY KEY,
17  data    BLOB NOT NULL,
18  version VARCHAR(255) NOT NULL
19);

跟 NoSQL 不同的地方在於我們無法將 OrderItem 直接跟 Order 保存在一起,基於資料表正規化的處理,會獨立出另一個單獨的 order_items 資料表,作為範例我們只有簡單的設定一個 FOREIGN KEY 設定,在實際的應用中可以根據需求調整設計。

除了使用單一的 Schema 檔案外,sqlc 也能支援以 Migration 的方式來生成,以 goose 為例子,我們可以將設定調整為目錄。

1version: "2"
2sql:
3  - engine: "sqlite"
4    schema: "db/migrations"
5    # ...

此時只要有類似這樣的檔案結構,sqlc 就能依序偵測資料表的結構,生成正確的 struct 進行對應。

  • 0_create_orders.sql
  • 1_create_order_items.sql
  • 2_create_tokens.sql

觸發條件是 CREATE TABLEALTER TABLE 只要符合偵測的規則,不使用官方測試過的套件理論上也能很好的運行。

Queries

使用 sqlc 跟 oapi-codegen 的邏輯很類似,我們都是在描述如何呼叫 API,只是這裡換成對資料庫的查詢是如何進行的。

我們先加入比較簡單的 db/tokens.sql 描述讀取、新增 Token 情境的操作。

 1-- name: FindToken :one
 2SELECT * FROM tokens WHERE id = ?
 3LIMIT 1;
 4
 5-- name: CreateToken :one
 6INSERT INTO tokens (
 7  id, data, version
 8) VALUES (
 9  ?, ?, ?
10)
11RETURNING *;

在這裡我們利用 sqlc 可以辨識的註解 -- name: [name] [type] 對兩段 SQL 進行標記,第一段是讀取 Token 的動作,我們使用了 FindToken 來命名,並且表示只有單一筆資料回傳,因此用 :one 進行標記。

因為不考慮更新的情境,所以直接使用了 INSERT INTO 來處理,此時使用 RETURNING * 表示要回傳一個儲存後的結果,如果用不到我們也可以不額外加入這樣的設定。

接著繼續加入 db/orders.sql 的查詢。

 1-- name: FindOrder :one
 2SELECT * FROM orders WHERE id = ?
 3LIMIT 1;
 4
 5-- name: ListOrderItems :many
 6SELECT * FROM order_items
 7WHERE order_id = ?;
 8
 9-- name: CreateOrder :one
10INSERT INTO orders (
11  id, customer_name
12) VALUES (
13  ?, ?
14) RETURNING *;
15
16-- name: CreateOrderItem :one
17INSERT INTO order_items (
18  id, order_id, name, quantity, unit_price
19) VALUES (
20  ?, ?, ?, ?, ?
21) RETURNING *;

基本上跟 db/tokens.sql 差不多,因為這次我們需要對兩張資料表進行操作,因此除了 Order 本身還需要加入 OrderItem 相關的查詢。

因為我們是直接使用 db/schema.sql 的方式處理,並且希望直接將資料表維跟 Golang 生成的二進制檔案放在一起,因此還需要額外做 db/db.go 這個檔案,用於後續的資料庫初始化,如果是使用其他工具,可以根據需求調整使用方式。

1package db
2
3import (
4	_ "embed"
5)
6
7//go:embed schema.sql
8var Schema string

到此為止,我們在安裝 sqlc 後使用 sqlc generate 這個命令產生 SQLite 相關的實作,接下來會說明轉換成 Repository 需要做的處理。