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

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,

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

<?phpuseIlluminateDatabaseMigrationsMigration;useIlluminateDatabaseSchemaBlueprint;useIlluminateSupportFacadesSchema;returnnewclassextendsMigration{/**
     * Run the migrations.
     *
     * @return void
     */publicfunctionup(){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
     */publicfunctiondown(){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
<?phpnamespaceAppHttpControllers;useAppModelsUser;useIlluminateHttpRequest;classUserControllerextendsController{publicfunction__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.phppublicfunctionscopeName(Builder$query):Builder{if(request()->has('name')){$query->where('name','like',"%".request()->input('name')."%");}return$query;}publicfunctionscopeEmail(Builder$query):Builder{if(request()->has('email')){$query->where('email','like',"%".request()->input('email')."%");}return$query;}publicfunctionscopeGender(Builder$query):Builder{if(request()->has('gender')){$query->where('gender',request()->input('gender'));}return$query;}publicfunctionscopeIsActive(Builder$query):Builder{if(request()->has('is_active')){$query->where('is_active',request()->input('is_active')?1:0);}return$query;}publicfunctionscopeIsAdmin(Builder$query):Builder{if(request()->has('is_admin')){$query->where('is_admin',request()->input('is_admin')?1:0);}return$query;}publicfunctionscopeBirthday(Builder$query):Builder{if(request()->has('birthday')){$query->where('birthday',request()->input('birthday'));}return$query;}// UserController.phppublicfunction__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 genderbirthdayis_activeis_admin. Chúng ta sẽ tiếp cận theo hướng nhóm các query tương tự nhau

// User.phppublicfunctionscopeRelativeFilter(Builder$query,$inputName):Builder{if(request()->has($inputName)){$query->where($inputName,'like',"%".request()->input($inputName)."%");}return$query;}publicfunctionscopeExactFilter(Builder$query,$inputName):Builder{if(request()->has($inputName)){$query->where($inputName,request()->input($inputName));}return$query;}publicfunctionscopeBooleanFilter(Builder$query,$inputName):Builder{if(request()->has($inputName)){$query->where($inputName,request()->input($inputName)?1:0);}return$query;}// UserController.phppublicfunction__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(IlluminatePipelinePipeline::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(IlluminatePipelinePipeline::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

// UserControllerpublicfunction__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

<?phpnamespaceAppModelsPipes;useIlluminateDatabaseEloquentBuilder;classRelativeFilter{publicfunction__construct(protectedstring$inputName){}publicfunctionhandle(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

namespaceAppModelsPipes;useIlluminateDatabaseEloquentBuilder;classExactFilter{publicfunction__construct(protectedstring$inputName){}publicfunctionhandle(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

namespaceAppModelsPipes;useIlluminateDatabaseEloquentBuilder;classBooleanFilter{publicfunction__construct(protectedstring$inputName){}publicfunctionhandle(Builder$query,Closure$next){if(request()->has($this->inputName)){$query->where($this->inputName,request()->input($this->inputName)?1:0);}return$next($query);}}// UserControllerpublicfunction__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([newAppModelsPipesRelativeFilter('name'),newAppModelsPipesRelativeFilter('email'),newAppModelsPipesExactFilter('gender'),newAppModelsPipesBooleanFilter('is_active'),newAppModelsPipesBooleanFilter('is_admin'),newAppModelsPipesExactFilter('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

<?phpnamespaceAppModelsPipes;useIlluminateDatabaseEloquentBuilder;abstractclassBaseFilter{publicfunction__construct(protectedstring$inputName){}publicfunctionhandle(Builder$query,Closure$next){if(request()->has($this->inputName)){$query=$this->apply($query);}return$next($query);}abstractprotectedfunctionapply(Builder$query):Builder;}// BooleanFilterclassBooleanFilterextendsBaseFilter{protectedfunctionapply(Builder$query):Builder{return$query->where($this->inputName,request()->input($this->inputName)?1:0);}}// ExactFilterclassExactFilterextendsBaseFilter{protectedfunctionapply(Builder$query):Builder{return$query->where($this->inputName,request()->input($this->inputName));}}// RelativeFilterclassRelativeFilterextendsBaseFilter{protectedfunctionapply(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.phppublicfunctionscopeFilter(Builder$query){$criteria=$this->filterCriteria();returnapp(IlluminatePipelinePipeline::class)->send($query)->through($criteria)->thenReturn();}publicfunctionfilterCriteria():array{return[newAppModelsPipesRelativeFilter('name'),newAppModelsPipesRelativeFilter('email'),newAppModelsPipesExactFilter('gender'),newAppModelsPipesBooleanFilter('is_active'),newAppModelsPipesBooleanFilter('is_admin'),newAppModelsPipesExactFilter('birthday'),];}// UserController.phppublicfunction__invoke(Request$request){// /users?name=john&email=desmond&gender=female&is_active=1&is_admin=0&birthday=2015-04-11returnUser::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.phpuseAppModelsConcernsFilterable;classUserextendsAuthenticatable{useFilterable;protectedfunctiongetFilters(){return[newAppModelsPipesRelativeFilter('name'),newAppModelsPipesRelativeFilter('email'),newAppModelsPipesExactFilter('gender'),newAppModelsPipesBooleanFilter('is_active'),newAppModelsPipesBooleanFilter('is_admin'),newAppModelsPipesExactFilter('birthday'),];}// the rest of code// File: app/Models/Concerns/Filterable.phpnamespaceAppModelsConcerns;useIlluminateDatabaseEloquentBuilder;useIlluminatePipelinePipeline;traitFilterable{publicfunctionscopeFilter(Builder$query){$criteria=$this->filterCriteria();returnapp(Pipeline::class)->send($query)->through($criteria)->thenReturn();}publicfunctionfilterCriteria():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ộ

Nguồn: viblo.asia

Bài viết liên quan

WebP là gì? Hướng dẫn cách để chuyển hình ảnh jpg, png qua webp

WebP là gì? WebP là một định dạng ảnh hiện đại, được phát triển bởi Google

Điểm khác biệt giữa IPv4 và IPv6 là gì?

IPv4 và IPv6 là hai phiên bản của hệ thống địa chỉ Giao thức Internet (IP). IP l

Check nameservers của tên miền xem website trỏ đúng chưa

Tìm hiểu cách check nameservers của tên miền để xác định tên miền đó đang dùn

Mình đang dùng Google Domains để check tên miền hàng ngày

Từ khi thông báo dịch vụ Google Domains bỏ mác Beta, mình mới để ý và bắt đầ