Tạo chức năng quản lý migrations cho DynamoDB trong project Laravel

Đặt vấn đề

Vừa qua mình có làm một dự án có sử dụng Dynamo DB và mình được phân công là người xây dựng database và migrations cho hệ thống. Nếu bình thường sử dụng với cơ sở dữ liệu MySQL thì mọi việc trở nên đơn giản và chẳng có gì để nói, nhưng ở đây khách hàng yêu cầu dùng cơ sở dữ liệu DynamoDB. Giới thiệu sơ qua thì

DynamoDB là 1 No-SQL Database, 1 phần của Amazon Web Service, có sự ổn định, tốc độ nhanh chóng, thích hợp cho việc lưu trũ, xử lí 1 lượng lớn dữ liệu

Và mọi vấn đề bắt đầu từ đây, Laravel không hỗ trợ connect với DynamoDB. Vậy làm sao để mình có thể quản lý DB bây giờ, trong cái khó ló cái khôn, nó không hỗ trợ thì mình thử tự làm xem nó ra làm sao. Bắt đầu thôi nào...

Giải quyết vấn đề

Đầu tiên khởi tạo 1 project Laravel composer create-project --prefer-dist laravel/laravel dynamodb. Sau đó mình sẽ cài sdk của aws hỗ trợ connect tới DynamoDB composer require aws/aws-sdk-php. Trước hết ta test thử với 1 hàm đơn giản khởi tạo 1 bảng với DynamoDB

$client =  Aws\DynamoDb\DynamoDbClient::factory([
    'region' => 'us-east-1',
    'version' => 'latest',
    'credentials' => [
        'key'    => env('AWS_ACCESS_KEY_ID', ''),
        'secret' => env('AWS_SECRET_ACCESS_KEY', ''),
    ],
]);
$client->createTable([
    'TableName' => 'users',
    'AttributeDefinitions' => [
        [
            'AttributeName' => 'id',
            'AttributeType' => 'S',
        ],
    ],
    'KeySchema' => [
        [
            'AttributeName' => 'id',
            'KeyType'       => 'HASH',
        ],
    ],
    'ProvisionedThroughput' => [
        'ReadCapacityUnits'  => 1,
        'WriteCapacityUnits' => 1,
    ],
]);
$client->waitUntil('TableExists', [
    'TableName' =>  'users',
    '@waiter' => [
        'delay' => 5,
        'maxAttempts' => 20,
    ],
]);

Phía trên là đoạn code để tạo 1 bảng là user, cặp access key và secret key bạn có thể lấy trên AWS Credential bằng cách đăng kí 1 tài khoản free của Amazon Web Service thì sẽ được dùng thử đa số các dịch vụ với nhiều hạn chế mà Amazon quy định, nhưng với DynamoDB thì vẫn đủ để test được. Phần tạo bảng user gồm 2 lệnh 1 là gửi request tạo bảng lên server aws với hàm createTable và sau đó là hàm để đảm bảo table trong trạng thái đã hoạt động waitUntil. Những API này các bạn có thể xem trên document của DynamoDB ở đây. Thử chạy đoạn code trên thì ta sẽ tạo đc 1 bảng trên DynamoDB.

Bây giờ ta sẽ đi vào xây dựng chức năng Migrations cho DynamoDB. Trước hết ta phải tìm hiểu cơ chế hoạt động của Laravel Migrations, sau khi xem code của Laravel ta thấy rằng chức năng migrations hoạt động như sau: Laravel xây dựng sẵn các command để giúp ta tạo ra các file migrations đặt tên theo quy chuẩn thời gian tạo + tên migration. Sau đó sẽ tạo 1 bảng migrations để lưu lại logs của các file đã chạy migrate. Mỗi lần chạy lệnh migrate sẽ lấy logs đó ra và so sánh với các file nằm trong thư mục migrations, nếu file nào chưa có trong logs thì sẽ cho chạy file đó đồng thời ghi thêm logs vào bảng migrations. Nhìn sơ qua thì cơ chế của nó rất đơn giản, ta sẽ bê nguyên cơ chế đó qua để tạo migrations cho DynamoDB. Vậy ta bắt đầu với công đoạn đầu tiên là tạo 1 command để tạo ra các file migration mà bình thường ta hay dùng câu lệnh php artisan make:migration. Mình đã tạo file đó như sau: Đầu tiên là tạo 1 file stub, ý nghĩa của file này chính là nội dung cơ bản của file migrations khi được tạo ra. Ta có 2 tùy chọn chính khi tạo file migrations đó là thêm bảng hoặc chỉnh sửa bảng. Ta có nội dung file stub như sau:

<?php
namespace Database\Migration\DynamoDB;

use App\Console\Commands\DynamoDB\DBClient;

class DummyClass extends DBClient
{
    public function up()
    {
        $this->dbClient->createTable([
            'TableName' => 'DummyTable',
            'AttributeDefinitions' => [
                [
                    'AttributeName' => '<string>',
                    'AttributeType' => 'S|N|B',
                ],
            ],
            'KeySchema' => [
                [
                    'AttributeName' => '<string>',
                    'KeyType'       => 'HASH|RANGE',
                ],
            ],
            'ProvisionedThroughput' => [
                'ReadCapacityUnits'  => 1,
                'WriteCapacityUnits' => 1,
            ]
        ]);
        $this->dbClient->waitUntil('TableExists', [
            'TableName' =>  'DummyTable',
            '@waiter' => [
                'delay' => 5,
                'maxAttempts' => 20,
            ],
        ]);
    }

    /**
     * if cannot rollback set $canRollback = false
     */
    public function down(&$canRollback)
    {
        $this->dbClient->deleteTable([
            'TableName' =>  'DummyTable',
        ]);
        $this->dbClient->waitUntil('TableNotExists', [
            'TableName' => 'DummyTable',
            '@waiter' => [
                'delay' => 5,
                'maxAttempts' => 20,
            ],
        ]);
    }
}

Nhìn lên đoạn code tạo bảng users ở trên ta thấy muốn tạo bảng cần init một biến DynamoDB Client, như vậy để tối ưu hóa code mình đã tạo 1 class DBClient mục đích để các file migrations có thể extends class đó và dùng chung biến $dbClient khởi tạo từ class. Ta lưu lại file trên tại stubs/create.stub. Sau đó ta tạo tiếp command để make migrations, nội dung như sau:

<?php
namespace App\Console\Commands\DynamoDB;

use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Composer;
use Illuminate\Support\Str;

class MakeMigration extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'dynamodb:make_migration {name} {--create=} {--table=}';
    
    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Make migration for DynamoDB';
    
    private $files;
    private $composer;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct(Filesystem $files, Composer $composer)
    {
        parent::__construct();
        $this->files = $files;
        $this->composer = $composer;
    }
    
    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $name = $this->argument('name');
        $create = $this->option('create') ?: false;
        $table = $this->option('table');
        $this->createMigration($name, $table, $create);
    }
    
    private function createMigration($name, $table, $create)
    {
        $migrationsPath = database_path() . '/migrations/dynamodb/';
        if (!$this->files->exists($migrationsPath)) {
            $this->files->makeDirectory($migrationsPath);
        }
        
        $this->writeFile($name, $migrationsPath, $table, $create);
    }
    
    private function writeFile($name, $path, $table, $create)
    {
        $path .= date('Y_m_d_His') . '_' . $name . '.php';
        $stub = $this->getStub($table, $create);
        $this->files->put($path, $this->getContentFile($name, $stub, $table));
        $this->composer->dumpAutoloads();
        $this->line('<info>Created DynamoDB Migration: </info>' . $path);
    }
    
    private function getStub($table, $create)
    {
        $stubsPath = __DIR__ . '/stubs';
        if (!$table) {
            return $this->files->get($stubsPath . '/blank.stub');
        }
        
        $stubFile = $create ? '/create.stub' : '/update.stub';
        
        return $this->files->get($stubsPath . $stubFile);
    }
    
    private function getContentFile($name, $stub, $table)
    {
        $className = Str::studly($name);
        $stub = str_replace('DummyClass', $className, $stub);
        
        return ($table) ? str_replace('DummyTable', $table, $stub) : $stub;
    }
}

Ở đây mình tạo command này với 2 options là createtable, nếu create=true và table= tên của table muốn tạo thì tên table sẽ được replace DummyTable ở trên file stub. Command này khi chạy sẽ tạo ra file migrations ở thư mục databases/migrations/dynamodb/. Bây giờ đến phần quan trọng nhất ta sẽ tạo 1 command migrate để chạy các file migrations đã tạo ở trên. 1 class BaseCommand với nội dung:

<?php
namespace App\Console\Commands\DynamoDB;

use Illuminate\Console\Command;
use Aws\DynamoDb\Exception\DynamoDbException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;

class BaseCommand extends Command
{
    protected $dbClient;
    
    public function __construct()
    {
        parent::__construct();
        $this->dbClient = DBClient::factory();
    }
    
    protected function isTableExists($tableName)
    {
        try {
            $result = $this->dbClient->describeTable([
                'TableName' => $tableName,
            ]);
        } catch (DynamoDbException $e) {
            return false;
        }
        
        return true;
    }
    
    protected function getLastBatchNumber($data)
    {
        return collect($data)->max('batch') ?: 0;
    }
    
    protected function runMigrate($file, $batch)
    {
        $instance = $this->newInstance($file);
        $instance->up();
        $this->writeMigrationLog($file, $batch);
        $this->line('<info>Migrated: </info>' . $file);
    }
    
    protected function getMigrationsData()
    {
        if ($this->isTableExists(config('aws.prefix') . 'migrations')) {
            $results = $this->dbClient->scan([
                'TableName' => config('aws.prefix') . 'migrations',
            ]);
            $data = [];
            foreach ($results['Items'] as $row) {
                $data[] = [
                    'name' => $row['name']['S'],
                    'batch' => $row['batch']['N'],
                ];
            }
            
            return $data;
        }
        
        $this->createMigrationsTable();
        
        return [];
    }
    
    protected function createMigrationsTable()
    {
        $this->dbClient->createTable([
            'TableName' => config('aws.prefix') . 'migrations',
            'AttributeDefinitions' => [
                [
                    'AttributeName' => 'name',
                    'AttributeType' => 'S',
                ],
                [
                    'AttributeName' => 'batch',
                    'AttributeType' => 'N',
                ],
            ],
            'KeySchema' => [
                [
                    'AttributeName' => 'batch',
                    'KeyType' => 'HASH',
                ],
                [
                    'AttributeName' => 'name',
                    'KeyType' => 'RANGE',
                ],
            ],
            'ProvisionedThroughput' => [
                'ReadCapacityUnits' => 1,
                'WriteCapacityUnits' => 1,
            ],
        ]);
        $this->dbClient->waitUntil('TableExists', [
            'TableName' => config('aws.prefix') . 'migrations',
            '@waiter' => [
                'delay' => 5,
                'maxAttempts' => 20,
            ],
        ]);
    }
    
    protected function getAllMigrationsFile($migrationsPath)
    {
        return Collection::make($migrationsPath)->flatMap(function ($path) {
            return File::glob($path.'/*_*.php');
        })->filter()->sortBy(function ($file) {
            return str_replace('.php', '', basename($file));
        })->values()->keyBy(function ($file) {
            return str_replace('.php', '', basename($file));
        })->all();
    }
    
    protected function writeMigrationLog($file, $batch)
    {
        $this->dbClient->putItem([
            'TableName' => config('aws.prefix') . 'migrations',
            'Item' => [
                'name' => ['S' => $file],
                'batch' => ['N' => (string)$batch],
            ],
        ]);
    }
    
    private function newInstance($file)
    {
        $class = 'Database\Migration\DynamoDB\\' . studly_case(implode('_', array_slice(explode('_', $file), 4)));
        
        return new $class;
    }
    
    protected function runRollback($file, $batch)
    {
        $canRollback = true;
        $instance = $this->newInstance($file);
        $instance->down($canRollback);
        if ($canRollback) {
            $this->deleteMigrationLog($file, $batch);
            $this->line('<info>Rollback: </info>' . $file);
        }
    }
    
    protected function deleteMigrationLog($file, $batch)
    {
        $this->dbClient->deleteItem([
            'TableName' => config('aws.prefix') . 'migrations',
            'Key' => [
                'batch' => [
                    'N' => (string)$batch,
                ],
                'name' => [
                    'S' => $file,
                ],
            ],
        ]);
    }
}

và class command Migrate:

<?php
namespace App\Console\Commands\DynamoDB;

use Illuminate\Filesystem\Filesystem;
use Illuminate\Console\ConfirmableTrait;

class Migrate extends BaseCommand
{
    use ConfirmableTrait;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'dynamodb:migrate {--force : Force the operation to run when in production.}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Run migration for DynamoDB';

    private $files;
    
    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct(Filesystem $files)
    {
        parent::__construct();
        $this->files = $files;
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        if (! $this->confirmToProceed()) {
            return;
        }

        $migrationsPath = database_path() . '/migrations/dynamodb';
        $allMigrationsFile = $this->getAllMigrationsFile($migrationsPath);
        $migrationsData = $this->getMigrationsData();
        $migrationsRunFile = array_except($allMigrationsFile, array_pluck($migrationsData, 'name'));
        $batch = $this->getLastBatchNumber($migrationsData) + 1;
        foreach ($migrationsRunFile as $fileName => $path) {
            $this->runMigrate($fileName, $batch);
        }
    }
}

Và trên là đoạn code thực hiện những gì mình đã nói ở phần đầu: lấy tất cả file migrations trong thư mục migrations/dynamodb/, lấy thông tin logs từ bảng migrations, so sánh 2 mục đó và lấy ra những file migrations chưa được chạy, sau đó chạy từng file đó và tiếp tục ghi logs. Ở mỗi row log ghi vào bảng migrations ta để ý 1 column là batch, column này lưu lại lần chạy migrate mục đích để phục vụ lúc ta rollback, những file có cùng batch thì sẽ được rollback. Phần còn lại đó xin mời các bạn xem qua source code để rõ hơn. Như vậy mình đã trình bày xong cách tạo migrations cho DynamoDB. Mình có push code lên github và tạo 1 package composer, các bạn có thể lấy về dùng thử luôn ở đây. Cảm ơn các bạn đã đọc đến đây. (bow)

Tham khảo


All Rights Reserved