Tuân thủ nguyên tắc DRY trong Laravel với Single Action Class

Tuân thủ nguyên tắc DRY trong Laravel với Single Action Class

Một câu hỏi kinh điển thường xuất hiện khi nói về kiến trúc ứng dụng, đó là: “tôi nên đặt đoạn code này ở đâu?”. Mặc dù Laravel là một framework rất linh hoạt, tuy nhiên câu trả lời cho câu hỏi này không phải lúc nào cũng dễ dàng.

Viết logic trong Controller là hoàn toàn ổn khi bạn biết mình sẽ chỉ có một endpoint duy nhất trong ứng dụng sử dụng logic này. Nhưng ngày nay, việc có nhiều endpoint chia sẻ các chức năng giống nhau là điều rất phổ biến.

Ví dụ, hầu hết các ứng dụng sẽ có một biểu mẫu để đăng ký người dùng, biểu mẫu này sẽ gọi một Controller và trả về một View tùy thuộc vào hành động thành công hay thất bại. Nếu ứng dụng cũng có một phiên bản dành cho điện thoại, nó cũng cần một điểm cuối dành riêng cho việc đăng ký người dùng qua API để trả về kết quả dưới định dạng JSON. Ngoài ra, việc phải có một command để import danh sách người dùng cũng khá phổ biến, đặc biệt là trong giai đoạn đầu ứng dụng được phát triển.

Với yêu cầu như trên, tạm thời chúng ta sẽ tạo 2 Controller như sau:

class UserController
{
    public function create(CreateUserRequest $request)
    {
        $user = User::create($request->validated());

        return view('user.created', ['user' => $user]);
    }
}

class UserApiController
{
    public function create(CreateUserRequest $request)
    {
        $user = User::create($request->validated());

        return response()->json($user);
    }
}

Nhìn sơ qua chúng ta có thể thấy code đã bị trùng lặp và vi phạm nguyên tắc DRY. Mặc dù trong trường hợp này có vẻ khá vô hại, nhưng nếu logic phát triển thêm, chẳng hạn như nếu bạn muốn gửi thông báo qua email cho người dùng mới đăng ký, bạn sẽ buộc phải nhớ gửi thông báo đó trong cả hai Controller trên. Vì vậy, nếu muốn tuân thủ nguyên tắc DRY, chúng ta cần phải di chuyển đoạn code trùng lặp sang một nơi khác.

Một câu trả lời phổ biến cho vấn đề này mà bạn sẽ tìm thấy trong rất nhiều diễn đàn là: “Tạo một Service Class và gọi nó từ Controller”.

Cũng không tệ!

Nhưng làm cách nào để cấu trúc các Service Class này? Tôi có phải tạo một UserService để triển khai tất cả các logic liên quan đến người dùng mà tôi sẽ đưa vào mọi nơi cần thiết không? Hay là cái gì?

Lúc đầu, có thể sẽ không vấn đề gì khi tạo một Class duy nhất và nhóm lại tất cả các mã áp dụng cho một mô hình cụ thể. Ví dụ như này:

class UserService
{
    public function create(array $data): User
    {
        $user = new User;
        $user->email = $data['email'];
        $user->password = Hash::make($data['password']);
        $user->save();

        return $user;
    }

    public function delete($userId): bool
    {
        $user = User::findOrFail($userId);
        $user->delete();

        return true;
    }
}

Lúc này, chúng ta có thể gọi các phương thức create() hoặc delete() của UserService từ bất kỳ Controller nào và sau đó xử lý kết quả trả về theo bất kỳ cách nào chúng ta muốn.

Vậy, vấn đề với cách tiếp cận này là gì?

Vấn đề là, chúng ta hiếm khi xử lý các Model một cách riêng lẻ.

Giả sử, khi người dùng tạo tài khoản, thì một blog mới cũng được tạo ra. Nếu làm theo cách tiếp cận hiện tại, chúng ta phải tạo một lớp BlogService rồi nhồi nó vào trong lớp UserService hiện tại. Như thế này chẳng hạn:

class UserService
{
    protected $blogService;

    public function __construct(BlogService $blogService)
    {
        $this->blogService = $blogService;
    }

    public function create(array $data): User
    {
        $user = new User;
        $user->email = $data['email'];
        $user->password = Hash::make($data['password']);
        $user->save();

        $blog = $this->blogService->create();
        $user->blogs()->attach($blog);

        return $user;
    }

    ...
}

Trông cũng không có gì đáng nói!

Tuy nhiên đời luôn không như là mơ. Dễ dàng dự đoán rằng, khi ứng dụng phát triển lớn hơn, chúng ta sẽ có đến với hàng chục Service Class, một vài trong số chúng lại có 5 hoặc 6 dependency khác, và tôi cũng sẽ không ngạc nhiên nếu bạn sẽ sớm kết thúc cuộc đời với một cơ số các câu chửi thề về cái nghề mà mình đã chọn...

Giới thiệu Single Action Class

Vì vậy, điều gì sẽ xảy ra nếu, thay vì có một Service Class duy nhất với một vài phương thức, chúng ta quyết định chia nó thành nhiều class con? Đó là cách tiếp cận mà tôi đã học được trong một vài package của Spatie, áp dụng trong mọi dự án gần đây và hóa ra nó hoạt động rất hiệu quả.

Trước tiên, chúng ta hãy bỏ thuật ngữ Service quá chung chung và mơ hồ, gọi các lớp mới của chúng ta là các Action Class, đồng thời xác định rõ ràng: chúng là gì và có thể làm gì?

  • Một Action Class nên có tên tự giải thích về những gì nó làm, ví dụ: CreateOrder, ConfirmCheckout, DeleteProduct, AddProductToCart, v.v.
  • Nó chỉ nên có một phương thức public dưới dạng một API. Lý tưởng nhất là nó phải luôn sử dụng cùng một tên, như handle() hay execute(). Điều này rất hữu ích nếu chúng ta cần triển khai Adapter Pattern cho các Action Class này.
  • Nó không trực tiếp xử lý các Request Class và cũng không trả về các Response Class. Trách nhiệm này phải do Controller đảm nhiệm.
  • Nó có thể có các Action Class dưới dạng dependency.
  • Nó phải thực thi các Business Logic bằng cách ném các Exception nếu có bất kỳ điều gì ngăn nó thực thi và/hoặc trả về giá trị mong muốn và để lại việc xử lý các Exception này cho hàm gọi nó xử lý.

OK, chúng ta đã điểm qua các yêu cầu cần thiết để tạo một Action Class, bây giờ là lúc đi vào thực tế bằng việc viết lại công việc đã xử lý bên trên.

Ví dụ về Action Class: tạo lớp CreateUser

class CreateUser
{
    protected $createBlog;

    public function __contruct(CreateBlog $createBlog)
    {
        $this->createBlog = $createBlog;
    }

    public function execute(array $data): User
    {
        $email = $data['email'];

        if (User::whereEmail($email)->count() >= 1) {
            throw new EmailNotUniqueException("{$email} này đã được sử dụng");
        }

        $user = new User;
        $user->email = $email;
        $user->password = Hash::make($data['password']);
        $user->save();

        $blog = $this->createBlog->excecute();
        $user->blog()->attach($blog);

        return $user;
    }
}

Có thể bạn sẽ thắc mắc tại sao phương thức execute() lại ném ra một Exception nếu Email đã được sử dụng bởi ngưởi dùng khác. Đáng lẽ nó phải được xử lý bởi Request Class chứ?

Hiển nhiên là chúng ta vẫn sẽ sử dụng Request Class để xác thực các thông tin gửi lên rồi. Tuy nhiên, bạn vẫn nên thực thi các điều kiện bên trong chính Action Class. Nó làm cho logic rõ ràng hơn để hiểu và việc gỡ lỗi cũng sẽ đơn giản hơn.

Và đây là phiên bản mới của Controller khi sử dụng Action Class:

class UserController
{
    public function create(CreateUserRequest $request, CreateUser $action)
    {
        $user = $action->execute($request->validated());

        return view('user.created', ['user' => $user]);
    }
}

class UserApiController
{
    public function create(CreateUserRequest $request, CreateUser $action)
    {
        $user = $action->execute($request->validated());

        return response()->json($user);
    }
}

Bây giờ, nếu bất kỳ sửa đổi nào đối với quá trình đăng ký người dùng của mình, chúng ta sẽ chỉ phải sửa lại trong CreateUser. Đẹp đẽ và gọn gàng, phải không?

Nhúng các Action Class

Giả sử rằng chúng ta cần một Action Class để import hàng nghìn người dùng vào ứng dụng của mình. Khi đó chúng ta có thể viết một Action Class cho quá trình này, lớp này sẽ sử dụng lại CreateUser mà chúng ta đã tạo ở bên trên:

class ImportUser
{
    protected $createUser;

    public function __contruct(CreateUser $createUser)
    {
        $this->createUser = $createUser;
    }

    public function execute(array $rows): Collection
    {
        return collect($rows)->map(function($row) {
            try {
                return $this->createUser->execute($row)
            }
            catch (EmailNotUniqueException $e) {
                // Xử lý các user có email bị trùng lặp tại đây
            }
        })
    }
}

Rất đẹp phải không? Chúng ta có thể tái sử dụng CreateUser của mình bằng cách nhúng nó vào phương thức Collection::map(), sau đó trả về một danh sách chứa tất cả người dùng mới được tạo.

Bạn cũng có thể cải thiện hơn một chút hơn bằng cách trả lại Null Object khi email bị trùng hoặc log lại thông tin này, hoặc các xử lý khác tùy yêu cầu. Ý tưởng đã có, và bạn chỉ việc chiến thôi.

Ứng dụng trong Decorator Pattern

Bây giờ, giả sử chúng ta muốn log lại mọi người dùng đã đăng ký mới vào một file. Có nhiều cách để làm việc này, như đặt thêm code vào trong CreateUser class, nhưng tốt hơn là nên áp dụng Decorator Pattern:

class LogCreateUser extends CreateUser
{
    public function execute(array $data): User
    {
        Log::info("Người dùng mới đăng ký: {$data['email']}")

        return parent::execute($data);
    }
}

Sau đó, bằng cách sử dụng Service Container của Laravel, chúng ta có thể bind LogCreateUser với CreateUser, khi đó, lớp trước sẽ được đưa vào bất cứ khi nào chúng ta cần một instance của lớp sau:

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(CreateUser::class, LogCreateUser::class);
    }
}

Hoặc thậm chí dễ dàng bật/tắt tính năng Log với một biến config:

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        if (config('app.log_registration')) {
            $this->app->bind(CreateUser::class, LogCreateUser::class);
        }
    }
}

Kết

Sử dụng cách tiếp cận này có thể sẽ khiến bạn phải tạo nhiều class lúc ban đầu. Và, tất nhiên đăng ký người dùng chỉ là một ví dụ đơn giản nhằm giữ cho bài viết được ngắn gọn và dễ hiểu. Giá trị của các Action Class thực bắt đầu trở nên rõ ràng khi độ phức tạp bắt đầu tăng lên, bởi vì bạn biết mã của bạn nằm ở một nơi duy nhất và ranh giới được xác định rõ ràng.

Lợi ích khi sử dụng Action Class:

  • Ngăn chặn sự trùng lặp code và tăng cường khả năng tái sử dụng. Giữ mọi thứ được SOLID.
  • Dễ dàng test trong nhiều tình huống khác nhau.
  • Những cái tên có ý nghĩa giúp bạn dễ dàng điều hướng bên trong cấu trúc của dự án lớn.
  • Dễ dàng sử dụng Decorator Pattern.
  • Tính nhất quán trong toàn bộ dự án: ngăn chặn mã được lan truyền trên Controller, Model, v.v.

Và tất nhiên, đây là cách tiếp cận của tôi dựa trên kinh nghiệm của mình với Laravel trong vài năm qua và các loại dự án mà tôi phải xử lý. Cá nhân mà nói, tôi thực sự cảm thấy nó có hiệu quả với các dự án vừa và nhỏ, và chắc chắn tôi vẫn tiếp tục áp dụng cho các dự án sau này.

Rất hy vọng nó cũng sẽ có ích cho bạn trong quá trình phát triển các ứng dụng Laravel của mình.

Công ty TNHH Giải pháp Website & Ứng dụng phần mềm Quang Minh

🚩 Địa chỉ
Số 81 Võ Huy Tâm, Phường Cẩm Trung, Thành phố Cẩm Phả, Tỉnh Quảng Ninh
📞 Điện thoại
(0862) 814-787
💌 Email
[email protected]
🌐 Zalo OA
https://zalo.me/369605269295116980
🌐 Facebook
https://www.facebook.com/qmasdotvn/
🌐 Twitter