Tạo chức năng quản lý migrations cho DynamoDB trong project Laravel
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 ...
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...
Đầ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 = AwsDynamoDbDynamoDbClient::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 DatabaseMigrationDynamoDB; use AppConsoleCommandsDynamoDBDBClient; 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 $$bClient 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 AppConsoleCommandsDynamoDB; use IlluminateConsoleCommand; use IlluminateFilesystemFilesystem; use IlluminateSupportComposer; use IlluminateSupportStr; 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à create và table, 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 AppConsoleCommandsDynamoDB; use IlluminateConsoleCommand; use AwsDynamoDbExceptionDynamoDbException; use IlluminateSupportCollection; use IlluminateSupportFacadesFile; 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 =