Xây dựng package Counter Cache cho Laravel 5

Đặt vấn đề

Trong quá trình làm dự án mình có gặp bài toán như sau : Ta có hai bảng products và comments, với quan hệ 1 - n. Mỗi product có nhiều comments. Trong mỗi comment chứa 1 rating_value và nội dung comment. Bài toán đặt ra là với mỗi product ta cần tình được tổng số comments và giá trị rating trung bình của products đó. Có nhiều cách để xử lý vấn đề này:

  • Nếu sử dụng query thì mỗi products ta hoàn toàn có thể thêm các attribute để tính toán tổng số comment và giá trị trung bình rating. Nhưng nếu như vậy ta sẽ gặp phải vấn đề n+1 queries. => Cách này là không tối ưu.
  • Cách thứ 2 là ta sẽ thêm các field trong bảng products ví dụ như comments_countrating_average để mỗi lần thêm sửa xóa comment thì ta đều tính lại các giá trị này và save vào bảng products. Vấn đề này thì đã có Models Event của Laravel hỗ trợ. Khi save một record comment thì mình sẽ tìm đến product và cập nhật giá trị tương ứng. Như vậy khi cần chỉ cần gọi products ra là đủ thông tin rồi mà không cần phải query tính toán thêm nữa. => Cách này có vẻ khả thi.

Đã suy nghĩ được đường lối như thế bây giờ ta sẽ tìm cách giải quyết vấn đề. Đầu tiên là thử tìm xem có thư viện nào ngon hỗ trợ không. 1 tia sáng lóe lên trong đầu: Mình đã từng code CakePHP và trong framework này có hỗ trợ sẵn xử lý vấn đề này gọi là CounterCache Behavior. Vậy trong Laravel có thư viện nào hỗ trợ sẵn như CakePHP không nhỉ, tiếp tục tìm kiếm với từ khóa Laravel CounterCache thì thấy Laravel không có hỗ trợ sẵn nhưng mình cũng tìm được một thư viện như thế trên github: https://github.com/kanazaca/counter-cache. Nhìn vào overview của thư viện thì thấy nó xử lý được cho mình bài toán về comments_count nhưng lại ko xử lý được rating_average. Thôi thì tạm hài lòng về nó, composer require về dùng thử xem thế nào. Mới đầu sử dụng thì không có vấn đề gì xảy ra. Nhưng sau khi QA test thì vấn đề cũng đã gặp phải. Trong một số trường hợp thì giá trị comments_count cập nhật sai. Debug thử thì mình nhận thấy rằng khi gọi object model ra và thay đổi từng thuộc tính sau đấy save lại thì comments_count bị tăng (Ơ, sao thế nhỉ, mình chỉ update thôi mà có phải create đâu sao lại tăng comments_count vớ vẩn thế này). Ví dụ:

$comment = Comment::find(3);
$comment->content = 'Content update';
$comment->save();

Cảm thấy mất chút niềm tin vào cái thư viện vừa dùng. (Warning: Dưới đây là góc bóc phốt thư viện của người khác viết!!!) Mình thử mò vào soi code của nó xem sao. OMG vừa vào đã thấy vấn đề rồi. Thanh niên này override lại các hàm save() update() delete() của eloquent model để tăng giảm comments_count. Đọc code thì mình đã hiểu là vì sao mình dùng hàm save()comments_count lại tăng. Vì cậu này code mặc định hàm save() thì sẽ increment field count. (facepalm). Chắc vì nghĩ hàm save() chỉ dùng khi create chăng (??). Còn 1 điều khá là buồn cười nữa: Là khi dùng hàm update() thì cách mà cậu ý làm để không tăng count khi update đó là giảm field count và sau khi update xong thì lại tăng lại field count (omg).

/**
 * Override save method because we need to increment all counters
 * when is a new one
 *
 * @param array $options
 * @return bool
 */
public function save(array $options = [])
{
    if(parent::save($options)) {
        return $this->incrementAllCounters();
    }
    return false;
}
/**
 * Override update method because we need to listen for relation changes
 *
 * @param array $attributes
 * @param array $options
 * @return bool
 */
public function update(array $attributes = [], array $options = [])
{
    foreach($this->counterCacheOptions as $method => $counter) {
        $this->decrementCounter($method, $counter); // decrement 1 in the old relation - xit happens bro
        $updated = parent::update($attributes);
        if (!$updated) {
            $this->incrementCounter($method, $counter);
        }
    }
    return true;
}

OMG chỉ vì không tăng field count mà cậu ý đã tốn mất 2 query update field count một cách không cần thiết. Sao không tìm cách nào khác để check xem đây là hành động update để không cập nhật field count nhỉ. (facepalm). Như vậy là thư viện này không dùng được rồi. Quay về bài toán ban đầu, chả nhẽ với mỗi model thì mình lại tự viết 1 cách máy móc các hàm để tăng giảm comments_count và tính lại rating_average ư. Ví dụ như này:

// App\Models\Comment
protected static function boot()
{
    static::created(function ($model) {
        $product = $model->product;
        $product->update([
            'comments_count' => $product->comments_count + 1,
            'rating_average' => $product->ratingAverage(),
        ]);
    });

    static:saved(function ($model) {
        $product = $model->product;
        $product->update([
            'rating_average' => $product->ratingAverage(),
        ]);
    });
    
    static::deleted(function ($comment) {
        $product = $model->product;
        $product->update([
            'comments_count' => $product->comments_count - 1,
            'rating_average' => $product->ratingAverage(),
        ]);
    });

    parent::boot();
}

// App\Models\Product
public function comments()
{
    return $this->hasMany(Comment::class);
}
public function ratingAverage()
{
    return $this->comments()->avg('rating_value');
}

Nếu thế này thì thủ công quá giả sử có thêm mấy Model cũng tương tự như này thì đúng là quá vất vả để xử lý vấn đề. Laravel thì không hỗ trợ, thư viện lấy về thì không dùng đươc. Trong cái khó ló cái khôn, không dùng được thì mình tự viết 1 thư viện để xử lý cho những vấn đề tương tự thế này thôi (tại sao không?) Bắt đầu suy nghĩ luôn solution: Mình cũng sẽ viết 1 thư viện tạm gọi là CounterCache đi, nhưng nghĩ một cách rộng hơn thì viết lại phải ngon hơn những người khác đã viết, phải cover hết các trường hợp: tăng giảm count, tính lại trung bình, cộng tổng field hay đại loại là một phép tính bất kì nào đó. Việt Nam nói là làm, action thôi... (go)

Xây dựng package CounterCache cho Laravel 5

Đầu tiên xác định tư tưởng là mình sẽ sử dụng các model events của Laravel có hỗ trợ sẵn để viết thư viện này. Trước hết mình sẽ thiết kế môt cái biến dùng để khai báo các trường được sử dụng counter cache. Nó sẽ trông như sau:

public $counterCacheOptions = [
    'product' => [
        'comments_count',
        'rating_average' => [
            'method' => 'ratingAverage',
            /** 'conditions' => [
                'is_publish' => true
            ], **/
        ],
    ],
];
public function product()
{
    return $this->belongsTo(Product::class);
}

Với product là hàm relationship, với mỗi phần tử trong mảng product nếu chỉ có value không có key thì mặc định là tăng giảm count. Ví dụ comments_count. Còn muốn tính trung bình ta cần khai báo tiếp field rating_average và có chỉ định method tính trung bình là ratingAverage, method này ở trong model Product như ví dụ cách làm thủ công ở phía trên. Ngoài ra có thể thêm conditions để chỉ định tính rating trung bình trong một số trường hợp cụ thể ví dụ ở đây là chỉ khi product có is_publish = true chẳng hạn. Và trait CounterCache đọc nội dung từ biến trên và thực hiện các chức năng tương ứng. Mình đã viết trait đó với nội dung sau:

<?php
namespace App\Models\Traits;

use Illuminate\Database\Eloquent\SoftDeletingScope;

trait CounterCache
{
    /**
     * Override boot function in eloquent model
     */
    protected static function boot()
    {
        parent::boot();
        static::created(function ($model) {
            (new self)->runCounter($model, 'increment');
        });

        static::saved(function ($model) {
            (new self)->runCounter($model);
        });

        static::deleted(function ($model) {
            (new self)->runCounter($model, 'decrement');
        });

        if (static::hasGlobalScope(SoftDeletingScope::class)) {
            static::restored(function ($model) {
                (new self)->runCounter($model, 'increment');
            });
        }
    }

    /**
     * Run counter from counter cache options in model
     *
     * @param $model
     * @param null $type
     */
    public function runCounter($model, $type = null)
    {
        foreach ($this->counterCacheOptions as $method => $counter) {
            $this->counterForRelation($model, $method, $counter, $type);
        }
    }

    /**
     * Update field
     *
     * @param $model
     * @param $method
     * @param $counter
     * @param $type
     */
    protected function counterForRelation($model, $method, $counter, $type)
    {
        foreach ($counter as $field => $option) {
            if (is_string($option) && $type) {
                $model->$method()->$type($option);
            }

            if (is_array($option)) {
                $relation = $this->loadRelation($model, $method);
                if (isset($option['conditions'])) {
                    $relation = $relation->where($option['conditions'])->first();
                }

                $counterMethod = $option['method'] ?? null;
                if ($relation) {
                    if ($counterMethod) {
                        $relation->update([
                            $field => $relation->$counterMethod(),
                        ]);
                    } elseif ($type) {
                        $relation->$type($field);
                    }
                }
            }
        }
    }

    /**
     * Build relation model from relationship
     *
     * @param $model
     * @param $method
     * @return \Illuminate\Database\Eloquent\Model
     */
    protected function loadRelation($model, $method)
    {
        $this->load($method);

        return $model->$method;
    }
}

Phần code trên để xử lý cho việc đọc các khai báo và thực hiện chức năng tương ứng. Các bạn tự xem code nhé. Và khi sử dụng ta chỉ cần use trait này trong model là xong.

// App\Models\Comment
use CounterCache;

Chú ý: Nếu trong model Comment bạn cũng muốn override lại hàm boot() để thực hiện một chức năng gì đó thì bạn cần phải khai báo như sau:

use CounterCache {
    boot as preBoot;
}

public $counterCacheOptions = [
    'product' => [
        'comments_count' => [],
        'rating_average' => [
            'method' => 'ratingAverage',
        ],
    ],
];
protected static function boot()
{
    self::preBoot();

    // ...
}

Mục đích là để hàm boot() trong trait mình viết vẫn được chạy còn nếu chỉ khai báo use CounterCache; thì khi mình viết hàm boot() ở model comment sẽ override hàm trong trait, như thế thì cái mình viết sẽ là vô dụng vì không được chạy. 😄 Như vậy trên đây là quá trình và cách xây dựng package Laravel CounterCache mà mình đã thực hiện. Các bạn có thể tham khảo và ứng dụng trong dự án của mình. Mình có thêm thư viện này lên github và packgist để thuận tiện hơn trong việc sử dụng. Các bạn có thể tham khảo thêm: https://github.com/quanvhframgia/laravel-counter-cache

Kết luận

Qua bài viết này mình rút ra 2 điều:

  • Khi sử dụng thư viện open source đừng quá tin tưởng và phụ thuộc quá nhiều về nó, trước đó hãy nghiên cứu kĩ và dùng thật cẩn thận vì người viết ra thư viện cũng là phận coder như chúng ta - có thể đẻ bugs bất cứ lúc nào. (yaoming)
  • Hãy học hỏi từ sai lầm của người khác vì nhiều lúc bạn sẽ không có cơ hội để phạm phải tất cả sai lầm. =))

Bài viết xin kết thúc tại đây, cảm ơn các bạn đã đọc. (love)