0

FxMap: Đừng viết foreach đi gọi service khác nữa, bạn xứng đáng có cuộc sống tốt đẹp hơn

Bài viết dành cho những ai từng nhìn vào một cái response chỉ toàn UserId, ProvinceId, ProductId và thở dài: "Lại phải đi gom tên về nữa à..."

Mở đầu: câu chuyện của một cái OrderResponse

Hôm qua sếp giao task: "Em ơi, làm cái màn hình danh sách đơn hàng. Hiện tên khách, tên tỉnh, tên sản phẩm nhé. Dễ mà."

Chữ "dễ mà" luôn là dấu hiệu đầu tiên của một tuần đi tăng ca.

Bạn mở code lên. Bạn có một hệ thống microservice (vì chúng ta là dân chuyên nghiệp, và vì CV nó đẹp). Service Order chỉ biết:

public class OrderResponse
{
    public string Id { get; set; }
    public string UserId { get; set; }       // service User giữ
    public string ProvinceId { get; set; }   // service Location giữ
    public string ProductId { get; set; }    // service Catalog giữ
}

Còn tên khách, email, tên tỉnh, tên sản phẩm thì... ở nhà người khác hết. Thế là bạn viết:

var orders = await _db.Orders.ToListAsync();

var userIds = orders.Select(o => o.UserId).Distinct().ToList();
var users = await _userClient.GetByIdsAsync(userIds);          // gọi 1
var userMap = users.ToDictionary(u => u.Id);

var provinceIds = orders.Select(o => o.ProvinceId).Distinct().ToList();
var provinces = await _locationClient.GetByIdsAsync(provinceIds); // gọi 2
var provinceMap = provinces.ToDictionary(p => p.Id);

// ... lặp lại cho Product, Category, Country, Shipper, Voucher, ...

foreach (var o in orders)
{
    o.UserName = userMap.TryGetValue(o.UserId, out var u) ? u.Name : null;
    o.UserEmail = userMap.TryGetValue(o.UserId, out var u2) ? u2.Email : null;
    o.ProvinceName = provinceMap.TryGetValue(o.ProvinceId, out var p) ? p.Name : null;
    // ... 40 dòng nữa, mỗi dòng là một cơ hội để copy-paste sai tên biến
}

Rồi endpoint tiếp theo cần y chang. Rồi endpoint tiếp theo nữa. Rồi một bạn mới vào team quên Distinct() và gọi service User 10.000 lần cho 10.000 đơn hàng của cùng một ông khách VIP. Ông khách VIP đó, chính là sếp bạn.

Nếu bạn thấy quen quen, FxMap sinh ra để dành cho bạn.

FxMap là gì (giải thích kiểu người thường)

FxMap là một thư viện .NET mã nguồn mở (Apache-2.0) cho bài toán distributed data mapping: bạn khai báo "property này lấy từ đâu", FxMap lo phần còn lại: gom id, gộp request, gửi đi qua transport bạn đang dùng, nhận về, nhét vào đúng chỗ.

Nếu bạn biết AutoMapper thì có thể hiểu nôm na thế này:

AutoMapper FxMap
Map từ đâu Object này sang object kia, cùng process Object của bạn ← dữ liệu của service khác
Nguồn dữ liệu Bộ nhớ / DB của bạn Service khác, qua NATS / gRPC / RabbitMQ / Kafka...
Nỗi đau giải quyết Viết dest.X = src.X Viết vòng lặp đi gọi service rồi Dictionary rồi TryGetValue rồi khóc

Điểm hay là hai service không cần biết nhau. Chúng chỉ thống nhất với nhau một cái tên (gọi là distributed key). Giống như hai người không có số của nhau nhưng cùng biết "quán cà phê đầu hẻm": ai cần gì cứ ra đó hỏi.

Ví dụ đầu tiên: 10 dòng thay 100 dòng

Quay lại OrderResponse. Với FxMap, bạn thêm các property cần hiển thị và khai báo một cái profile:

public class OrderResponse
{
    public string UserId { get; set; }
    public string UserName { get; set; }     // FxMap điền
    public string UserEmail { get; set; }    // FxMap điền
}

public class OrderResponseProfile : ProfileOf<OrderResponse>
{
    protected override void Configure() =>
        UseDistributedKey<UserDistributedKey>()
            .Of(x => x.UserId)                 // "chìa khóa" là UserId
            .For(x => x.UserName)              // lấy thuộc tính mặc định (Name)
            .For(x => x.UserEmail, "Email");   // lấy thuộc tính Email
}

Và chỗ trả response:

await distributedMapper.MapDataAsync(response);

Hết. Thật đó. Không có foreach, không có Dictionary, không có TryGetValue xếp hàng như đội duyệt binh.

Dù response là một object hay một list 10.000 phần tử, FxMap vẫn chỉ gửi một request cho mỗi distributed key (với danh sách id đã Distinct). Ông khách VIP của bạn chỉ bị hỏi thăm đúng một lần.

Phía bên kia: service sở hữu dữ liệu

Service User (nơi có bảng Users thật) cũng chỉ cần khai báo "tôi cho phép người khác lấy gì":

public sealed class UserDistributedKey : IDistributedKey;

public class UserConfig : EntityConfigureOf<User>
{
    protected override void Configure()
    {
        Id(x => x.Id);
        DefaultProperty(x => x.Name);
        UseDistributedKey<UserDistributedKey>();
        ExposedName(x => x.Email, "UserEmail");   // đổi tên khi "xuất khẩu"
    }
}

Rồi cắm một data provider (hiện có EF Core và MongoDB) và một transport. FxMap tự dựng handler trả lời request, tự build câu query từ expression, không phải viết controller "GET /users/by-ids" nữa. Bạn đã bao giờ viết endpoint đó chưa? Bạn đã viết 14 lần rồi, đúng không?

Vài chi tiết nhỏ mà "đắt": từ bản 4.0.0, Id(...) và DefaultProperty(...) nhận hẳn lambda selector, được validate ngay lúc cấu hình. Quên khai báo Id thì app báo lỗi lúc khởi động chứ không đợi đến 2 giờ sáng ở production mới nổ. Id còn có thể là biểu thức tính toán kiểu x => x.Code + x.Site.

Điều làm FxMap khác một cái "HTTP client bọc ngoài": ngôn ngữ biểu thức

Nếu chỉ lấy Name và Email thì chưa có gì để khoe. Nhưng thường thì sếp sẽ hỏi: "Hiện luôn tên quốc gia của khách, tổng tiền khách đã chi, và số đơn đã hoàn thành nhé." FxMap có hẳn một DSL giống SQL để bạn viết ngay trong profile:

public class UserResponseProfile : ProfileOf<UserResponse>
{
    protected override void Configure()
    {
        UseDistributedKey<UserDistributedKey>()
            .Of(x => x.UserId)
            // Truy cập thuộc tính đơn giản
            .For(x => x.UserEmail, "Email")
            // Đi qua navigation
            .For(x => x.CountryName, "Country.Name")
            // Lọc
            .For(x => x.CompletedOrders, "Orders(Status = 'Done')")
            // Aggregation
            .For(x => x.TotalSpent, "Orders:sum(Total)")
            // Projection
            .For(x => x.UserDetails, "{Id, Name, Address.City as CityName}")
            // GroupBy
            .For(x => x.OrdersByStatus, "Orders:groupBy(Status).{Status, :count as Count}");
    }
}

Chuỗi này không được gửi đi rồi Eval bừa bãi. Service sở hữu dữ liệu parse nó thành LINQ expression tree và để EF Core / MongoDB dịch thành query thật. Nghĩa là Orders:sum(Total) chạy ở DB, không phải kéo 50.000 đơn hàng về rồi cộng bằng tay. Ví dụ, thay vì:

var total = user.Orders.Sum(o => o.Total);   // kéo hết về RAM, cầu nguyện

bạn có một câu SUM đàng hoàng. Server của bạn sẽ gửi lời cảm ơn qua... mức CPU thấp.

Bonus: có Roslyn analyzer (FxMap.Analyzers) kiểm tra chuỗi expression ngay lúc compile. Gõ sai Orders:summ(Total) là IDE gạch đỏ liền, thay vì để dành bất ngờ cho giờ chạy.

Chuỗi phụ thuộc: UserId → ProvinceId → CountryId

Tình huống kinh điển: Order có UserId. User có ProvinceId. Province có CountryId. Muốn tên quốc gia của khách, bạn phải gọi 3 service nối đuôi nhau như tàu hỏa.

Viết tay thì đây là cuộc đua "ai lồng được nhiều callback nhất". Với FxMap, bạn khai báo từng mắt xích trong profile, và nó tự giải theo từng tầng:

  • Tầng 1: gom tất cả UserId → hỏi service User một phát, lấy về ProvinceId.
  • Tầng 2: gom tất cả ProvinceId vừa có → hỏi một phát, lấy về CountryId.
  • Tầng 3: tương tự.

Số request chỉ phụ thuộc vào số tầng × số key, không phụ thuộc số object. Từ 10 đơn hay 10.000 đơn thì số lần gọi mạng vẫn thế. Đây là cách trị căn bệnh N+1 từ gốc, thay vì uống thuốc giảm đau.

Với object lồng nhau (một Order có list Items, mỗi item có ProductId, Product có CategoryId...), FxMap tự đi sâu vào graph để tìm mọi chỗ cần điền. Đồ thị sâu 2000 tầng cũng đã được test (bản Discover dùng stack tường minh chứ không đệ quy, nên không chết vì StackOverflow).

Transport: bạn đang dùng gì thì cắm đó

Đã có sẵn:

  • gRPC
  • NATS
  • RabbitMQ
  • Apache Kafka
  • Azure Service Bus
  • Amazon SQS

Kèm retry và supervision. Muốn đổi từ NATS sang gRPC? Đổi package và dòng đăng ký. Profile và entity config không đổi một chữ. Giống như đổi hãng xe ôm mà địa chỉ nhà vẫn thế.

Trường hợp service sở hữu dữ liệu chính là service đang gọi (ví dụ monolith, hoặc chạy test), FxMap trả lời cục bộ luôn, không ra khỏi process. Nên bạn có thể bắt đầu bằng modular monolith rồi tách microservice sau mà không phải viết lại phần mapping. Kiến trúc sư của bạn gọi đây là "evolutionary architecture", còn bạn gọi là "ngủ đủ giấc".

Dùng với GraphQL (HotChocolate)

Nếu API của bạn là GraphQL, gói FxMap.HotChocolate điền các response type thông qua HotChocolate. Resolver không phải tự gọi qua lại. Với GraphQL, bài toán N+1 vốn đã đủ ám ảnh rồi, bớt đi một nguồn cũng là phúc đức.

Còn hiệu năng thì sao? (phần dành cho người hay hoài nghi, tức là tất cả chúng ta)

Câu hỏi hợp lý: "Thư viện làm nhiều việc thế thì chậm không?"

Phần lớn thời gian của một lần gọi thật là chờ mạng, nên mình đo riêng phần mapper bằng cách bỏ mạng ra: FxMap làm giàu DTO từ EF Core InMemory trong cùng process (không transport), so với AutoMapper 14 ProjectTo (một query có join).

Kịch bản: mỗi đơn có khách (customer → province → country) và 3-5 item (item → product → category). Hai bên cho ra cùng một đồ thị OrderDto (benchmark có kiểm tra kết quả giống nhau).

Số đơn AutoMapper ProjectTo FxMap (query + enrich) Thời gian FxMap/AutoMapper Bộ nhớ FxMap/AutoMapper
10 164 µs / 276 KB 277 µs / 382 KB 1.69× 1.38×
100 2.07 ms / 2.2 MB 1.81 ms / 1.1 MB 0.88× 0.50×
1,000 96.4 ms / 21.4 MB 93.6 ms / 7.0 MB 0.97× 0.33×

(BenchmarkDotNet 0.15, 3 launches, .NET 10, Apple M1 Pro. Càng thấp càng tốt.)

Đọc thế nào cho trung thực:

  • 10 đơn: FxMap chậm hơn (1.69×). Cả hai đều dưới một phần nghìn giây, chi phí cố định của việc "mỗi key mỗi tầng một query" lộ ra. Không ai nhận ra, kể cả sếp.
  • 100 đơn trở lên: FxMap ngang hoặc nhanh hơn, và dùng chỉ một nửa đến một phần ba bộ nhớ, vì nó lấy mỗi key phân biệt đúng một lần thay vì join lặp trên mọi dòng.
  • Đây là so sánh cục bộ, in-memory. Với DB và mạng thật, chi phí round-trip mới là thứ quyết định. Và đó chính là chỗ batching theo key của FxMap tỏa sáng. Mấy con số trên không nói gì về độ trễ mạng, mình nói thẳng để khỏi bị ai nhắc lại trong comment.

Muốn tự kiểm chứng:

cd FxMap/test/FxMap.Benchmark
dotnet run -c Release -- --filter '*ProjectionBenchmark*' --launchCount 3

Cài đặt trong 5 phút

dotnet add package FxMap
dotnet add package FxMap.EntityFrameworkCore   # provider (hoặc FxMap.MongoDb)
dotnet add package FxMap.Nats                  # transport (hoặc Grpc, RabbitMq, Kafka...)
dotnet add package FxMap.Analyzers             # tùy chọn, bắt lỗi lúc compile

⚠️ Mọi package FxMap.* phải cùng một version. Đừng thử mix FxMap 4.0.0 với FxMap.Nats 3.x rồi đổ lỗi cho thư viện nhé, mình biết bạn định làm thế.

Đăng ký:

builder.Services.AddFxMap(cfg =>
{
    cfg.AddEntitiesFromAssemblyContaining<SomeEntityAssemblyMarker>();
    cfg.AddProfilesFromAssemblyContaining<SomeProfileAssemblyMarker>();
});

Tới đó là chạy. Thư viện hỗ trợ .NET 8, 9 và 10.

Một kịch bản đầy đủ, từ đầu đến cuối

Giả sử có 2 service:

Service Order (cần hiển thị tên khách):

public class OrderResponse
{
    public string Id { get; set; }
    public string UserId { get; set; }
    public string UserName { get; set; }
    public string UserEmail { get; set; }
}

public class OrderResponseProfile : ProfileOf<OrderResponse>
{
    protected override void Configure() =>
        UseDistributedKey<UserDistributedKey>()
            .Of(x => x.UserId)
            .For(x => x.UserName)
            .For(x => x.UserEmail, "Email");
}

app.MapGet("/orders", async (AppDbContext db, IDistributedMapper mapper, CancellationToken ct) =>
{
    var orders = await db.Orders
        .Select(o => new OrderResponse { Id = o.Id, UserId = o.UserId })
        .ToListAsync(ct);

    await mapper.MapDataAsync(orders, ct);   // một request tới service User, hết chuyện
    return orders;
});

Service User (chủ sở hữu dữ liệu): chỉ cần UserConfig : EntityConfigureOf<User> như ở trên, một provider EF Core, và đăng ký cùng transport. Không viết controller, không viết DTO "để service khác gọi".

Quan sát, debug, và "nó chạy sao mà ra thế này?"

FxMap phát Activity cho OpenTelemetry, ở cả mapping lẫn data access. Cắm Jaeger vào là thấy luôn: một lần map gọi những key nào, mấy tầng, mất bao lâu. Repo có sẵn thư mục sample/ với docker-compose gồm NATS, PostgreSQL, MongoDB, Jaeger, Prometheus, Grafana. Chạy một lệnh là có cả sân khấu để thử.

Khi nào nên dùng, khi nào đừng

Nên dùng khi:

  • Bạn có microservice và response toàn khóa ngoại cần "dịch" ra tên/thông tin.
  • Bạn mệt với việc viết endpoint GetByIds cho mọi entity.
  • Bạn muốn một chỗ tập trung để thấy "property này đến từ đâu".
  • Bạn dùng GraphQL và muốn bớt resolver gọi qua gọi lại.

Đừng dùng khi:

  • Dữ liệu nằm cùng một DB, một JOIN là xong. Đừng mang xe tải đi mua gói xôi.
  • Bạn cần transaction xuyên service. FxMap là công cụ đọc/làm giàu dữ liệu, không phải saga.
  • Bạn muốn mapper đổi object-sang-object thuần túy. Đó là việc của AutoMapper / Mapperly, và hai thằng này hoàn toàn có thể sống chung.

Lộ trình

Phiên bản hiện tại là 4.0.0. Mình đang thử nghiệm tính năng Collection cho các khóa không duy nhất (ví dụ một mã bệnh nhân có nhiều lượt khám, lấy N dòng đầu, có order/limit) trên nhánh riêng dev-feature-collection; chưa phát hành nên mình chưa hứa gì ở đây. Ngoài ra còn nhiều ý tưởng về tối ưu mapper đang được thử và đo đạc (đo trước, khoe sau).

Lời kết

Viết code gom dữ liệu giữa các service giống như rửa chén: không ai ghét nó vì khó, người ta ghét vì lặp đi lặp lại và không ai khen. FxMap không biến việc đó thành thú vị, nhưng nó biến nó thành một khai báo thay vì một đống vòng lặp.

Nếu bạn thấy hay, ghé xem và cho một cái ⭐ cũng được, còn chê thì mở issue cũng được, mình đọc cả hai (tất nhiên cái ⭐ đọc nhanh hơn).

Cảm ơn bạn đã đọc đến đây. Và nếu hôm nay bạn vừa viết xong một vòng foreach gọi service, mình không phán xét. Mình chỉ... để link ở trên thôi. 🙂

Tái bút: nếu sếp bạn là ông khách VIP bị gọi 10.000 lần, hãy cài FxMap trước khi ổng đọc log.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí