Let's Go Further مهاجرت‌های SQL › کار با مهاجرت‌های SQL
قبلی · فهرست مطالب · بعدی
فصل ۶.۲.

کار با مهاجرت‌های SQL

حالا که ابزار migrate نصب شده، بیایید نحوهٔ استفاده از آن را با ایجاد یک جدول movies جدید در پایگاه داده‌مان نشان دهیم.

اولین کاری که باید انجام دهیم تولید یک جفت فایل مهاجرت با استفاده از دستور migrate create است. اگر همراه ما کدنویسی می‌کنید، دستور زیر را در ترمینال خود اجرا کنید:

$ migrate create -seq -ext=.sql -dir=./migrations create_movies_table
/home/alex/Projects/greenlight/migrations/000001_create_movies_table.up.sql
/home/alex/Projects/greenlight/migrations/000001_create_movies_table.down.sql

در این دستور:

اگر در دایرکتوری migrations خود نگاه کنید، حالا باید یک جفت فایل مهاجرت ‘up’ و ‘down’ جدید به این شکل ببینید:

./migrations/
├── 000001_create_movies_table.down.sql
└── 000001_create_movies_table.up.sql

در حال حاضر این دو فایل جدید کاملاً خالی هستند. بیایید فایل مهاجرت ‘up’ را ویرایش کنیم تا عبارت CREATE TABLE لازم برای جدول movies ما در آن قرار بگیرد، به این شکل:

فایل: migrations/000001_create_movies_table.up.sql
CREATE TABLE IF NOT EXISTS movies (
    id bigserial PRIMARY KEY,  
    created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
    title text NOT NULL,
    year integer NOT NULL,
    runtime integer NOT NULL,
    genres text[] NOT NULL,
    version integer NOT NULL DEFAULT 1
);

دقت کنید که فیلدها و نوع‌های داده در این جدول مشابه فیلدها و نوع‌های داده در ساختار Movie هستند که قبلاً ایجاد کردیم؟ این مهم است چون به این معناست که می‌توانیم به‌راحتی داده‌های هر ردیف جدول movies را به یک ساختار Movie واحد در کد Go خودمان نگاشت کنیم.

اگر با نوع‌های دادهٔ مختلف PostgreSQL در عبارت SQL بالا آشنا نیستید، مستندات رسمی مرور جامعی ارائه می‌دهد. اما مهم‌ترین نکاتی که باید به آن‌ها اشاره کنیم عبارتند از:

خب، بیایید به سراغ مهاجرت ‘down’ برویم و عبارات SQL لازم برای بازگرداندن مهاجرت ‘up’ که نوشتیم را اضافه کنیم.

فایل: migrations/000001_create_movies_table.down.sql
DROP TABLE IF EXISTS movies;

دستور DROP TABLE در PostgreSQL همیشه تمام ایندکس‌ها و محدودیت‌های موجود روی جدول هدف را حذف می‌کند، بنابراین همین یک عبارت برای بازگرداندن ‘up’ کافی است.

عالی است، اولین جفت فایل‌های مهاجرت ما آماده شد!

در همین حین، بیایید یک جفت فایل مهاجرت دیگر حاوی محدودیت‌های CHECK ایجاد کنیم تا برخی از قوانین تجاری خود را در سطح پایگاه داده اعمال کنیم. به‌طور خاص، می‌خواهیم مطمئن شویم که مقدار runtime همیشه بزرگ‌تر از صفر باشد، مقدار year بین ۱۸۸۸ و سال جاری باشد، و آرایه genres همیشه بین ۱ تا ۵ مورد داشته باشد.

باز هم، اگر همراه ما کدنویسی می‌کنید، دستور زیر را برای ایجاد یک جفت فایل مهاجرت دیگر اجرا کنید:

$ migrate create -seq -ext=.sql -dir=./migrations add_movies_check_constraints
/home/alex/Projects/greenlight/migrations/000002_add_movies_check_constraints.up.sql
/home/alex/Projects/greenlight/migrations/000002_add_movies_check_constraints.down.sql

و سپس عبارات SQL زیر را به ترتیب برای افزودن و حذف محدودیت‌های CHECK اضافه کنید:

فایل: migrations/000002_add_movies_check_constraints.up.sql
ALTER TABLE movies ADD CONSTRAINT movies_runtime_check CHECK (runtime >= 0);

ALTER TABLE movies ADD CONSTRAINT movies_year_check CHECK (year BETWEEN 1888 AND date_part('year', now()));

ALTER TABLE movies ADD CONSTRAINT genres_length_check CHECK (array_length(genres, 1) BETWEEN 1 AND 5);
فایل: migrations/000002_add_movies_check_constraints.down.sql
ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_runtime_check;

ALTER TABLE movies DROP CONSTRAINT IF EXISTS movies_year_check;

ALTER TABLE movies DROP CONSTRAINT IF EXISTS genres_length_check;

وقتی داده‌ای را در جدول movies درج یا به‌روزرسانی می‌کنیم، اگر هر کدام از این بررسی‌ها fail شود، درایور پایگاه داده خطایی مشابه این برمی‌گرداند:

pq: new row for relation "movies" violates check constraint "movies_year_check"

اجرای مهاجرت‌ها

خب، حالا آماده‌ایم دو مهاجرت ‘up’ را روی پایگاه دادهٔ greenlight خود اجرا کنیم.

اگر همراه ما کدنویسی می‌کنید، دستور زیر را برای اجرای مهاجرت‌ها اجرا کنید و DSN پایگاه داده را از متغیر محیطی خود وارد کنید. اگر همه چیز به‌درستی پیکربندی شده باشد، باید خروجی‌هایی مشابه این ببینید که تأیید می‌کند مهاجرت‌ها با موفقیت اجرا شده‌اند:

$ migrate -path=./migrations -database=$GREENLIGHT_DB_DSN up
1/u create_movies_table (38.19761ms)
2/u add_movies_check_constraints (63.284269ms)

در این مرحله، ارزش دارد که یک اتصال به پایگاه دادهٔ خود باز کنید و جداول را با دستور فرعی \dt فهرست کنید:

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight: 
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

greenlight=> \dt
                List of relations
 Schema |       Name        | Type  |   Owner    
--------+-------------------+-------+------------
 public | movies            | table | greenlight
 public | schema_migrations | table | greenlight
(2 rows)

باید ببینید که جدول movies ایجاد شده، همراه با جدول schema_migrations که هر دو متعلق به کاربر greenlight هستند.

جدول schema_migrations به‌صورت خودکار توسط ابزار migrate تولید می‌شود و برای ردیابی اینکه کدام مهاجرت‌ها اعمال شده‌اند استفاده می‌شود. بیایید نگاهی سریع به داخل آن بیندازیم:

greenlight=> SELECT * FROM schema_migrations;
 version | dirty 
---------+-------
       2 | f
(1 row)

ستون version در اینجا نشان می‌دهد که فایل‌های مهاجرت ما تا شمارهٔ 2 در ترتیب (شامل آن) روی پایگاه داده اجرا شده‌اند. مقدار ستون dirty برابر false است، که نشان می‌دهد فایل‌های مهاجرت بدون هیچ خطایی به‌صورت تمیز اجرا شده‌اند و عبارات SQL حاوی آن‌ها به‌طور کامل با موفقیت اعمال شده‌اند.

اگر می‌خواهید، می‌توانید دستور فرعی \d را روی جدول movies اجرا کنید تا ساختار جدول را ببینید و تأیید کنید که محدودیت‌های CHECK به‌درستی ایجاد شده‌اند. به این شکل:

greenlight-> \d movies
                                        Table "public.movies"
   Column   |            Type             | Collation | Nullable |              Default               
------------+-----------------------------+-----------+----------+------------------------------------
 id         | bigint                      |           | not null | nextval('movies_id_seq'::regclass)
 created_at | timestamp(0) with time zone |           | not null | now()
 title      | text                        |           | not null | 
 year       | integer                     |           | not null | 
 runtime    | integer                     |           | not null | 
 genres     | text[]                      |           | not null | 
 version    | integer                     |           | not null | 1
Indexes:
    "movies_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "genres_length_check" CHECK (array_length(genres, 1) >= 1 AND array_length(genres, 1) <= 5)
    "movies_runtime_check" CHECK (runtime >= 0)
    "movies_year_check" CHECK (year >= 1888 AND year::double precision <= date_part('year'::text, now()))

اطلاعات تکمیلی

مهاجرت به یک نسخهٔ خاص

به جای نگاه کردن به جدول schema_migrations، اگر می‌خواهید ببینید پایگاه دادهٔ شما در حال حاضر روی کدام نسخهٔ مهاجرت است، می‌توانید دستور version ابزار migrate را اجرا کنید، به این شکل:

$ migrate -path=./migrations -database=$EXAMPLE_DSN version
2

همچنین می‌توانید با استفاده از دستور goto به یک نسخهٔ خاص مهاجرت کنید:

$ migrate -path=./migrations -database=$EXAMPLE_DSN goto 1

اجرای مهاجرت‌های down

شما می‌توانید از دستور down برای بازگرداندن تعداد مشخصی از مهاجرت‌ها استفاده کنید. برای مثال، برای بازگرداندن آخرین مهاجرت باید اجرا کنید:

$ migrate -path=./migrations -database=$EXAMPLE_DSN down 1

من شخصاً ترجیح می‌دهم از دستور goto برای بازگردانی استفاده کنم (چون صریح‌تر روی نسخهٔ هدف است) و استفاده از دستور down را برای بازگرداندن تمام مهاجرت‌ها نگه دارم، مانند این:

$ migrate -path=./migrations -database=$EXAMPLE_DSN down
Are you sure you want to apply all down migrations? [y/N]
y
Applying all down migrations
2/d create_bar_table (39.988791ms)
1/d create_foo_table (59.460276ms)

نمونهٔ دیگری از این دستور، drop است که تمام جداول پایگاه داده از جمله جدول schema_migrations را حذف می‌کند — اما خود پایگاه داده باقی می‌ماند، همراه با هر چیزی که قبلاً ایجاد شده مثل دنباله‌ها و enumها. به همین دلیل، استفاده از drop می‌تواند پایگاه داده را در وضعیت نامرتب و نامشخصی قرار دهد، و به‌طور کلی بهتر است اگر می‌خواهید همه چیز را بازگردانید، از دستور down استفاده کنید.

رفع خطاها در مهاجرت‌های SQL

مهم است که دربارهٔ آنچه هنگام ایجاد خطای نحوی در فایل‌های مهاجرت SQL اتفاق می‌افتد صحبت کنیم، زیرا رفتار ابزار migrate در ابتدا می‌تواند کمی گیج‌کننده باشد.

وقتی یک مهاجرت حاوی خطا اجرا می‌کنید، همهٔ عبارات SQL تا قبل از عبارت خطادار اعمال می‌شوند و سپس ابزار migrate با پیامی که خطا را توضیح می‌دهد خارج می‌شود. چیزی شبیه به این:

$ migrate -path=./migrations -database=$EXAMPLE_DSN up
1/u create_foo_table (36.6328ms)
2/u create_bar_table (71.835442ms)
error: migration failed: syntax error at end of input in line 0: CREATE TABLE (details: pq: syntax error at end of input)

اگر فایل مهاجرتی که با خطا مواجه شد شامل چندین عبارت SQL باشد، ممکن است فایل مهاجرت تا حدی اعمال شده باشد قبل از اینکه خطا رخ دهد. در نتیجه، این بدان معناست که پایگاه داده از دید ابزار migrate در وضعیت نامعلومی قرار دارد.

بر این اساس، فیلد version در جدول schema_migrations عدد مهاجرت ناموفق را نشان می‌دهد و فیلد dirty روی true تنظیم می‌شود. در این شرایط، اگر مهاجرت دیگری اجرا کنید (حتی مهاجرت down)، پیام خطایی مشابه زیر دریافت خواهید کرد:

Dirty database version {X}. Fix and force version.

کاری که باید انجام دهید این است که خطای اصلی را بررسی کنید و بفهمید آیا فایل مهاجرتی که با خطا مواجه شد تا حدی اعمال شده است یا نه. اگر چنین بود، باید آن مهاجرت نیمه‌اعمال‌شده را به‌صورت دستی بازگردانید.

وقتی این کار انجام شد، همچنین باید عدد version را در جدول schema_migrations به مقدار صحیح ‘force’ کنید. برای مثال، برای قرار دادن شماره نسخهٔ پایگاه داده روی 1 باید از دستور force به این صورت استفاده کنید:

$ migrate -path=./migrations -database=$EXAMPLE_DSN force 1

پس از اعمال force روی نسخه، پایگاه داده به‌عنوان ‘پاک’ در نظر گرفته می‌شود و باید بتوانید دوباره مهاجرت‌ها را بدون مشکل اجرا کنید.

فایل‌های مهاجرت از راه دور

ابزار migrate همچنین از خواندن فایل‌های مهاجرت از منابع از راه دور شامل Amazon S3 و مخزن‌های GitHub پشتیبانی می‌کند. برای مثال:

$ migrate -source="s3://<bucket>/<path>" -database=$EXAMPLE_DSN up
$ migrate -source="github://owner/repo/path#ref" -database=$EXAMPLE_DSN up
$ migrate -source="github://user:personal-access-token@owner/repo/path#ref" -database=$EXAMPLE_DSN up

اطلاعات بیشتر دربارهٔ این قابلیت و فهرست کامل منابع از راه دور پشتیبانی‌شده را می‌توانید اینجا پیدا کنید.

اجرای مهاجرت‌ها هنگام راه‌اندازی برنامه

اگر می‌خواهید، امکان استفاده از پکیج Go golang-migrate/migrate (نه ابزار خط فرمان) برای اجرای خودکار مهاجرت‌های پایگاه داده هنگام راه‌اندازی برنامه نیز وجود دارد.

ما از این روش در کتاب استفاده نخواهیم کرد، پس اگر همراه ما کدنویسی می‌کنید، لطفاً کد خود را تغییر ندهید. اما به‌طور کلی، الگو چیزی شبیه به این است:

package main

import (
    "context"      
    "database/sql" 
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/golang-migrate/migrate/v4"                   // New import
    "github.com/golang-migrate/migrate/v4/database/postgres" // New import
    _ "github.com/golang-migrate/migrate/v4/source/file"     // New import
    _ "github.com/lib/pq"
)

func main() {
    ...

    db, err := openDB(cfg)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
    defer db.Close()

    logger.Info("database connection pool established")

    migrationDriver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }

    migrator, err := migrate.NewWithDatabaseInstance("file:///path/to/your/migrations", "postgres", migrationDriver)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }

    err = migrator.Up()
    if err != nil && err != migrate.ErrNoChange {
        logger.Error(err.Error())
        os.Exit(1)
    }
    
    logger.Info("database migrations applied")

    ...
}

اگرچه این روش کار می‌کند — و ممکن است در ابتدا جذاب به نظر برسد — اتصال قوی اجرای مهاجرت‌ها به کد منبع برنامه می‌تواند در بلندمدت محدودکننده و مشکل‌ساز باشد.

مقالهٔ جداسازی مهاجرت‌های پایگاه داده از شروع سرور بحث خوبی در این زمینه ارائه می‌دهد، و پیشنهاد می‌کنم اگر این موضوع برای شما جالب است آن را بخوانید. این مقاله روی پایتون تمرکز دارد، اما گول این را نخورید — اصول مشابه در برنامه‌های Go هم صادق است.