- vừa được xem lúc

Laravel Pipeline - Sự phát triển của một hệ thống query filter

0 0 31

Người đăng: Baro

Theo Viblo Asia

Query filter... một vấn đề quen thuộc khi phát triển một hệ thống. Nhưng khi bắt tay vào code, nhiều câu hỏi quen thuộc hiện lên trong mỗi developer nói chung: "Mình nên để đống logic query này ở đâu? Mình nên quản lý nó như nào cho dễ sử dụng?". Thành thật mà nói, với mỗi một dự án mình phát triển, mình lại viết theo một kiểu riêng, dựa vào kinh nghiệm của những dự án trước để tạo. Và mỗi lần khởi tạo một dự án mới, mình lại tự hỏi bản thân cùng một câu hỏi lần này mình sẽ bố trí query filter như nào! Bài viết này có thể coi như từng bước phát triển một hệ thống query filter, với những vấn đề gặp phải tương ứng.

Ngữ cảnh bài toán

Ở thời điểm bài viết, mình sử dụng Laravel 9, trên nền PHP8.1 và MySQL 8. Mình tin rằng tech-stack không phải một vấn đề đáng kể, ở đây chúng ta tập trung chủ yếu là xây dựng một hệ thống Query Filter. Trong bài viết này, mình sẽ giả tưởng xây dựng filter cho bảng users

<?php use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; return new class extends Migration
{ /** * Run the migrations. * * @return void */ public function up() { Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->string('gender', 10)->nullable()->index(); $table->boolean('is_active')->default(true)->index(); $table->boolean('is_admin')->default(false)->index(); $table->timestamp('birthday')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('users'); }
}

Ngoài ra, mình cũng sử dụng thêm Laravel Telescope để tiện theo dõi query

Khởi điểm

Trong những ngày đầu tiếp xúc và học sử dụng Laravel, mình thường trực tiếp gọi query ngay tại controller. Đơn giản, dễ hiểu, tuy nhiên cách này tồn tại các vấn đề:

  • Một lượng lớn logic đặt tại controller khiến controller bị phình to
  • Không thể tái sử dụng
  • Nhiều công việc giống nhau lặp đi lặp lại
<?php namespace App\Http\Controllers; use App\Models\User;
use Illuminate\Http\Request; class UserController extends Controller
{ public function __invoke(Request $request) { // /users?name=ryder&email=hartman&gender=male&is_active=1&is_admin=0&birthday=2014-11-30 $query = User::query(); if ($request->has('name')) { $query->where('name', 'like', "%{$request->input('name')}%"); } if ($request->has('email')) { $query->where('email', 'like', "%{$request->input('email')}%"); } if ($request->has('gender')) { $query->where('gender', $request->input('gender')); } if ($request->has('is_active')) { $query->where('is_active', $request->input('is_active') ? 1 : 0); } if ($request->has('is_admin')) { $query->where('is_admin', $request->input('is_admin') ? 1 : 0); } if ($request->has('birthday')) { $query->whereDate('birthday', $request->input('birthday')); } return $query->paginate(); // select * from `users` where `name` like '%ryder%' and `email` like '%hartman%' and `gender` = 'male' and `is_active` = 1 and `is_admin` = 0 and date(`birthday`) = '2014-11-30' limit 15 offset 0 }
}

Sử dụng Local Scope

Để có thể ẩn bớt lượng logic trong khi query, chúng ta cùng thử sử dụng Local Scope của Laravel. Chuyển các query thành các function scope trong model User


// User.php
public function scopeName(Builder $query): Builder
{ if (request()->has('name')) { $query->where('name', 'like', "%" . request()->input('name') . "%"); } return $query;
} public function scopeEmail(Builder $query): Builder
{ if (request()->has('email')) { $query->where('email', 'like', "%" . request()->input('email') . "%"); } return $query;
} public function scopeGender(Builder $query): Builder
{ if (request()->has('gender')) { $query->where('gender', request()->input('gender')); } return $query;
} public function scopeIsActive(Builder $query): Builder
{ if (request()->has('is_active')) { $query->where('is_active', request()->input('is_active') ? 1 : 0); } return $query;
} public function scopeIsAdmin(Builder $query): Builder
{ if (request()->has('is_admin')) { $query->where('is_admin', request()->input('is_admin') ? 1 : 0); } return $query;
} public function scopeBirthday(Builder $query): Builder
{ if (request()->has('birthday')) { $query->where('birthday', request()->input('birthday')); } return $query;
} // UserController.php
public function __invoke(Request $request)
{ // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11 $query = User::query() ->name() ->email() ->gender() ->isActive() ->isAdmin() ->birthday(); return $query->paginate(); // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}

Với cách bố trí này, chúng ta đã chuyển phần lớn thao tác với database vào lớp model, tuy vậy việc lặp lại code là khá nhiều. Ví dụ 2 scope filter cho nameemail là giống nhau, tương tự với nhóm gender birthdayis_active is_admin. Chúng ta sẽ tiếp cận theo hướng nhóm các query tương tự nhau

// User.php
public function scopeRelativeFilter(Builder $query, $inputName): Builder
{ if (request()->has($inputName)) { $query->where($inputName, 'like', "%" . request()->input($inputName) . "%"); } return $query;
} public function scopeExactFilter(Builder $query, $inputName): Builder
{ if (request()->has($inputName)) { $query->where($inputName, request()->input($inputName)); } return $query;
} public function scopeBooleanFilter(Builder $query, $inputName): Builder
{ if (request()->has($inputName)) { $query->where($inputName, request()->input($inputName) ? 1 : 0); } return $query;
} // UserController.php
public function __invoke(Request $request)
{ // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11 $query = User::query() ->relativeFilter('name') ->relativeFilter('email') ->exactFilter('gender') ->booleanFilter('is_active') ->booleanFilter('is_admin') ->exactFilter('birthday'); return $query->paginate(); // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}

Lúc này chúng ta đã nhóm gần hết những thứ trùng lặp. Tuy vậy, muốn khử if hoặc là mở rộng các filter này sang bên model khác thì có chút khó khăn. Chúng ta cùng tìm kiếm một phương pháp giải quyết triệt để vấn đề này.

Sử dụng Pipeline pattern

Pipeline design pattern là một design pattern cung cấp khả năng xây dựng và thực thi một chuỗi các hành động theo từng bước. Laravel đã xây dựng sẵn khung Pipeline giúp chúng ta có thể dễ dàng ứng dụng design pattern này trong thực tế, nhưng vì lý do nào đó nó ko được liệt kê trên offical documentation. Bản thân Laravel cũng áp dụng Pipeline để apply được cái middleware nằm giữa Request và Response. Cơ bản nhất thì để sử dụng Pipeline trong Laravel, chúng ta có thể dùng mẫu:

app(\Illuminate\Pipeline\Pipeline::class) ->send($intialData) ->through($pipes) ->thenReturn(); // data with pipes applied

Đối với bài toán của chúng ta, có thể áp dụng truyền vào pipeline một intial query User:query(), trải qua các bước filter, trả về một query builder đã được apply các filter vào.

app(\Illuminate\Pipeline\Pipeline::class) ->send(User::query()) ->through($filters) ->thenReturn(); // builder with filters applied

Với ý tưởng này, chúng ta cùng xây dựng prototype trên controller

// UserController
public function __invoke(Request $request)
{ // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11 $query = app(Pipeline::class) ->send(User::query()) ->through([ // filters ]) ->thenReturn(); return $query->paginate(); // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0

Bắt tay vào xây dựng các pipe filters

// File: app/Models/Pipes/RelativeFilter.php <?php namespace App\Models\Pipes; use Illuminate\Database\Eloquent\Builder; class RelativeFilter
{ public function __construct(protected string $inputName) { } public function handle(Builder $query, \Closure $next) { if (request()->has($this->inputName)) { $query->where($this->inputName, 'like', "%" . request()->input($this->inputName) . "%"); } return $next($query); }
} // File: app/Models/Pipes/ExactFilter.php <?php namespace App\Models\Pipes; use Illuminate\Database\Eloquent\Builder; class ExactFilter
{ public function __construct(protected string $inputName) { } public function handle(Builder $query, \Closure $next) { if (request()->has($this->inputName)) { $query->where($this->inputName, request()->input($this->inputName)); } return $next($query); }
} //File: app/Models/Pipes/BooleanFilter.php
<?php namespace App\Models\Pipes; use Illuminate\Database\Eloquent\Builder; class BooleanFilter
{ public function __construct(protected string $inputName) { } public function handle(Builder $query, \Closure $next) { if (request()->has($this->inputName)) { $query->where($this->inputName, request()->input($this->inputName) ? 1 : 0); } return $next($query); }
} // UserController
public function __invoke(Request $request)
{ // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11 $query = app(Pipeline::class) ->send(User::query()) ->through([ new \App\Models\Pipes\RelativeFilter('name'), new \App\Models\Pipes\RelativeFilter('email'), new \App\Models\Pipes\ExactFilter('gender'), new \App\Models\Pipes\BooleanFilter('is_active'), new \App\Models\Pipes\BooleanFilter('is_admin'), new \App\Models\Pipes\ExactFilter('birthday'), ]) ->thenReturn(); return $query->paginate(); // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}

Bằng việc chuyển mỗi logic query từng class riêng biệt, chúng ta đã mở khóa khả năng tùy biến sử dụng OOP như bao gồm đa hình, kế thừa, đóng gói, trừu tượng. Ví dụ các bạn thấy trong hàm handle của pipe, chỉ có phần logic nằm trong if statement là khác nhau, mình sẽ tách và trừu tượng hóa nó bằng cách tạo ra một class abstract BaseFilter

//File: app/Models/Pipes/BaseFilter.php <?php namespace App\Models\Pipes; use Illuminate\Database\Eloquent\Builder; abstract class BaseFilter
{ public function __construct(protected string $inputName) { } public function handle(Builder $query, \Closure $next) { if (request()->has($this->inputName)) { $query = $this->apply($query); } return $next($query); } abstract protected function apply(Builder $query): Builder;
} // BooleanFilter
class BooleanFilter extends BaseFilter
{ protected function apply(Builder $query): Builder { return $query->where($this->inputName, request()->input($this->inputName) ? 1 : 0); }
} // ExactFilter
class ExactFilter extends BaseFilter
{ protected function apply(Builder $query): Builder { return $query->where($this->inputName, request()->input($this->inputName)); }
} // RelativeFilter
class RelativeFilter extends BaseFilter
{ protected function apply(Builder $query): Builder { return $query->where($this->inputName, 'like', "%" . request()->input($this->inputName) . "%"); }
}

Giờ Filter của chúng ta đã trực quan và có tính tái sử dụng cao, dễ dàng triển khai và thậm chí mở rộng hơn, chỉ cần tạo một pipe, extends BaseFilter và khai báo function apply là đã có thể nhét vào query để sử dụng.

Kết hợp Local Scope với Pipeline

Thời điểm này, chúng ta sẽ cố gắng ẩn đoạn Pipeline trên controller đi, giúp cho đoạn code của chúng ta sạch sẽ hơn, bằng cách tạo 1 scope gọi tới Pipeline bên trong Model

// User.php
public function scopeFilter(Builder $query)
{ $criteria = $this->filterCriteria(); return app(\Illuminate\Pipeline\Pipeline::class) ->send($query) ->through($criteria) ->thenReturn();
} public function filterCriteria(): array
{ return [ new \App\Models\Pipes\RelativeFilter('name'), new \App\Models\Pipes\RelativeFilter('email'), new \App\Models\Pipes\ExactFilter('gender'), new \App\Models\Pipes\BooleanFilter('is_active'), new \App\Models\Pipes\BooleanFilter('is_admin'), new \App\Models\Pipes\ExactFilter('birthday'), ];
} // UserController.php
public function __invoke(Request $request)
{ // /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11 return User::query() ->filter() ->paginate() ->appends($request->query()); // append all current queries into pagination links // select * from `users` where `name` like '%john%' and `email` like '%desmond%' and `gender` = 'female' and `is_active` = 1 and `is_admin` = 0 and `birthday` = '2015-04-11' limit 15 offset 0
}

User đã có thể gọi filter từ bất cứ đâu. Nhưng để các model khác cũng có thể triển khai filter thì chúng ta sẽ tìm các khai báo dễ dàng hơn. Lúc này mình sẽ tạo một Trait chứa scope và chìa ra khai báo các pipe tham gia quá trình filters bên trong model

// User.php use App\Models\Concerns\Filterable; class User extends Authenticatable { use Filterable; protected function getFilters() { return [ new \App\Models\Pipes\RelativeFilter('name'), new \App\Models\Pipes\RelativeFilter('email'), new \App\Models\Pipes\ExactFilter('gender'), new \App\Models\Pipes\BooleanFilter('is_active'), new \App\Models\Pipes\BooleanFilter('is_admin'), new \App\Models\Pipes\ExactFilter('birthday'), ]; } // the rest of code // File: app/Models/Concerns/Filterable.php namespace App\Models\Concerns; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pipeline\Pipeline; trait Filterable
{ public function scopeFilter(Builder $query) { $criteria = $this->filterCriteria(); return app(Pipeline::class) ->send($query) ->through($criteria) ->thenReturn(); } public function filterCriteria(): array { if (method_exists($this, 'getFilters')) { return $this->getFilters(); } return []; }
}

Chúng ta đã giải quyết ổn thỏa vấn đề chia để trị, mỗi file mỗi class mỗi function giờ đã có trách nhiệm rõ ràng, không ôm đồm quá nhiều công việc. Code cũng vì thế mà sạch sẽ trực quan và dễ dàng tái sử dụng hơn rất nhiều rồi đúng không! Mình sẽ để code của toàn bộ quá trình Demo bài này tại đây

Lời kết

Trên đây là một phần nào đó hành trình mà mình đã trải qua để xây dựng một hệ thống Query Filter nâng cao, đồng thời giới thiệu tới các bạn một số hướng tiếp cận lập trình Laravel như Local Scope và đặc biệt Pipeline design pattern. Để nhanh chóng và dễ dàng áp dụng hệ thống này cho một Project mới, các bạn có thể tham khảo và sử dụng package Pipeline Query Collection, gồm một bộ các pipe mình đã dựng sẵn giúp dễ dàng cài cắm và sử dụng. Hi vọng mọi người sẽ ủng hộ

Bình luận

Bài viết tương tự

- vừa được xem lúc

Tìm hiểu về Resource Controller trong Laravel

Giới thiệu. Trong laravel, việc sử dụng các route post, get, group để gọi đến 1 action của Controller đã là quá quen đối với các bạn sử dụng framework này.

0 0 334

- vừa được xem lúc

Phân quyền đơn giản với package Laravel permission

Như các bạn đã biết, phân quyền trong một ứng dụng là một phần không thể thiếu trong việc phát triển phần mềm, dù đó là ứng dụng web hay là mobile. Vậy nên, hôm nay mình sẽ giới thiệu một package có thể giúp các bạn phân quyền nhanh và đơn giản trong một website được viết bằng PHP với framework là L

0 0 417

- vừa được xem lúc

Sử dụng Swagger để xây dựng API documentation

Giới thiệu về Swagger. RESTful API là một tiêu chuẩn dùng trong việc thiết kế API cho các ứng dụng web (thiết kế Web services) để tiện cho việc quản lý các resource.

0 0 1k

- vừa được xem lúc

Ví dụ CRUD với Laravel và Vuejs.

1. Cài đặt Laravel. composer create-project --prefer-dist laravel/laravel vuelaravelcrud. .

0 0 139

- vừa được xem lúc

Một số tips khi dùng laravel (Part 1)

1. Show database query in raw SQL format. DB::enableQueryLog(); // Bật tính năng query logging. DB::table('users')->get(); // Chạy truy vấn bạn muốn ghi log.

0 0 68

- vừa được xem lúc

Inertiajs - Xây dựng Single Page App không cần API

Tiêu đề là mình lấy từ trang chủ của https://inertiajs.com/ chứ không phải mình tự nghĩ ra đâu nhé :v. Lâu lâu rồi chưa động tới Laravel (dự án cuối cùng mình code là ở ver 5.8), thế nên một ngày đẹp trời lượn vào đọc docs ver 8.

0 0 227