Project Laravel nào cũng có một ông như vầy: method CheckoutController@store hồi đầu 30 dòng, giờ lên 340. Nó kiểm tra tồn kho, tính lại giá, check mã giảm giá, cộng VAT, tạo đơn, giữ hàng, gọi cổng thanh toán, và đâu đó ở giữa có cái if ($request->has('gift_wrap')) ai đó thêm vô chiều thứ Sáu. Không ai muốn đụng vô, sửa chỗ này là gãy chỗ khác, và test duy nhất là một feature test 200 dòng mock nửa cái app.
Bản thân nghiệp vụ không có gì sai. Nó là một chuỗi bước, bước nào cũng nhận cùng một cục dữ liệu, làm đúng một việc rồi chuyển tiếp. Laravel có sẵn công cụ sinh ra cho đúng hình dạng đó, và mình xài nó ở mọi request mà hổng để ý: cái pipeline chạy middleware. Code của mình cũng xài được nó qua facade Pipeline.
Pipeline chạy ra sao
Pipeline nhận một "passable" (giá trị gì cũng được: DTO, query builder, chuỗi) rồi đẩy nó qua một danh sách pipe. Mỗi pipe nhận passable và một closure $next. Pipe có thể sửa passable, gọi $next($passable) để đi tiếp, làm thêm việc sau khi $next trả về, hoặc dừng cả chuỗi bằng cách return hay throw mà không gọi $next. Y chang hợp đồng của middleware.
Pipeline::send($passable) giá trị đi xuyên qua pipeline
->through([...pipes]) class, class string hoặc closure
->via('handle') method được gọi trên class pipe (mặc định: handle)
->then($destination) closure cuối, nhận giá trị đã xử lý
->thenReturn() viết tắt: trả về nguyên giá trị đã xử lý
Bên trong, Laravel dựng một củ hành: bọc closure đích trong pipe cuối, bọc cái đó trong pipe kế cuối, cứ vậy bằng array_reduce, rồi gọi lớp ngoài cùng. Pipe dạng class string được resolve từ container lúc gọi, nên constructor của nó type-hint dependency gì cũng được.
Thiết kế object hành khách
Trước khi viết pipe, quyết định cái gì sẽ đi xuyên qua chúng. Truyền nguyên Request hay một mảng lỏng lẻo thì demo được, nhưng pipe nào cũng phải đoán key. Một DTO nhỏ có thể sửa được, gọi là "passenger", cho mỗi bước một chỗ có kiểu rõ ràng để đọc input và ghi kết quả:
namespace App\Checkout;
use App\Models\Cart;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\User;
final class CheckoutData
{
public int $subtotal = 0;
public int $discount = 0;
public int $tax = 0;
public ?Coupon $coupon = null;
public ?Order $order = null;
public ?string $paymentReference = null;
public function __construct(
public readonly User $user,
public readonly Cart $cart,
public readonly ?string $couponCode = null,
) {}
public function total(): int
{
return max(0, $this->subtotal - $this->discount) + $this->tax;
}
}
Input để readonly vì không pipe nào được đổi người đang checkout hay giỏ hàng đang mua. Output là property public bình thường, các pipe lần lượt điền vô. Tiền lưu bằng số nguyên đơn vị nhỏ nhất (với VND thì chính là số tiền) để khỏi dính lỗi làm tròn float.
Pipe là class
Mỗi bước thành một class có đúng một method handle. Đây là bước áp mã giảm giá:
namespace App\Checkout\Pipes;
use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Models\Coupon;
use Closure;
final class ApplyCoupon
{
public function handle(CheckoutData $data, Closure $next): mixed
{
if ($data->couponCode === null) {
return $next($data);
}
$coupon = Coupon::query()
->where('code', $data->couponCode)
->where('expires_at', '>', now())
->first();
if ($coupon === null) {
throw CheckoutFailed::because('Coupon is invalid or expired.');
}
$data->coupon = $coupon;
$data->discount = min($data->subtotal, $coupon->amountOff($data->subtotal));
return $next($data);
}
}
Đọc từ trên xuống là biết bước này làm gì. Nó không biết gì về thuế, thanh toán hay HTTP request. Nó chỉ có một lý do để thay đổi. Cái return $next($data) sớm cho trường hợp "không có coupon" là pattern mình sẽ xài nhiều nhất: pipe nào không áp dụng thì cứ chuyển dữ liệu đi tiếp.
Throw exception là cách một pipe hủy cả luồng. Vì các pipe sau chỉ được gọi qua $next, exception trong ApplyCoupon nghĩa là CreateOrder và ChargePayment không bao giờ chạy.
Ráp pipeline checkout
Action gom mọi thứ lại giờ ngắn đủ nằm gọn một màn hình, và thứ tự các bước đọc lên y như quy trình nghiệp vụ:
namespace App\Checkout;
use App\Checkout\Pipes\ApplyCoupon;
use App\Checkout\Pipes\CalculateSubtotal;
use App\Checkout\Pipes\CalculateTax;
use App\Checkout\Pipes\ChargePayment;
use App\Checkout\Pipes\CreateOrder;
use App\Checkout\Pipes\EnsureItemsInStock;
use App\Checkout\Pipes\ReserveInventory;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;
final class PlaceOrder
{
/** @var list<class-string> */
private array $pipes = [
EnsureItemsInStock::class,
CalculateSubtotal::class,
ApplyCoupon::class,
CalculateTax::class,
CreateOrder::class,
ReserveInventory::class,
ChargePayment::class,
];
public function __invoke(CheckoutData $data): Order
{
return DB::transaction(fn () => Pipeline::send($data)
->through($this->pipes)
->then(fn (CheckoutData $data): Order => $data->order));
}
}
Bọc pipeline trong DB::transaction nghĩa là exception nào xảy ra cũng rollback cả dòng order lẫn phần giữ hàng. Nhưng nhớ là gọi thanh toán là side effect nằm ngoài database: nếu ChargePayment thành công mà commit fail sau đó, mình đã trừ tiền khách cho một đơn không tồn tại. Đặt bước charge ở cuối, giữ nó idempotent bằng một reference lưu trên order, và cân nhắc transactional outbox cho mọi việc phải xảy ra đúng một lần sau commit.
Vì pipe quyết định lúc nào gọi $next, mình còn viết được bước bọc quanh phần còn lại của chuỗi, giống middleware đo thời gian response:
namespace App\Checkout\Pipes;
use App\Checkout\CheckoutData;
use Closure;
use Illuminate\Support\Facades\Log;
final class LogCheckoutDuration
{
public function handle(CheckoutData $data, Closure $next): mixed
{
$startedAt = hrtime(true);
$result = $next($data);
Log::info('checkout.completed', [
'user_id' => $data->user->id,
'total' => $data->total(),
'ms' => (hrtime(true) - $startedAt) / 1e6,
]);
return $result;
}
}
Đặt nó đầu danh sách là nó đo hết mọi bước phía sau. Thêm hành vi xuyên suốt như vầy không còn phải sửa các bước nghiệp vụ nữa.
Pipeline cho query filter
Ứng dụng kinh điển thứ hai là lọc endpoint danh sách. Passable là Eloquent Builder, mỗi filter chỉ thêm where của nó khi tham số query string tương ứng có mặt:
namespace App\Http\Controllers;
use App\Models\Product;
use App\QueryFilters\Category;
use App\QueryFilters\InStock;
use App\QueryFilters\PriceRange;
use App\QueryFilters\Search;
use App\QueryFilters\Sort;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Pipeline;
final class ProductIndexController
{
public function __invoke(): CursorPaginator
{
return Pipeline::send(Product::query()->where('is_active', true))
->through([
Search::class,
Category::class,
PriceRange::class,
InStock::class,
Sort::class,
])
->thenReturn()
->cursorPaginate(24)
->withQueryString();
}
}
namespace App\QueryFilters;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
final class PriceRange
{
public function __construct(private Request $request) {}
public function handle(Builder $query, Closure $next): mixed
{
$min = $this->request->integer('min_price');
$max = $this->request->integer('max_price');
if ($min > 0) {
$query->where('price', '>=', $min);
}
if ($max > 0) {
$query->where('price', '<=', $max);
}
return $next($query);
}
}
Filter nào cũng được resolve từ container, nên Request được inject qua constructor. Thêm filter mới là thêm một class và một dòng trong mảng, controller không phải sửa logic gì. thenReturn() trả lại builder, rồi mình paginate như bình thường.
Còn hai tính năng nên biết. Class string có thể kèm tham số sau dấu hai chấm, tới handle dưới dạng tham số chuỗi bổ sung (y như throttle:60,1 trong middleware). Và closure trộn chung với class được, tiện cho mấy bước nhỏ xíu. via() đổi tên method nếu pipe của mình không xài handle:
use App\Checkout\Pipes\EnsureMinimumTotal;
use Illuminate\Support\Facades\Pipeline;
$result = Pipeline::send($data)
->through([
EnsureMinimumTotal::class . ':50000,VND',
fn ($data, $next) => $next($data),
])
->via('process')
->thenReturn();
Kiểm thử pipe và pipeline
Vì mỗi pipe là một class một method với $next rõ ràng, unit test không cần tới tầng HTTP. Gọi thẳng handle và truyền một closure làm $next để ghi lại là nó có được gọi hay không:
use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Checkout\Pipes\ApplyCoupon;
use App\Models\Cart;
use App\Models\Coupon;
use App\Models\User;
function checkoutData(?string $code, int $subtotal): CheckoutData
{
$data = new CheckoutData(User::factory()->make(), Cart::factory()->make(), $code);
$data->subtotal = $subtotal;
return $data;
}
it('applies a valid coupon and passes the data on', function () {
Coupon::factory()->create(['code' => 'TET2027', 'amount' => 50_000, 'expires_at' => now()->addDay()]);
$called = false;
$result = (new ApplyCoupon())->handle(checkoutData('TET2027', 300_000), function ($data) use (&$called) {
$called = true;
return $data;
});
expect($called)->toBeTrue()
->and($result->discount)->toBe(50_000)
->and($result->total())->toBe(250_000);
});
it('stops the pipeline when the coupon is expired', function () {
Coupon::factory()->create(['code' => 'OLD', 'expires_at' => now()->subDay()]);
(new ApplyCoupon())->handle(checkoutData('OLD', 300_000), fn () => throw new LogicException('next must not run'));
})->throws(CheckoutFailed::class);
it('skips silently when no coupon code is given', function () {
$result = (new ApplyCoupon())->handle(checkoutData(null, 120_000), fn ($data) => $data);
expect($result->discount)->toBe(0);
});
Ba case là đủ cho đa số pipe: biến đổi dữ liệu rồi gọi $next, hủy luồng mà không gọi $next, và đi qua nguyên vẹn khi không áp dụng. Cho $next trong test "hủy luồng" throw luôn, để pipe nào quên dừng là bị bắt liền.
Sau đó một hai test ở mức pipeline kiểm tra thứ tự và hợp đồng giữa các bước. Vì class pipe được resolve từ container, mình thay một pipe bằng mock được:
use App\Checkout\Pipes\ChargePayment;
use App\Checkout\PlaceOrder;
it('never charges the card when an earlier pipe fails', function () {
$this->mock(ChargePayment::class)->shouldNotReceive('handle');
$data = checkoutData('DOES-NOT-EXIST', 300_000);
expect(fn () => app(PlaceOrder::class)($data))->toThrow(App\Checkout\CheckoutFailed::class);
$this->assertDatabaseCount('orders', 0);
});
Test đó canh đúng cái lỗi đắt nhất của luồng checkout: trừ tiền trong khi đơn lẽ ra phải bị từ chối.
Những lỗi hay gặp
- Quên return
$next($data). Gọi$next($data);mà thiếureturnlà kết quả rơi mất,thennhậnnull. Static analysis bắt được nếuhandlekhai báo return type. - Phụ thuộc thứ tự ngầm.
CalculateTaxcầnsubtotalvàdiscount, nên phải đứng sauApplyCoupon. Ghi chú rõ trong class passenger, hoặc assert điều kiện đầu pipe và throw sớm. - Truyền Request đi khắp nơi. Pipe đọc thẳng từ request thì không tái sử dụng được trong queued job hay Artisan command. Copy input vô passenger một lần ở ranh giới.
- Xài pipeline cho hai bước.
Pipeline::send()->through([A::class, B::class])cho một hàm hai dòng là vòng vo vô ích. Gọi method thẳng cho rõ. - Nuốt exception trong pipe bọc ngoài. Pipe log hay đo thời gian mà catch
Throwablerồi trả về thứ khác sẽ biến thanh toán thất bại thành checkout "thành công". Nhớ rethrow. - Side effect không rollback được. Thao tác database trong transaction thì an toàn; email, webhook, thanh toán thì không. Dispatch mấy cái đó sau commit (
ShouldQueueAfterCommit,DB::afterCommit(), hoặc outbox).
Khi nào nên và không nên xài pipeline
Pipeline hợp khi:
- quy trình là một chuỗi tuyến tính các bước trên cùng dữ liệu: checkout, onboarding user, xử lý từng dòng import, publish tài liệu;
- các bước tùy chọn hoặc cấu hình được: dựng danh sách pipe từ feature flag, setting của tenant hay loại đơn;
- mình cần filter cho trang danh sách, mỗi tham số ứng với một mệnh đề query;
- mình cần hành vi bọc quanh (đo giờ, log, lock) mà không đụng từng bước.
Nên chọn cách khác khi:
- luồng rẽ nhánh nhiều ("nếu B2B thì đi đường năm bước này, không thì đường kia"): state machine hoặc action riêng cho từng nhánh dễ theo dõi hơn;
- các bước cần chạy song song hoặc phải sống sót qua restart: xài queued job với
Bus::chain()hayBus::batch(), có retry và lưu trạng thái mà pipeline trong bộ nhớ không có; - chỉ có một hai bước: gọi thẳng cho lẹ.
Quay lại cái controller 340 dòng: refactor xong nó chỉ validate request, dựng CheckoutData, gọi PlaceOrder rồi trả resource. Mỗi quy tắc nghiệp vụ nằm trong một class test riêng được, và yêu cầu "gói quà" kế tiếp chỉ là một pipe mới với một dòng trong danh sách.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết một pipeline tối giản giống Laravel. Tạo class Pipeline gồm: send(passable) lưu giá trị và trả về chính pipeline; through(pipes) lưu mảng các hàm pipe và trả về pipeline; then(destination) cho passable chạy qua các pipe theo thứ tự rồi trả kết quả; và thenReturn() (Python: then_return()) giống then() với destination trả nguyên giá trị. Mỗi pipe là một hàm được gọi dạng pipe(value, next). Gọi next(newValue) sẽ chạy phần còn lại của chuỗi (các pipe sau rồi tới destination) và trả về kết quả, nên pipe chạy code được cả trước lẫn sau next. Pipe nào return mà không gọi next thì dừng chuỗi: các pipe sau và destination không chạy, giá trị nó return thành kết quả. Không có pipe nào thì then() chỉ gọi destination(passable).
Code khởi tạo
class Pipeline {
constructor() {
this.passable = undefined;
this.pipes = [];
}
send(passable) {
// TODO: store the passable and return this
throw new Error('TODO');
}
through(pipes) {
// TODO: store the list of pipes and return this
throw new Error('TODO');
}
then(destination) {
// TODO: run the passable through every pipe in order.
// Each pipe is called as pipe(passable, next); next(value) continues the chain.
// After the last pipe, call destination(value) and return what it returns.
throw new Error('TODO');
}
thenReturn() {
// TODO: same as then() with a destination that returns the value unchanged
throw new Error('TODO');
}
}
from typing import Any, Callable
class Pipeline:
def __init__(self) -> None:
self.passable: Any = None
self.pipes: list[Callable[[Any, Callable[[Any], Any]], Any]] = []
def send(self, passable: Any) -> "Pipeline":
# TODO: store the passable and return self
raise NotImplementedError
def through(self, pipes: list) -> "Pipeline":
# TODO: store the list of pipes and return self
raise NotImplementedError
def then(self, destination: Callable[[Any], Any]) -> Any:
# TODO: run the passable through every pipe in order.
# Each pipe is called as pipe(passable, next); next(value) continues the chain.
# After the last pipe, call destination(value) and return what it returns.
raise NotImplementedError
def then_return(self) -> Any:
# TODO: same as then() with a destination that returns the value unchanged
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýDựng chuỗi từ cuối lên: bắt đầu bằng destination, bọc nó bằng pipe cuối, rồi pipe kế cuối, cứ vậy.
reduceRight của JavaScript và reduce trên reversed(pipes) của Python rất hợp để dựng củ hành.
Mỗi lớp bọc là một hàm một tham số: value => pipe(value, lớp bọc kế tiếp).
class Pipeline {
constructor() {
this.passable = undefined;
this.pipes = [];
}
send(passable) {
this.passable = passable;
return this;
}
through(pipes) {
this.pipes = [...pipes];
return this;
}
then(destination) {
const run = this.pipes.reduceRight(
(next, pipe) => (value) => pipe(value, next),
(value) => destination(value),
);
return run(this.passable);
}
thenReturn() {
return this.then((value) => value);
}
}
from functools import reduce
from typing import Any, Callable
class Pipeline:
def __init__(self) -> None:
self.passable: Any = None
self.pipes: list[Callable[[Any, Callable[[Any], Any]], Any]] = []
def send(self, passable: Any) -> "Pipeline":
self.passable = passable
return self
def through(self, pipes: list) -> "Pipeline":
self.pipes = list(pipes)
return self
def then(self, destination: Callable[[Any], Any]) -> Any:
def wrap(next_fn, pipe):
return lambda value: pipe(value, next_fn)
run = reduce(wrap, reversed(self.pipes), destination)
return run(self.passable)
def then_return(self) -> Any:
return self.then(lambda value: value)

