2
1

RDBの半構造化データをスキーマ定義してアプリで扱いやすくする

Posted at

はじめに

RDBでもJSONなどで半構造化データを保存することはありますよね?データに柔軟性を持たせるためとはいえ、データのパターンやパターンごとに持ち得るプロパティ、各プロパティの型にある程度制限をかけたいケースは少なくないと思います。

今回はgo製アプリケーションでの読み書きに絞って、データの持ち方・制限のかけ方を検証してみようと思います。

記事中のコードは関連箇所のみ抽出して記述しています。リンク先のコードで実際の動作を確認できます。
https://github.com/h-rym/qiita_sample_code.git

前準備

dockerでもなんでも良いのでpostgresが起動している前提で進めます。

docker run --name postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=test_database -p 5432:5432 -d postgres

今回はシンプルにjsonb型のカラムのみ持つIssueテーブルを題材に進めていきます。
下のコードはテーブル生成用のgoスクリプトです。

func main() {
	// データベースの接続情報
	host := "localhost"
	port := 5432
	user := "postgres"
	password := "password"
	dbname := "test_database"

	// データベースに接続
	connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", host, port, user, password, dbname)
	db, err := sql.Open("postgres", connStr)
	if err != nil {
		fmt.Println("データベースに接続できませんでした:", err)
		return
	}
	defer db.Close()

	// Issueテーブルを作成
	// 既に存在すれば作り直す
	// カラム: properties(jsonb)
	if _, err := db.Exec("DROP TABLE IF EXISTS issue"); err != nil {
		fmt.Println("テーブルを削除できませんでした:", err)
		return
	}
	if _, err := db.Exec("CREATE TABLE IF NOT EXISTS issue (properties jsonb)"); err != nil {
		fmt.Println("テーブルを作成できませんでした:", err)
		return
	}
}

Issueテーブルに格納されるJSONはアプリケーションの仕様で2パターンあるとします。
idreporter_idprioritystatus はどちらのパターンにも存在するプロパティです。

BugIssue

  • severityversion_affectedがBugIssueのみに存在するプロパティ
// 例)
{
    "id": 1,
    "reporter_id": 11,
    "priority": 1,
    "status": "new",
    "severity": "low",
    "version_affected": "1.0.0"
}

FeatureRequestIssue

  • sponsor_idがFeatureRequestIssueのみに存在するプロパティ
// 例)
{
    "id": 2,
    "reporter_id": 22,
    "priority": 2,
    "status": "pending",
    "sponsor_id": 222
}

永続化されるデータを制限する

前述のBugIssueとFeatureRequestIssueを型定義してみました。
REST API等でJSON形式で渡されることを想定しています。

type BugOptions struct {
    Severity        string `json:"severity" validate:"required"`
    VersionAffected string `json:"version_affected" validate:"required"`
}

type FeatureRequestOptions struct {
    SponsorID int `json:"sponsor_id" validate:"required"`
}

type Issue struct {
    ID         int    `json:"id" validate:"required"`
    ReporterID int    `json:"reporter_id" validate:"required"`
    Priority   int    `json:"priority" validate:"required"`
    Status     string `json:"status" validate:"required"`
    *BugOptions
    *FeatureRequestOptions
}

func isBugIssue(issue Issue) bool {
    return issue.BugOptions != nil
}

func isFeatureRequestIssue(issue Issue) bool {
    return issue.FeatureRequestOptions != nil
}

実際にDBにInsertする処理は以下のようになります。

func main() {
    // 実際のアプリケーションではHTTP requestでJSONデータが届く
    jsonData := `{
        "id": 1,
        "reporter_id": 11,
        "priority": 1,
        "status": "new",
        "severity": "low",
        "version_affected": "1.0.0"
    }`
    
    // 構造体への詰め替え
    issue := Issue{}
    if err := json.Unmarshal([]byte(jsonData), &issue); err != nil {
        fmt.Println("JSONデータを構造体に変換できませんでした")
        return
    }

    // バリデーション
    // 今回は必須チェックのみ
    validate := validator.New()
    if err := validate.Struct(issue); err != nil {
        fmt.Printf("バリデーションエラーです。 %#v\n", issue)
        return
    }

    // BugIssueかつFeatureRequestIssueはあり得ないという仕様
    if isBugIssue(issue) == isFeatureRequestIssue(issue) {
	fmt.Printf("正しくないデータです %#v\n", issue)
	return
    }

    // データベースに保存
    newJsonData, err := json.Marshal(issue)
    if err != nil {
	fmt.Printf("JSONデータを作成できませんでした。 %#v\n", err)
    	return
    }
    if _, err = db.Exec("INSERT INTO issue (properties) VALUES ($1)", string(newJsonData)); err != nil {
        fmt.Println("データを保存できませんでした")
	return
    }

    fmt.Println("データを保存しました")
}

このコードを実行した後、実際にDBの中身を確認すると下のようになっています。

# select * from issue;

properties
---------------------------------------------------------------------------------
 {"id": 1, "status": "new", "priority": 1, "severity": "low", "reporter_id": 11}
(1 row)

共通プロパティをまとめた構造体にパターン固有プロパティをまとめた構造体を埋め込ませるのがポイントです。

  • 送信側に制限をかけていなければ、json_data.bug_options.severityのようにグルーピングされず、全データがフラットに一階層で渡されることが想定されます。構造体の埋め込みであれば、JSONとしてはフラットだが、構造体としてはパターン固有データとして扱うことができます。
  • また、固有プロパティ構造体の有無でタイプの判定を行うことができます。上例のデータであればissue.FeatureRequestOptionsnilです。
  • バリデーションライブラリを使うことでプロパティの不足を防ぎます。

例 不完全なBugIssueデータ

  • 型定義時にVersionAffectedに対してvalidate:"required"カスタムタグを与えているため、下記JSONデータはvalidate.Struct(issue)で弾かれます。
    jsonData := `{
        "id": 1,
        "reporter_id": 11,
        "priority": 1,
        "status": "new",
        "severity": "low"
	}`

例 BugIssue かつ FeatureRequestIssueなデータ

  • isBugIssue(issue) == isFeatureRequestIssue(issue)の判定で弾きます。
  • アプリケーションの仕様に応じて、許容したりデータを削ったりすることもあると思います。
    jsonData := `{
        "id": 1,
        "reporter_id": 11,
        "priority": 1,
        "status": "new",
        "severity": "low",
        "version_affected": "1.0.0",
        "sponsor_id": 100
	}`

改善点

半構造化状態でデータを保持したい理由はやはり柔軟性。Issueのパターンを手軽に増減させたいからだと思います。その観点から型判定の実装に改善の余地があるのかなと考えています。
isBugIssue(issue) == isFeatureRequestIssue(issue) でどちらかのパターンであることを判定していますが、パターンの数が増えたり複合パターンを許容し出すと一気に複雑になりそうです。また、メソッドの中身も単純にnil判定をしているだけなので都度メソッドを定義する必要もないです。

DBから取り出したデータを適切なパターンごとに扱いやすくする

シード投入

テストデータを用意します。前述の制限をかける前 or 別経路からデータが登録されているケースを想定します。

jsonDataList := []string{
    // Bug Issue 1
    `{
        "id": 1,
        "reporter_id": 11,
        "priority": 1,
        "status": "new",
        "severity": "low",
        "version_affected": "1.0.0"
    }`,
    // Bug Issue 2
    `{
        "id": 2,
        "reporter_id": 22,
        "priority": 2,
        "status": "new",
        "severity": "middle",
        "version_affected": "1.0.0"
    }`,
    // Feature Request Issue
    `{
        "id": 3,
        "reporter_id": 33,
        "priority": 3,
        "status": "new",
        "sponsor_id": 333
    }`,
    // Bug Issue 余分なデータを含む
    `{
        "id": 4,
        "reporter_id": 44,
        "priority": 4,
        "status": "new",
        "severity": "high",
        "version_affected": "1.0.0",
        "extra": "extra data"
    }`,
    // Bug Issue 必須カラム不足
    `{
        "id": 5,
        "reporter_id": 55,
        "priority": 5,
        "status": "new",
        "severity": "high"
    }`,
    // Bug Issue かつ Feature Request Issue
    `{
        "id": 6,
        "reporter_id": 66,
        "priority": 6,
        "status": "new",
        "severity": "high",
        "version_affected": "1.0.0",
        "sponsor_id": 666
    }`,
}

for _, jsonData := range jsonDataList {
    if _, err = db.Exec("INSERT INTO issue (properties) VALUES ($1)", jsonData); err != nil {
        fmt.Println("データを保存できませんでした")
        return
    }
}

実装例

完全なBugIssueパターンのデータ(ID: 1, 2, 4)のみ抽出する実装例です。

type BugOptions struct {
	Severity        string `json:"severity" validate:"required"`
	VersionAffected string `json:"version_affected" validate:"required"`
}

type FeatureRequestOptions struct {
	SponsorID int `json:"sponsor_id" validate:"required"`
}

type Issue struct {
	ID         int    `json:"id" validate:"required"`
	ReporterID int    `json:"reporter_id" validate:"required"`
	Priority   int    `json:"priority" validate:"required"`
	Status     string `json:"status" validate:"required"`
	*BugOptions
	*FeatureRequestOptions
}

func isBugIssue(issue Issue) bool {
	return issue.BugOptions != nil
}

func isFeatureRequestIssue(issue Issue) bool {
	return issue.FeatureRequestOptions != nil
}

type BugIssue struct {
	ID         int    `json:"id" validate:"required"`
	ReporterID int    `json:"reporter_id" validate:"required"`
	Priority   int    `json:"priority" validate:"required"`
	Status     string `json:"status" validate:"required"`
	BugOptions
}

func main() {
    // 中略

    // Issueテーブルからデータを取得
    rows, err := db.Query("SELECT properties FROM issue")
    if err != nil {
        fmt.Println("データを取得できませんでした:", err)
        return
    }
    defer rows.Close()

    bugIssueList := []BugIssue{}
    for rows.Next() {
        var jsonData string
        if err := rows.Scan(&jsonData); err != nil {
        	fmt.Println("データを取得できませんでした:", err)
        	continue
        }

        // 取得したデータをIssue構造体に変換
        var issue Issue
            if err := json.Unmarshal([]byte(jsonData), &issue); err != nil {
            	fmt.Println("データを変換できませんでした:", jsonData)
            	continue
            }

            // バリデーション
            validate := validator.New()
            if err := validate.Struct(issue); err != nil {
                fmt.Printf("バリデーションエラーです。 %#v\n", issue)
                continue
            }

            if isBugIssue(issue) == isFeatureRequestIssue(issue) {
                fmt.Printf("正しくないデータです %#v\n", issue)
                continue
            }

            if !isBugIssue(issue) {
                continue
            }

            bugIssueList = append(bugIssueList, BugIssue{
                ID:         issue.ID,
                ReporterID: issue.ReporterID,
                Priority:   issue.Priority,
                Status:     issue.Status,
                BugOptions: *issue.BugOptions,
            })
	}

    // Bug Issueのリストを表示
    for _, bugIssue := range bugIssueList {
        // Bug Issueに対して処理を行う
        fmt.Printf("Bug Issue: %#v\n", bugIssue)
    }
}

Insert時の型定義やバリデーションを転用できるので目新しい実装はそれほど無いと思います。

一旦全てのデータをIssue型で持ち、条件を満たすデータのみBugIssue型に詰め直しています。そうすることで、利用の都度キーの有無を意識しなくて済むようにしています。

注意点は、仕方のないことですが、Issueテーブルの全レコードをアプリケーション側に持ってきてからフィルターしています。今回は簡単のためIssueテーブルにjsonb型のカラムしか持たせていませんが、実際のアプリでは用途に合わせて別のカラムを持たせ、一度に取得するレコード数を減らす工夫が必要になると思います。例えば、Issueは必ず1つのプロジェクトに紐づく、Issueはプロジェクト単位で取り扱うという仕様があれば、Project IDを別カラムに持たせるのが良いです。

Insert時同様、複合パターンの取り扱い、欠損有りデータの取り扱いはアプリの仕様に合わせて決めていくところだと思います。また、本例ではFeatureRequestIssueを破棄していますが、Issue型で一旦全てを保持し、フィルターメソッドを介してデータを扱う方が最適なこともあると思います。

終わりに

アプリケーションで半構造化データを扱う際、上記の対応を行うことである程度秩序をもたらすことができそうです。
しかし、半構造化データカラムの治安が悪くなるのは同カラムを読み書きするアプリが複数になった時だと考えています。
そうなると、スキーマをgoの構造体で定義するのは十分ではなさそうです。次回は汎用的なスキーマ言語を用いてgoだけでなく他言語でも同じ定義を利用する方法を考えていきたいと思います。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1