Laravel Eloquent Technique: Dedicated Query String Filtering

Eloquent Techniques: Dedicated Query String Filtering

Giới thiệu

Với vai trò là Developer, chúng ta hẳn đã rất quen với những form search dữ liệu. Cụ thể là trong các màn hình Admin cần view và filter dữ liệu để dễ dàng cho việc quản lý. Mở đầu cho bài viết tôi xin đưa ra bài toán về quản lý các bài học (Lesson) như sau:

  • Tôi cần một màn hình Admin để xem danh sách các bài học của hệ thống, các bài học có các thông tin như title, content, views (tổng số lượt xem), difficulty (độ khó, gồm các mức beginner, advanced, intermediate).
  • Tôi có thể lọc danh sách đó theo các tiêu chí như ngày tạo mới nhất/cũ nhất, lượt views nhiều nhất/ít nhất hay lọc theo độ khó.

Bài toán đưa ra khá đơn giản phải không nào? Tưởng tượng qua thì chúng ta sẽ tạo 1 controller, 1 action, trả ra 1 view. Khi nhập dữ liệu vào form search thì sẽ submit lên chính controller đó, xử lý input và query để lấy dữ liệu tương ứng. Ta sẽ thực hiện HTTP GET Request đưa các tham số lên URL. Quá đơn giản phải không nào 😛

Vấn đề

Nếu đề bài chỉ đơn giản như trên, chỉ có một hoặc hai tham số cần filter thì chắc chúng ta ai cũng thực hiện hết sức đơn giản. Việc filter dữ liệu chắc chỉ cần thực hiện ở trên Controller là xong. Tuy nhiên bạn thử tưởng tượng với những hệ thống kiểu e-commerce, một sản phẩm đi kèm theo là biết bao thông tin filter: màu sắc, size, hãng sản xuất, ... thì phần parameter trên URL sẽ khủng như thế nào.

Chúng ta sẽ cùng xem một ví dụ cụ thể tại đây. Tác giả của câu hỏi đã nhờ refactor lại code của Controller, hãy cùng xem qua đoạn code này:

class CoursesController extends Controller
{
    public function index(Request $request)
    {
        //http://localhost:8000/courses?search=logis&lat=53.559003&lng=-2.077371&radius=5

        $radius = ($request->has('radius') ? $request->get('radius') : 10);
        $lat    = ($request->has('lat') ? $request->get('lat') : 53.540930);
        $lng    = ($request->has('lng') ? $request->get('lng') : -2.111366);

        $query = \DB::table('courses')
            ->join('locations', 'courses.id', '=', 'locations.course_id')
            ->join('providers', 'courses.provider_id', '=', 'providers.id')
            ->select('courses.id', 'title', 'courses.description', 'locations.name', 'locations.address', 'providers.company_name', 'providers.company_name', 'providers.logo', \DB::raw('3959 * acos( cos( radians('.$lat.') ) * cos( radians( lat ) ) * cos( radians( lng ) - radians  ('.$lng.') ) + sin( radians('.$lat.') ) * sin( radians( lat ) ) ) as distance') );

        if($request->has('search'))
            $query->where('courses.title', 'LIKE', '%' . $request->get('search') . '%');

        if($request->has('difficulty_level'))
            $query->where('courses.difficulty_level', '=', $request->get('difficulty_level'));

        if($request->has('parking'))
            $query->where('locations.parking', '=', 1);

        if($request->has('accessibility'))
            $query->where('locations.accessibility', '=', 1);

        if($request->has('public_transport'))
            $query->where('locations.public_transport', '=', 1);

        $courses = $query->having('distance', '<', $radius)
        ->orderBy('distance', 'asc')
        ->get();

        $categories = Category::orderBy('category')->lists('category', 'id');

        return view('frontend.courses.index', compact('courses', 'categories'));
    }
}

Các bạn có thể thấy qua là có quá nhiều parameter đã có và sẽ có thể phát sinh trong tương lai, và như vậy thì Controller sẽ dần dần phình to và logic sẽ dần dần phức tạp lên nhiều. Và sau khi refactor code, Action của chúng ta rất gọn gàng như sau:

public function index(CourseFilters $filters) {
    return Course::filter($filters)->get();
}

Trong bài viết ngày hôm nay tôi sẽ cùng các bạn giải quyết bài toán này không đơn giản chỉ ở mức xử lý được logic filter, ta còn cần giải quyết vấn đề nhỡ sau này có thêm tham số filter thì sao? Việc cứ viết thêm sẽ làm cho logic xử lý trên Controller ngày càng phình to và tôi dám chắc rằng đến một lúc nào đó logic quá chồng chéo và phức tạp rồi mà bạn cần phải sửa thêm thì chúng ta sẽ chọn giải pháp "tát nước theo mưa", đã nát rồi thì có thêm nữa cũng nát thôi =))

Các cụ ta có câu "phòng bệnh còn hơn chữa bệnh", nên chúng ta hãy đón đầu và giải quyết trước khi mọi việc trở nên tồi tệ. Bài viết này tôi sẽ giải quyết sử dụng kỹ thuật trong Eloquent của Laravel Framework. Hy vọng trên tư tưởng này các bạn có thể triển khai cho những Framework hay ngôn ngữ mà mình lựa chọn.

Chuẩn bị

Trước khi bắt đầu, ta cần giai đoạn chuẩn bị, chúng ta cần:

  • Khởi tạo mới một Laravel project.
  • Tạo bảng Lesson gồm các trường: id, title, content, views, difficulty, created_at, updated_at.
  • Chuẩn bị các dữ liệu để test.

Việc chuẩn bị này hết sức nhanh chóng với sự hỗ trợ của Model Factories, các bạn có thể tham khảo bài viết sau: https://laravel-news.com/2015/10/learn-to-use-model-factories-in-laravel-5-1/

Trong bài toán hiện tại tôi khai báo Factory cho Lesson như sau:

// database/factories/ModelFactory.php
$factory->define(App\Lesson::class, function (Faker\Generator $faker) {
    return [
        'title' => $faker->sentence(),
        'content' => $faker->text(),
        'views' => $faker->numberBetween(1000, 9000),
        'difficulty' => $faker->randomElement(['beginner', 'advanced', 'intermediate'])
    ];
});

Đồng thời khởi tạo mặc định 30 lessons thông qua Seeder:

// database/seeds/LessonTableSeeder.php
use Illuminate\Database\Seeder;

class LessonTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        App\Lesson::truncate();
        factory(App\Lesson::class, 30)->create();
    }
}

Hãy cùng tận hưởng kết quả bằng việc thiết kế LessonController hết sức đơn giản như sau:

// app/Http/Controllers/LessonController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Http\Requests;
use App\Lesson;

class LessonController extends Controller
{
    public function index()
    {
        return Lesson::all();
    }
}

Ta được thành quả: https://gyazo.com/0b102325cd9faf94ccb4361357cf8418

Filter dữ liệu

Ta sẽ focus chính vào LessonController, yêu cầu đặt ra đó là chúng ta phải có chức năng filter theo popular (mức độ phổ biến, thực ra chỉ là sắp xếp theo views giảm dần), và filter theo difficulty với các mức độ beginner, advanced, intermediate.

Với cách làm thông thường mà chúng ta hay triển khai trong Laravel, tôi sẽ xây dựng lại phương thức index trong LessonController như sau:

public function index(Request $request)
{
    $lesson = (new Lesson)->newQuery();

    if ($request->exists('popular')) {
        $lesson->orderBy('views', 'desc');
    }

    if ($request->has('difficulty')) {
        $lesson->where('difficulty', $request->difficulty);
    }

    return $lesson->get();
}

Hy vọng đọc code các bạn có thể hiểu được phần nào (yaoming), nếu không có param popular hay difficulty ta sẽ lấy tất cả các lessons có trong DB, ngược lại thì ta sẽ filter theo điều kiện tương ứng.

Tản mạn một chút liệu bạn có phân biệt được $request->exists()$request->has() khác nhau ở điểm nào không? Đơn giản là phương thức exists() chỉ kiểm tra xem có key trên query string không, còn phương thức has() yêu cầu phải có cả key và value trên query string. Giả sử ta có request http://localhost:8000/lessons?popular&difficulty=beginner:

$request->exists('popular');  // true
$request->has('popular');  // false
$request->exists('difficulty');  // true
$request->has('difficulty');  // true

Okie vậy là xong bước đầu, bạn có thể tận hưởng thành quả ban đầu với request giả sử của tôi ở bước trên, ta sẽ filter được dữ liệu theo điều kiện mong muốn.

Tuy nhiên ta đang chỉ làm việc với 2 params, liệu sau này có thêm nhiều điều kiện filter nữa, hay ta phải làm những chức năng filter giống như các trang e-commerce lớn như Ebay, Amazon, ... mà cứ thêm vào Controller thế này thì thảm hoạ sẽ xảy ra (khoc) Bây giờ chúng ta sẽ refactor code của chính mình.

Cải tiến

Cùng phân tích lại đoạn code trong Controller phía trên, bóc tách nó ra và cải tiến. Trước hết hãy để ý đến dòng đầu tiên:

$lesson = (new Lesson)->newQuery();

Đoạn này chúng ta sẽ nhận được 1 instance của Illuminate\Database\Eloquent\Builder, cứ note lại như vậy đã rồi tính tiếp.

if ($request->exists('popular')) {
    $lesson->orderBy('views', 'desc');
}

if ($request->has('difficulty')) {
    $lesson->where('difficulty', $request->difficulty);
}

Đoạn này chính là đoạn mà chúng ta lấn cấn nhất trong việc mở rộng sau này, nếu mỗi param thêm vào lại phải check has hoặc exists rồi viết thêm điều kiện query thì khá nặng nhọc phải không nào?

Như phần giới thiệu đầu bài viết, tôi đã giới thiệu với các bạn một câu hỏi nhờ refactor code, và kết quả mà người trả lời suggest, kết quả action index trong Controller bây giờ sẽ trở thành thế này:

public function index(LessonFilters $filters)
{
    return Lesson::filter($filters)->get();
}

Diễn giải một cách nôm na là với action index này tôi muốn filter các Lesson theo một số điều kiện nào đó (filters) và lấy ra kết quả. Controller của chúng ta bây giờ rất gọn gàng, mạch lạc và đi đúng vào tiêu chí cần thực hiện.

Vậy thì hàm filter() xây dựng thế nào? class LessonFilters là gì và phục vụ mục đích gì?

Hàm filter() được xây dựng dựa trên kỹ thuật scope trong eloquent, cụ thể ta sẽ khai báo trong App/Lesson.php một phương thức như sau:

public function scopeFilter($query, QueryFilter $filters)
{
    return $filters->apply($query);
}

Ta sẽ có biến $query tương ứng với việc khởi tạo 1 instance của Builder như tôi đã nói là note lại ở trên. Đồng thời ta sẽ xây dựng 1 class base QueryFilter để quản lý các filters (chính là các parameter truyền vào filter) và apply() nó vào câu query hiện tại.

// App/QueryFilter.php
namespace App;

use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;

abstract class QueryFilter
{
    protected $request;

    protected $builder;

    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    public function apply(Builder $builder)
    {
        $this->builder = $builder;

        foreach ($this->filters() as $name => $value) {
            if (method_exists($this, $name)) {
                call_user_func_array([$this, $name], array_filter([$value]));
            }
        }

        return $this->builder;
    }

    public function filters()
    {
        return $this->request->all();
    }
}

Ta thấy rằng QueryFilter thực hiện lấy các parameters từ Request (giống như chúng ta lấy trên Controller), đồng thời với $builder query truyền vào ta xây dựng được phương thức apply() tương ứng.

Đoạn code:

call_user_func_array([$this, $name], array_filter([$value]));

tưởng chừng có vẻ nguy hiểm nhưng thực ra có thể viết tường minh thông qua đoạn code sau:

if (!empty($value)) {
    $this->$name($value);
} else {
    $this->$name();
}

Tức là nếu có value đi kèm với key thì gọi hàm có param truyền vào, ngược lại thì không cần truyền param. Nếu vậy thì các tên hàm này xây dựng ở đâu? Nếu bạn để ý thì trên Controller tôi có dùng kỹ thuật DI để đưa class LessonFilters vào. Ta sẽ thiết kế class này kế thừa từ QueryFilter:

namespace App;

class LessonFilters extends QueryFilter
{
    public function popular($order = 'desc')
    {
        return $this->builder->orderBy('views', $order);
    }

    public function difficulty($level)
    {
        return $this->builder->where('difficulty', $level);
    }

}

Ta đã thiết kế xong hết phần khung để chạy. Bây giờ ta chỉ việc focus vào LessonFilters class thôi. Controller đã xong phần việc của nó. Và giả sử bây giờ tôi cần filter theo một thuộc tính length nào đó, tôi chỉ việc thêm vào LessonFiltersLessonFilters phương thức:

public function length($order = 'desc')
{
    return $this->builder->orderBy('length', $order);
}

Mọi việc được giải quyết khá đơn giản và gọn gàng. Các bạn có thể tham khảo thêm về code đầy đủ thông qua: https://github.com/nguyenthanhtung88/eloquent-dedicated-query-string-filtering

Kết luận

  • Qua bài viết này tôi mong muốn đưa đến các bạn một phương pháp thiết kế code trong các chức năng, các trang cần filter dữ liệu dựa vào các điều kiện tìm kiếm. Trong phương pháp này có sử dụng các kỹ thuật của Eloquent trong Laravel.
  • Hy vọng bài viết giúp ích cho bạn trong quá trình phát triển một trong số các chức năng vẫn thường thấy ở cái website hiện tại.