تراکنشها و جزئیات دیگر (Transactions and Other Details)
در این بخش، به بررسی تراکنشهای پایگاه داده (Database Transactions) و سایر جزئیات مهم در کار با پایگاه داده میپردازیم.
یک تراکنش (Transaction) مجموعهای از عملیات پایگاه داده است که به صورت یک واحد اتمی (Atomic Unit) اجرا میشوند - یعنی یا همه عملیات با موفقیت انجام میشوند یا هیچ کدام انجام نمیشوند.
پکیج database/sql (The database/sql Package)
همانطور که احتمالاً متوجه شدهاید، پکیج database/sql اساساً یک رابط استاندارد (Standard Interface) بین برنامه Go شما و دنیای پایگاههای داده SQL فراهم میکند.
تا زمانی که از پکیج database/sql استفاده کنید، کد Go که مینویسید به طور کلی قابل حمل خواهد بود و با هر نوع پایگاه داده SQL کار خواهد کرد — چه MySQL، PostgreSQL، SQLite یا چیز دیگری باشد. این بدان معناست که برنامه شما به پایگاه دادهای که در حال حاضر استفاده میکنید به شدت متصل نیست و تئوری این است که میتوانید پایگاههای داده را در آینده بدون بازنویسی تمام کد خود تعویض کنید (با در نظر گرفتن نکات خاص درایور و پیادهسازیهای SQL).
مهم است که توجه داشته باشید که در حالی که database/sql به طور کلی کار خوبی در ارائه یک رابط استاندارد برای کار با پایگاههای داده SQL انجام میدهد، برخی از خصوصیات خاص در نحوه عملکرد درایورها و پایگاههای داده مختلف وجود دارد. همیشه ایده خوبی است که قبل از شروع استفاده از یک درایور جدید، مستندات آن را مرور کنید تا هر گونه نکات و موارد خاص را درک کنید.
پرگویی (Verbosity)
اگر از Ruby، Python یا PHP آمدهاید، کد برای پرس و جو از پایگاههای داده SQL ممکن است کمی پرگو به نظر برسد، به خصوص اگر به کار با یک لایه انتزاعی (Abstraction Layer) یا ORM عادت کرده باشید.
اما مزیت پرگویی این است که کد ما غیر جادویی است؛ ما میتوانیم دقیقاً بفهمیم و کنترل کنیم که چه اتفاقی میافتد. و با گذشت زمان، الگوهای ایجاد پرس و جوهای SQL آشنا میشوند و میتوانید از کارهای قبلی کپی و پیست کنید، یا از ابزارهای توسعهدهنده مانند GitHub copilot برای نوشتن پیشنویس اول کد استفاده کنید.
اگر واقعاً پرگویی شروع به اذیت کردن شما کرده است، ممکن است بخواهید پکیج jmoiron/sqlx را امتحان کنید. این پکیج به خوبی طراحی شده و برخی از افزونههای خوبی را ارائه میدهد که کار با پرس و جوهای SQL را سریعتر و آسانتر میکند. گزینه دیگری که ممکن است بخواهید در نظر بگیرید، پکیج blockloop/scan است.
مدیریت مقادیر null (Managing NULL Values)
یکی از چیزهایی که 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
به طور تقریبی، راهحل این است که فیلدی که در حال اسکن آن هستید را از نوع string به نوع sql.NullString تغییر دهید. برای یک مثال کاری به این گیت مراجعه کنید.
اما به عنوان یک قاعده، سادهترین کار این است که به سادگی از مقادیر NULL اجتناب کنید. محدودیتهای NOT NULL را بر روی تمام ستونهای پایگاه داده خود تنظیم کنید، همانطور که در این کتاب انجام دادهایم، همراه با مقادیر DEFAULT معقول در صورت لزوم.
کار با تراکنشها (Working with Transactions)
مهم است که بدانید که فراخوانیهای Exec()، Query() و QueryRow() میتوانند از هر اتصالی از استخر sql.DB (Connection Pool) استفاده کنند. حتی اگر دو فراخوانی Exec() بلافاصله در کنار یکدیگر در کد شما باشند، هیچ تضمینی وجود ندارد که از همان اتصال پایگاه داده استفاده کنند.
گاهی اوقات این قابل قبول نیست. به عنوان مثال، اگر یک جدول را با دستور LOCK TABLES در MySQL قفل کنید، باید UNLOCK TABLES را دقیقاً بر روی همان اتصال فراخوانی کنید تا از بنبست جلوگیری کنید.
برای اطمینان از اینکه از همان اتصال استفاده میشود، میتوانید چندین دستور را در یک تراکنش بپیچید. در اینجا الگوی پایه آمده است:
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() در صورت بروز هر گونه خطا استفاده کنید، تراکنش تضمین میکند که یا:
- همه دستورات با موفقیت اجرا میشوند؛ یا
- هیچ دستوری اجرا نمیشود و پایگاه داده بدون تغییر باقی میماند.
دستورات آماده (Prepared Statements)
همانطور که قبلاً اشاره کردم، روشهای 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() — بدون آمادهسازی دستورات خودتان — یک نقطه شروع معقول است.
واژهنامه اصطلاحات فنی
| اصطلاح فارسی | معادل انگلیسی | توضیح |
|---|---|---|
| تراکنشهای پایگاه داده | Database Transactions | مجموعهای از عملیات پایگاه داده که به صورت یک واحد اتمی اجرا میشوند |
| تراکنش | Transaction | مجموعهای از عملیات که باید به صورت یک واحد کامل اجرا شوند |
| واحد اتمی | Atomic Unit | عملیاتی که یا به طور کامل اجرا میشوند یا اصلاً اجرا نمیشوند |
| رابط استاندارد | Standard Interface | رابط برنامهنویسی یکسان برای کار با پایگاههای داده مختلف |
| پرگویی | Verbosity | میزان جزئیات و طول کد مورد نیاز برای بیان یک عملیات |
| لایه انتزاعی | Abstraction Layer | لایهای که پیچیدگی عملیات پایینتر را پنهان میکند |
| مقادیر null | NULL Values | مقادیر خالی یا نامشخص در پایگاه داده |
| دستورات آماده | Prepared Statements | دستورات SQL که از قبل کامپایل شده و برای اجرای مکرر بهینه شدهاند |
| حملات تزریق SQL | SQL Injection Attacks | حملات امنیتی که از آسیبپذیریهای کوئری SQL سوء استفاده میکنند |
| استخر اتصال | Connection Pool | مجموعهای از اتصالات پایگاه داده که برای استفاده مجدد نگهداری میشوند |