When & How to split your Laravel controllers

Laravel

Introduction

Khi làm việc với các Web-application Framework nói chung và Laravel nói riêng, chắc hẳn bạn đã từng gặp những God Controllers với hàng chục phương thức (tính cả các action chính và các phương thức trợ giúp - helper methods) và kéo dài tới vài trăm dòng lệnh. Dù bạn là người viết những controllers đó từ những dòng đầu tiên, cũng rất khó để bạn có thể nắm được những gì chúng đang thực hiện sau vài tháng quay lại. Đối với Laravel thì có khá nhiều cách để làm giảm kích thước của controller như service classes, events, repositories, query objects, policies, middlewares,... Tùy thuộc vào độ phức tạp của project mà chúng ta có thể lựa chọn phương pháp phù hợp.

Tuy nhiên trong bài viết này mình sẽ không bàn về những phương pháp trên (trên Viblo đã có rất nhiều bài viết chi tiết về các vấn đề đó). Thay vào đó mình sẽ nói về việc tách các God Controllers thành những controller nhỏ và dễ quản lý hơn. Những dấu hiệu nào cho ta biết chúng ta nên tách các controllers và tách như thế nào cho hợp lý. Có thể vấn đề này khá đơn giản tuy nhiên nếu áp dụng đúng cách thì sẽ mang lại khá nhiều hiệu quả khi dự án ngày một phức tạp hơn.

Trước tiên hãy làm một phép tính đơn giản, bạn hãy mở các file route (routes/web.php, routes/api.php) ra và đếm số lượng các action, số lượng controller và tính toán con số sau:

A/C=Average number of actions per controller=Total number of actionsTotal number of controllersA/C = Average \space number \space of \space actions \space per \space controller = \dfrac{Total \space number \space of \space actions}{Total \space number \space of \space controllers}

Updated: Bạn có thể sử dụng package này để lấy những số liệu thống kê về codebase hiện tại thay vì làm thủ công: https://github.com/stefanzweifel/laravel-stats

Giả sử con số bạn nhận được lớn hơn 10, điều đó có nghĩa là số lượng controller của bạn quá ít và chúng khá phức tạp. Ngược lại nếu con số này quá nhỏ có nghĩa bạn đang thực hiện những việc không cần thiết và khiến cho codebase quá rời rạc. Mình thấy nếu con số nhận được trong khoảng [4,5][4, 5] thì bạn đang làm tốt trong việc phân phối logic giữa các controllers. Tất nhiên, con số này không phải là thước đo chuẩn mực, nó chỉ là một con số tham khảo và cho bạn biết rằng bạn có nên thực hiện việc refactor trên các controler hay không mà thôi.

Trong các phần sau của bài viết, mình sẽ trình bày một số trường hợp mà bạn nên thực hiện việc tách controller và những lưu ý khi sử dụng các phương pháp đó. Để thuận tiện, chúng ta sẽ tưởng tượng như mình đang xây dựng một ứng dụng nho nhỏ cho phép hiển thị danh sách các Podcasts (mình rất thích nghe các Podcast về công nghệ 😄). Mỗi podcast sẽ gồm nhiều episodes khác nhau. Bạn có thể subscribe/unsubscribe một podcast nào đó, thay đổi ảnh cho podcast, hiển thị danh sách các episodes trong podcast, hiển thị tổng hợp các episodes từ các podcast khác nhau - recent episodes,... Ngoài ra RESTful sẽ là một khái niệm được nhắc đến khá nhiều trong bài viết, mình nghĩ chắc ai cũng nắm được khá niệm này nên không trình bày chi tiết trong bài viết này. Để bắt đầu, chúng ta giả sử cấu trúc của file route hiện tại sẽ như sau (lưu ý chúng ta không sử dụng implicit route model binding ở đây):

Route::get('/podcasts', '[email protected]');
Route::get('/podcasts/new', '[email protected]');
Route::post('/podcasts', '[email protected]');
Route::get('/podcasts/{id}', '[email protected]');
Route::get('/podcasts/{id}/edit', '[email protected]');
Route::patch('/podcasts/{id}', '[email protected]');
Route::delete('/podcasts/{id}', '[email protected]');

Route::get('/podcasts/{id}/episodes', '[email protected]');
Route::post('/podcasts/{id}/update-cover-image', '[email protected]');
Route::post('/podcasts/{id}/subscribe', '[email protected]');
Route::post('/podcasts/{id}/unsubscribe', '[email protected]');

Route::get('/episodes', '[email protected]');
Route::get('/episodes/{id}', '[email protected]');
Route::get('/podcasts/{id}/episodes/new', '[email protected]');
Route::post('/podcasts/{id}/episodes', '[email protected]');
Route::get('/episodes/{id}/edit', '[email protected]');
Route::patch('/episodes/{id}', '[email protected]');

Trong ví dụ trên A/C=17/2=8.5A/C = 17 / 2 = 8.5 - một con số khá lớn, một dấu hiệu cho thấy số lượng controller của chúng ta là quá ít và một controller đang làm quá nhiều công việc. Có một quy tắc khá đơn giản khi viết các controller đó là:

Chỉ sử dụng các phương thức của một RESTful controller và không sử dụng custom method cho controller action. Đối với Laravel, các phương thức đó sẽ là: index, create, store, show, edit, updatedestroy

Trong ví dụ trên, listEpisodes, updateCoverImage, subscribe, unsubscribe là các custom action bên trong PodcastsController. Chúng ta sẽ bắt đầu việc refactor các controller hiện tại theo quy tắc phía trên. 👏

1. Nested Resources

Chúng ta sẽ bắt đầu với controller action sau:

Route::get('/podcasts/{id}/episodes', '[email protected]');

Nhiệm vụ của action này là liệt kê danh sách các episodes của một podcast nào đó. Nếu nghĩ một cách thông thường, action này nằm trong PodcastsController là một điều khá hợp lý. Tuy nhiên để ý rằng tên của action hiện tại là listEpisodes - điều chúng ta không mong muốn.

Bước đầu tiên khi tách controller là xem xét action nào của RESTful là phù hợp với action hiện tại của chúng ta. Đối với trường hợp hiện tại, chúng ta có thể nhanh chóng xác định index sẽ là phương thức chúng ta cần. Tuy nhiên để ý rằng PodcastsController đã có phương thức index với nhiệm vụ liệt kê danh sách các podcasts hiện tại 🤔 Xa hơn một chút, chúng ta có thể nghĩ đến việc dùng phương thức index bên trong EpisodesController, tuy nhiên một lần nữa phương thức đó đang được sử dụng cho một việc khác 🤔

Một cách khác không tốt chút nào đó là sử dụng [email protected] cho cả việc liệt kê danh sách các recent episodes và liệt các episodes của một podcast. Nói cách khác, chúng ta sẽ gộp hai controller action sau thành một controller action:

Route::get('/podcasts/{id}/episodes', '[email protected]');
Route::get('/episodes', '[email protected]');

Lưu ý, action đầu tiên có nhận vào một parameter là $id còn action sau thì không. Do đó, chúng ta có thể làm như sau trong phương thức index của EpisodesController

// [email protected]

public function index($id = null)
{
    // Here $id is an ID of a given podcast
    if ($id) {
        // Fetch episodes for a given podcast
    }
    
    // Fetch recent episodes from all podcasts
}

Ở đây chúng ta đang cố gắng để tuân thủ quy tắc trên, tuy nhiên lại đi theo một hướng sai :slight_frown:. Việc sử dụng một optional parameter như trong ví dụ trên sẽ làm cho controller trở nên phức tạp và khó thay đổi sau này. Hơn nữa, logic ở hai action trên có thể hoàn toàn không liên quan gì tới nhau, làm như vậy sẽ khiến cho controller có quá nhiều responsibilities - không tốt một chút nào.

Vậy chúng ta sẽ làm gì trong trường hợp này? 🤔 Câu trả lời là tạo một controller mới, tuy nhiên chúng ta sẽ đặt tên nó như thế nào? Để trả lời cho câu hỏi trên, chúng ta cần xem xét tới resource mà chúng ta đang thực hiện công việc với nó. Nó có phải là Podcast ? - không. Nó có phải là Episode - không hẳn. Chính xác nó sẽ là một resource mô tả Episodes của một Podcast - chúng ta sẽ gọi nó là PodcastEpisode - khá đơn giản phải không 😄. Sau khi đã xác định được resource cần dùng, công việc còn lại sẽ là tạo controller cho resource đó:

Route::get('/podcasts/{id}/episodes', '[email protected]');

Cool!

Tuy nhiên hãy để ý đến hai action sau trong EpisodesController liên quan đến việc tạo mới episode cho một podcast nào đó.

Route::get('/podcasts/{id}/episodes/new', '[email protected]');
Route::post('/podcasts/{id}/episodes', '[email protected]');

Để ý rằng hai action này đều có URI với prefix là podcasts, vì vậy sẽ hợp lý hơn nếu chúng ta chuyển hai action này sang PodcastEpisodesController mà chúng ta vừa tạo. Kết quả, thay vì một EpisodesController với 6 action, chúng ta sẽ có một controller mới: PodcastEpisodesController (3 actions) và EpisodesController (chỉ còn 4 actions).

Trong nhiều trường hợp, chúng ta nên tạo mới controller cho các nested resources thay vì sử dụng các action trong controller của các resources riêng biệt.

2. Independently Edited Resources

Trong phần này của bài viết, chúng ta sẽ xem xét đến action sau:

Route::post('/podcasts/{id}/update-cover-image', '[email protected]');

Nhiệm vụ của action này là cho phép người dùng thay đổi cover image của một podcast. Thông thường, việc thay đổi hình ảnh sẽ được tách riêng thành một chức năng nhỏ, chúng ta hoàn toàn có thể gộp việc này khi thực hiện update thông tin của một podcast. Tuy nhiên nó sẽ làm mọi thứ trở nên phức tạp hơn, do xử lý file uploads không phải là công việc đơn giản.

Tương tự như trong phần đầu, update sẽ là RESTful method mà chúng ta sẽ sử dụng. Chúng ta cũng có thể nghĩ đến cách gộp phương thức updateCoverImage vào bên trong phương thức update của PodcastsController. Tuy nhiên việc này sẽ mang lại nhiều bất lợi, lý do cũng tương tự như đã giải thích trong phần trước của bài viết.

Chúng ta không đề cập đến chi tiết của codebase cũng như database ở đây. Tuy nhiên, chúng ta sẽ ngầm hiểu rằng cover_image sẽ là một attribute của Podcast model. Chúng ta muốn sử dụng phương thức RESTful ở đây, nên điều chúng ta cần làm là coi cover image như một resource cụ thể (mặc dù nó không có bảng riêng, cũng không có model tương ứng) - chúng ta sẽ gọi nó là PodcastCoverImage. Công việc tiếp theo là tạo controller cho resource này:

Route::put('/podcasts/{id}/cover-image', '[email protected]');

Có hai thay đổi chính ở đây:

  • URI chuyển từ: /podcasts/{id}/update-cover-image thành /podcasts/{id}/cover-image, do cover image đã được coi là một resource cụ thể, riêng biệt nên việc perfix URI với update- là không cần tiết và khá thừa.
  • HTTP Verb chuyển từ post sang put. Trước khi tách controller chúng ta chưa có một resource tương ứng cho cover image. Do đó chúng ta post sẽ được dùng như một phương thức chữa cháy. Sau khi tách controller, thì put sẽ là một phương thức chuẩn hơn khi làm việc với RESTful.

Nếu một property (attribute) nào đó được thay đổi một cách riêng biết, chúng ta sẽ coi nó như một resource cụ thể và tạo mới controller cho resource đó (lưu ý resource ở đây chỉ là một quy ước chứ không phải là một resource thực sự do chúng ta không hề có model hay database model riêng cho nó).

3. Pivots

Pivot table không phải là một khái niệm mới, nó đơn giản là một cách để thể hiện quan hệ Many to Many hay mở rộng hơn sẽ là Polymorphic Relations. Trong ví dụ của chúng ta, người dùng sẽ được quyền subscribe hoặc unsubscribe một podcast nào đó - một trường hợp khá phù hợp cho quan hệ Many to Many. Và trong Laravel, mặc định tên của bảng pivot sẽ là podcast_user. Chúng ta sẽ cùng xem xét hai action dưới đây:

Route::post('/podcasts/{id}/subscribe', '[email protected]');
Route::post('/podcasts/{id}/unsubscribe', '[email protected]');

Trong hai phần trước, chúng ta dễ dàng khẳng định các phương thức RESTful cần dùng sẽ là indexupdate. Tuy nhiên trong trường hợp hiện tại, việc subscribing to a podcast có thể được map đến khá nhiều RESTful action khác nhau, cụ thể là create, store, hoặc update. Vậy chúng ta sẽ lựa chọn phương thức nào 🤔

Trước hết chúng ta cần đặt ra một câu hỏi đơn giản: Trước và sau khi người dùng thực hiện việc subscribe một podcast chúng ta có thêm gì mới? Hmm... Chúng ta sẽ có một podcast subscription mới sau khi người dùng thực hiện việc trên. Vậy tại sao chúng ta không nghĩ đến việc tạo một resource mới cho podcast subscription nhỉ, chúng ta sẽ gọi resource đó là Subscription cho đơn giản hóa vấn đề. Ở đây Subscription sẽ là một resource thực với một model tên Subscription và bảng tương ứng sẽ là podcast_user (pivot table).

Cool!

Tuy nhiên để ý rằng việc đặt tên bảng hiện tại là podcast_user trong khi resource của chúng ta là Subscription, có vẻ không được hợp lý cho lắm :upside_down:. Chúng ta có thể sửa điều này khá đơn giản bằng cách override tên của pivot table khi định nghĩa relation trong Laravel (các bạn có thể đọc documentation của Laravel về vấn đề này). Công việc bây giờ của chúng ta đơn giản là tạo SubscriptionsControllerSubscription model tương ứng cho resource mà chúng ta vừa định nghĩa.

Để ý rằng URI trước kia của chúng ta đang là /podcasts/{id}/subscribe, chúng ta sẽ giữa nguyên URI này hay nên thay đổi nó 🤔 Câu trả lời là chúng ta sẽ thay đổi nó, đơn giản bởi vì podcast subscription đã được coi là một resource riêng vì vậy việc scope podcast subscription với podcasts là không còn hợp lý. Nếu theo chuẩn của RESTful, URI sẽ được thay đổi thành /subscriptions (post to the collection).

Route::post('/subscriptions', '[email protected]');

Bạn có thể hỏi vậy chúng ta sẽ lấy ID của podcast từ đâu khi mà URI mới không còn parameter nữa. Để ý rằng khi tạo mới một resource, dữ liệu sẽ đến từ request thay vì từ route parameter. Giả sử, bạn muốn tạo mới một bài viết với hai property là titlebody bạn sẽ làm như sau: Post:create(request(['title', 'body']));, chứ không lấy các property đó từ route.

Trong trường hợp của chúng ta, ID của podcast sẽ lấy từ request, có thể là request('podcast_id'). Tương tự chúng ta sẽ chuyển action liên quan đến việc unsubscribing to a podcast sang SubscriptionsController mà chúng ta vừa tạo.

Khi làm việc với pivots, đôi khi chúng ta nên coi chúng như một resource cụ thể với controller và model tương ứng.

4. Resource State Transitions

Mình sẽ đi qua khá nhanh phần này của bài viết vì nó khá tương tự như phần thứ 2. Giả sử, ứng dụng của chúng ta cho phép owner của một podcast publishunpublish podcast đó, bằng việc set/unset giá trị cho trường published_at trong bảng podcasts. Chúng ta cũng sẽ có hai action tương ứng cho việc này:

Route::post('/podcasts/{id}/publish', '[email protected]');
Route::post('/podcasts/{id}/unpublish', '[email protected]');

Nếu theo ý tưởng chúng ta đề cập đến trong phần thứ 2, chúng ta sẽ coi publish như một resource cụ thể Published chẳng hạn. Hmm... resource đó có vẻ không ổn cho lắm, ngay từ tên gọi đã làm cho chúng ta khá khó hiểu. Tại sao chúng ta không đặt câu hỏi tương tự như phần trước của bài viết: Trước và sau khi người dùng thực hiện việc publish một podcast chúng ta có thêm gì mới? Câu trả lời là chúng ta sẽ có một published podcast vẫn là một podcast nhưng được thay đổi trạng thái. Do đó chúng ta sẽ sử dụng một resource mới với tên là PublishedPodcast thay vì Published như trước. Hai action trên sẽ được chuyển thành phương thức storedestroy tương ứng và URI sẽ được chuyển thành /published-postcasts/published-podcasts/{id}. Done!

Khi cần thực hiện các việc liên quan đến thay đổi trạng thái của một resource đã có, chúng ta sẽ coi resource đã thay đổi trạng thái như một resource mới và tạo controller cho resource đó.

Conclusion

Trong bài viết này, mình có trình bày một số cách để tách các controller lớn thành các controller nhỏ và dễ quản lý. Trên thực tế tùy từng trường hợp mà chúng ta sẽ áp dụng phương pháp phù hợp, hoặc không áp dụng phương pháp nào đã trình bày ở trên nếu bạn cảm thấy nó không cần thiết. Mong bài viết sẽ giúp ích được một phần nhỏ nào đó cho các bạn khi làm việc với Laravel controller 😃