Let's Go پاسخ‌های مبتنی بر پایگاه داده › تراکنش‌ها و جزئیات دیگر
قبلی · فهرست · بعدی
فصل 4.9.

تراکنش‌ها و جزئیات دیگر

پکیج database/sql

همانطور که احتمالاً شروع به درک کرده‌اید، پکیج database/sql اساساً یک رابط استاندارد بین برنامه Go شما و دنیای پایگاه‌های داده SQL ارائه می‌دهد.

تا زمانی که از پکیج database/sql استفاده می‌کنید، کد Go که می‌نویسید به طور کلی قابل حمل خواهد بود و با هر نوع پایگاه داده SQL کار خواهد کرد — چه MySQL، PostgreSQL، SQLite یا چیز دیگری باشد. این به معنای این است که برنامه شما به پایگاه داده که در حال حاضر استفاده می‌کنید خیلی محکم گره خورده نیست، و نظریه این است که می‌توانید در آینده پایگاه‌های داده را بدون بازنویسی تمام کد خود عوض کنید (ویژگی‌های خاص درایور و پیاده‌سازی‌های SQL کنار گذاشته شده).

مهم است که توجه داشته باشید که در حالی که database/sql به طور کلی کار خوبی در ارائه یک رابط استاندارد برای کار با پایگاه‌های داده SQL انجام می‌دهد، برخی ویژگی‌های خاص در نحوه عملکرد درایورها و پایگاه‌های داده مختلف وجود دارد. همیشه ایده خوبی است که مستندات یک درایور جدید را بخوانید تا هر ویژگی خاص و موارد لبه را قبل از شروع استفاده از آن درک کنید.

طولانی بودن

اگر از Ruby، Python یا PHP می‌آیید، کد برای پرس‌وجوی پایگاه‌های داده SQL ممکن است کمی طولانی به نظر برسد، به خصوص اگر به کار با یک لایه انتزاعی یا ORM عادت کرده باشید.

اما جنبه مثبت طولانی بودن این است که کد ما غیرجادویی (non-magical) است؛ می‌توانیم دقیقاً آنچه اتفاق می‌افتد را درک و کنترل کنیم. و با کمی زمان، متوجه خواهید شد که الگوهای ساخت پرس‌وجوهای SQL آشنا می‌شوند و می‌توانید از کار قبلی کپی و پیست کنید، یا از ابزارهای توسعه‌دهنده مانند GitHub copilot برای نوشتن پیش‌نویس اول کد برای شما استفاده کنید.

اگر طولانی بودن واقعاً شروع به آزار شما می‌کند، ممکن است بخواهید پکیج jmoiron/sqlx را امتحان کنید. به خوبی طراحی شده است و برخی افزونه‌های خوب ارائه می‌دهد که کار با پرس‌وجوهای SQL را سریع‌تر و آسان‌تر می‌کند. گزینه دیگر و جدیدتری که ممکن است بخواهید در نظر بگیرید پکیج blockloop/scan است.

مدیریت مقادیر null

یک چیز که Go خیلی خوب انجام نمی‌دهد مدیریت مقادیر NULL در رکوردهای پایگاه داده است.

بیایید وانمود کنیم که ستون title در جدول snippets ما حاوی یک مقدار NULL در یک ردیف خاص است. اگر آن ردیف را پرس‌وجو کنیم، سپس rows.Scan() خطای زیر را برمی‌گرداند چون نمی‌تواند NULL را به یک رشته تبدیل کند:

sql: Scan error on column index 1: unsupported Scan, storing driver.Value type
<nil> into type *string

به طور بسیار تقریبی، راه‌حل این است که فیلدی که به آن scan می‌کنید را از یک string به یک نوع sql.NullString تغییر دهید. برای یک مثال کارآمد این gist را ببینید.

اما، به عنوان یک قاعده، ساده‌ترین کار این است که به سادگی از مقادیر NULL به طور کامل اجتناب کنید. محدودیت‌های NOT NULL را روی همه ستون‌های پایگاه داده خود تنظیم کنید، همانطور که در این کتاب انجام داده‌ایم، همراه با مقادیر منطقی DEFAULT در صورت نیاز.

کار با تراکنش‌ها

مهم است که درک کنید که فراخوانی‌های Exec()، Query() و QueryRow() می‌توانند از هر اتصالی در استخر sql.DB استفاده کنند. حتی اگر دو فراخوانی به Exec() بلافاصله در کنار هم در کد خود داشته باشید، هیچ تضمینی وجود ندارد که از همان اتصال پایگاه داده استفاده کنند.

گاهی این قابل قبول نیست. به عنوان مثال، اگر یک جدول را با دستور LOCK TABLES MySQL قفل کنید، باید UNLOCK TABLES را روی دقیقاً همان اتصال فراخوانی کنید تا از deadlock جلوگیری کنید.

برای تضمین اینکه از همان اتصال استفاده می‌شود، می‌توانید چندین دستور را در یک تراکنش بپیچید. این الگوی پایه است:

type ExampleModel struct {
    DB *sql.DB
}

func (m *ExampleModel) ExampleTransaction() error {
    // Calling the Begin() method on the connection pool creates a new sql.Tx
    // object, which represents the in-progress database transaction.
    tx, err := m.DB.Begin()
    if err != nil {
        return err
    }

    // Defer a call to tx.Rollback() to ensure it is always called before the 
    // function returns. If the transaction succeeds it will be already be 
    // committed by the time tx.Rollback() is called, making tx.Rollback() a 
    // no-op. Otherwise, in the event of an error, tx.Rollback() will rollback 
    // the changes before the function returns.
    defer tx.Rollback()

    // Call Exec() on the transaction, passing in your statement and any
    // parameters. It's important to notice that tx.Exec() is called on the
    // transaction object just created, NOT the connection pool. Although we're
    // using tx.Exec() here you can also use tx.Query() and tx.QueryRow() in
    // exactly the same way.
    _, err = tx.Exec("INSERT INTO ...")
    if err != nil {
        return err
    }

    // Carry out another transaction in exactly the same way.
    _, err = tx.Exec("UPDATE ...")
    if err != nil {
        return err
    }

    // If there are no errors, the statements in the transaction can be committed
    // to the database with the tx.Commit() method. 
    err = tx.Commit()
    return err
}

تراکنش‌ها همچنین بسیار مفید هستند اگر می‌خواهید چندین دستور SQL را به عنوان یک عمل اتمی واحد اجرا کنید. تا زمانی که از متد tx.Rollback() در صورت هر خطایی استفاده می‌کنید، تراکنش تضمین می‌کند که یا:

دستورات آماده

همانطور که قبلاً ذکر کردم، متدهای Exec()، Query() و QueryRow() همه از دستورات آماده در پشت صحنه برای کمک به جلوگیری از حملات تزریق SQL استفاده می‌کنند. آن‌ها یک دستور آماده روی اتصال پایگاه داده تنظیم می‌کنند، آن را با پارامترهای ارائه شده اجرا می‌کنند، و سپس دستور آماده را می‌بندند.

این ممکن است نسبتاً ناکارآمد به نظر برسد چون هر بار همان دستورات آماده را ایجاد و دوباره ایجاد می‌کنیم.

در تئوری، یک رویکرد بهتر می‌تواند استفاده از متد DB.Prepare() برای ایجاد یک دستور آماده خودمان یک بار، و استفاده مجدد از آن به جای آن باشد. این به خصوص برای دستورات SQL پیچیده (مثلاً آنهایی که چندین JOIN دارند) و بسیار مکرر تکرار می‌شوند (مثلاً یک درج انبوه از ده‌ها هزار رکورد) صادق است. در این موارد، هزینه آماده‌سازی مجدد دستورات ممکن است تأثیر قابل توجهی روی زمان اجرا داشته باشد.

این الگوی پایه برای استفاده از دستور آماده خودتان در یک برنامه وب است:

// We need somewhere to store the prepared statement for the lifetime of our
// web application. A neat way is to embed it in the model alongside the 
// connection pool.
type ExampleModel struct {
    DB         *sql.DB
    InsertStmt *sql.Stmt
}

// Create a constructor for the model, in which we set up the prepared
// statement.
func NewExampleModel(db *sql.DB) (*ExampleModel, error) {
    // Use the Prepare method to create a new prepared statement for the
    // current connection pool. This returns a sql.Stmt object which represents
    // the prepared statement.
    insertStmt, err := db.Prepare("INSERT INTO ...")
    if err != nil {
        return nil, err
    }

    // Store it in our ExampleModel struct, alongside the connection pool.
    return &ExampleModel{DB: db, InsertStmt: insertStmt}, nil
}

// Any methods implemented against the ExampleModel struct will have access to
// the prepared statement.
func (m *ExampleModel) Insert(args...) error {
    // We then need to call Exec directly against the prepared statement, rather
    // than against the connection pool. Prepared statements also support the
    // Query and QueryRow methods.
    _, err := m.InsertStmt.Exec(args...)

    return err
}

// In the web application's main function we will need to initialize a new
// ExampleModel struct using the constructor function.
func main() {
    db, err := sql.Open(...)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
    defer db.Close()

    // Use the constructor function to create a new ExampleModel struct.
    exampleModel, err := NewExampleModel(db)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }

    // Defer a call to Close() on the prepared statement to ensure that it is
    // properly closed before our main function terminates.
    defer exampleModel.InsertStmt.Close()
}

چند نکته وجود دارد که باید مراقب آن‌ها باشید.

دستورات آماده روی اتصالات پایگاه داده وجود دارند. بنابراین، چون Go از یک استخر از اتصالات پایگاه داده متعدد استفاده می‌کند، آنچه واقعاً اتفاق می‌افتد این است که اولین بار که یک دستور آماده (یعنی شیء sql.Stmt) استفاده می‌شود، روی یک اتصال پایگاه داده خاص ایجاد می‌شود. سپس شیء sql.Stmt به خاطر می‌سپارد که کدام اتصال در استخر استفاده شده است. دفعه بعد، شیء sql.Stmt سعی می‌کند دوباره از همان اتصال پایگاه داده استفاده کند. اگر آن اتصال بسته شده یا در حال استفاده باشد (یعنی بیکار نباشد)، دستور روی اتصال دیگری دوباره آماده می‌شود.

تحت بار سنگین، ممکن است تعداد زیادی دستور آماده روی چندین اتصال ایجاد شود. این می‌تواند منجر به آماده‌سازی و آماده‌سازی مجدد دستورات بیشتر از آنچه انتظار دارید شود — یا حتی برخورد به محدودیت‌های سمت سرور در تعداد دستورات (در MySQL حداکثر پیش‌فرض 16,382 دستور آماده است).

کد همچنین پیچیده‌تر از استفاده نکردن از دستورات آماده است.

بنابراین، یک معامله بین عملکرد و پیچیدگی وجود دارد. همانطور که با هر چیزی، باید مزیت عملکرد واقعی پیاده‌سازی دستورات آماده خودتان را اندازه‌گیری کنید تا تعیین کنید که آیا ارزش انجام دارد. برای بیشتر موارد، پیشنهاد می‌کنم که استفاده از متدهای عادی Query()، QueryRow() و Exec() — بدون آماده‌سازی دستورات خودتان — یک نقطه شروع معقول است.