Event bus của Vert.x nhận JsonObject và trả JsonObject. Nó linh hoạt, và nó không có kiểu: gõ sai tên trường thì không có gì báo cho tới lúc chạy. Service Proxy là câu trả lời của Vert.x — bạn khai một interface Java, bộ sinh mã dựng ra hai lớp, và bên gọi làm việc với interface đó như gọi một hàm bình thường.

Câu hỏi tôi muốn trả lời: lớp trừu tượng ấy tốn bao nhiêu?

Khai interface

@ProxyGen
@VertxGen
public interface KhoService {
    static KhoService createProxy(Vertx vertx, String diaChi){
        return new KhoServiceVertxEBProxy(vertx, diaChi);
    }
    void tim(String ma, Handler<AsyncResult<JsonObject>> ketQua);
    void dem(Handler<AsyncResult<Integer>> ketQua);
    void them(String ma, int soLuong, Handler<AsyncResult<Void>> ketQua);
}

Bên cung cấp đăng ký, bên gọi tạo proxy:

new ServiceBinder(vertx).setAddress("kho.service").register(KhoService.class, new KhoServiceImpl());
KhoService proxy = KhoService.createProxy(vertx, "kho.service");
proxy.tim("sp-7", ar -> ...);           // trong nhu goi ham thuong

15 dòng interface sinh ra 267 dòng trong hai lớp: KhoServiceVertxEBProxy (123 dòng, bên gọi) và KhoServiceVertxProxyHandler (144 dòng, bên nhận). Mã sinh ra không có gì bí ẩn — nó đóng gói tham số vào JsonObject rồi gửi qua event bus:

public void tim(String ma, Handler<AsyncResult<JsonObject>> ketQua){
    JsonObject _json = new JsonObject();
    _json.put("ma", ma);
    DeliveryOptions _deliveryOptions = new DeliveryOptions();
    _deliveryOptions.addHeader("action", "tim");
    _vertx.eventBus().<JsonObject>request(_address, _json, _deliveryOptions, res -> { ... });
}

Nói cách khác: proxy không phải một cơ chế truyền tin mới. Nó là đúng đoạn mã bạn sẽ tự viết, chỉ khác là bạn không phải viết.

Phép đo đầu tiên của tôi sai

Chạy lần đầu, tôi được kết quả này:

goi ham Java thang          0,13 µs/lan
event bus TAY (JsonObject)  2,22 µs/lan
service PROXY               0,78 µs/lan

Proxy nhanh hơn bản viết tay 2,8 lần. Điều đó vô lý — mã sinh ra làm đúng những việc bản viết tay làm, có khi còn nhiều hơn một dòng getHeaders().set(...).

Giả thuyết đầu tiên của tôi là cấp phát: bản viết tay tạo một DeliveryOptions mới cho mỗi lượt gọi. Tôi thêm hai biến thể — một cái dùng lại một DeliveryOptions, một cái bỏ hẳn header và nhét action vào thân thông điệp:

tay: dung lai DeliveryOptions             0,90 µs/lan
tay: khong header, action trong body      0,97 µs/lan
proxy (do lai de doi chieu)               0,89 µs/lan

Có vẻ khớp với giả thuyết. Nhưng phép thử quyết định là chạy đúng biến thể tạo mới DeliveryOptions một lần nữa, ở vị trí muộn trong chương trình:

tay: TAO MOI DeliveryOptions (do lai)     1,09 µs/lan

Cùng đoạn mã, cùng lượng cấp phát, nhanh gấp đôi. Vậy thủ phạm không phải cấp phát mà là hâm nóng JIT: phép đo event bus TAY là phép đo đầu tiên chạm vào đường event bus trong cả tiến trình, nên nó gánh toàn bộ chi phí nạp lớp và biên dịch cho những phép đo sau. Phần hâm nóng của tôi — một phần mười số vòng — đủ cho closure của chính nó nhưng không đủ cho hạ tầng dùng chung bên dưới.

Chạy lại toàn bộ với hai lượt hâm nóng đầy đủ và lấy trung vị của năm lần đo:

Trung vị Thấp nhất Cao nhất
Gọi hàm Java thẳng 0,05 µs 0,04 0,08
Event bus viết tay 1,06 µs 0,93 1,18
Service proxy 0,99 µs 0,96 1,01
Viết tay, dùng lại DeliveryOptions 0,97 µs 0,93 0,97
Viết tay, action trong thân 1,01 µs 0,96 1,20
Proxy trả Integer 0,87 µs 0,85 0,89
Proxy trả Void 0,87 µs 0,83 0,89

Kết luận thật, sau khi sửa phép đo: service proxy tốn đúng bằng gửi event bus bằng tay. Mọi biến thể nằm trong dải 0,87–1,06 µs, tức là trong nhiễu giữa các lần chạy. Mã sinh ra không thêm gì đo được.

Cái đắt không phải proxy mà là chính event bus: 1,0 µs so với 0,05 µs của một lời gọi hàm Java, tức khoảng hai mươi lần. Con số này khớp với những gì đã đo ở phần 20 về event bus so với HTTP nội bộ — event bus rẻ hơn HTTP rất nhiều nhưng vẫn đắt hơn một lời gọi hàm rất nhiều.

Bài học về phép đo thì đáng nhớ hơn cả con số: phép đo đầu tiên chạm vào một hạ tầng luôn trả giá cho tất cả các phép đo sau. Nếu tôi chỉ chạy đúng thứ tự đó rồi công bố, tôi đã viết một bài khẳng định bộ sinh mã của Vert.x nhanh hơn mã tự viết — nghe hấp dẫn, và sai.

Cái giá thật nằm ở chỗ khác

Ba thứ tôi vấp phải khi dựng, không cái nào liên quan tới hiệu năng.

Bộ sinh mã trên 4.5.x vẫn đòi chữ ký callback. Interface đầu tiên tôi viết dùng Future<JsonObject> tim(String ma) — đúng phong cách của toàn bộ Vert.x 4.5 — và bị từ chối:

GenException: Proxy methods must have void or Fluent returns

Phải quay về Handler<AsyncResult<T>>, kiểu API mà phần còn lại của Vert.x 4 đã bỏ. Nghĩa là bên trong ứng dụng bạn có hai phong cách bất đồng bộ sống cạnh nhau.

Chỉ một số kiểu được phép. Đưa một POJO thường vào tham số thì hỏng ngay lúc biên dịch:

error: type lab.uy2.SanPham is not legal for use for a parameter in proxy

Đây thật ra là điểm cộng — hỏng lúc build tốt hơn nhiều so với hỏng lúc chạy — nhưng nó nghĩa là mô hình dữ liệu của bạn phải uốn theo JsonObject, kiểu nguyên thuỷ, hoặc lớp có @DataObject.

Cấu hình bộ sinh mã dễ sai. Trên 4.5.11 lớp processor là io.vertx.codegen.CodeGenProcessor — không phải io.vertx.codegen.processor.CodeGenProcessor như trên nhánh 5, và gõ nhầm chỉ nhận được Annotation processor not found. Ngoài ra hãy dùng cờ -s chuẩn của javac để đẩy nguồn sinh ra sang thư mục riêng; để nó rơi lẫn vào thư mục classes thì lần biên dịch sau báo package does not exist một cách khó hiểu.

Khi gọi sai thì sao

Hai kiểu sai, hai hành vi khác nhau:

goi dia chi khong ai lang nghe -> ReplyException: No handlers for address dia-chi-khong-ton-tai
goi ham khong ton tai          -> Invalid action: khong-co-ham-nay

Cả hai đều trả lỗi về cho bên gọi, tốt. Nhưng trường hợp thứ hai còn kèm một thứ nữa trong log của bên cung cấp:

ERROR io.vertx.core.impl.ContextImpl -- Unhandled exception
java.lang.IllegalStateException: Invalid action: khong-co-ham-nay
    at lab.uy.KhoServiceVertxProxyHandler.handle(KhoServiceVertxProxyHandler.java:139)

Mã sinh ra vừa trả lỗi cho bên gọi vừa để ngoại lệ chui ra ngoài. Nếu bạn có cảnh báo dựa trên Unhandled exception, một bên gọi cũ dùng tên hàm đã bị xoá sẽ làm nó kêu — dù mọi thứ vẫn xử lý đúng. Đáng biết trước, vì nó là kiểu ồn khiến người ta tập quen với việc bỏ qua cảnh báo.

Vậy có nên dùng không

Đây là quyết định về việc bảo trì, không phải về tốc độ — số đo đã cho thấy tốc độ như nhau.

Nên dùng khi hai bên của event bus cùng nằm trong một mã nguồn Java và bạn muốn trình biên dịch bắt lỗi đổi tên trường hay đổi kiểu. Đây chính là chỗ event bus dạng JsonObject thô hay gây đau: một bên đổi "ma" thành "maSanPham" và bên kia im lặng nhận null.

Không nên dùng khi bên kia là dịch vụ viết bằng ngôn ngữ khác, hoặc là trình duyệt nối qua SockJS bridge, hoặc khi thông điệp của bạn có cấu trúc động mà interface Java không diễn đạt nổi. Trong những trường hợp đó proxy chỉ thêm một tầng phải bảo trì mà không mua được gì.

Và trong cả hai trường hợp, hãy nhớ con số 20 lần: proxy làm lời gọi trông như một hàm bình thường, nhưng nó vẫn là một chặng qua event bus. Đặt nó trong một vòng lặp mười nghìn lượt thì cái vẻ ngoài "gọi hàm" sẽ khiến bạn không nhận ra vì sao chỗ đó chậm.

Thử ba mươi giây

Đo chi phí một chặng event bus trong chính ứng dụng của bạn, và làm cho đúng:

// ham nong DAY DU truoc, roi moi do — neu khong, phep do dau tien se sai gap doi
for (int r = 0; r < 2; r++) { CountDownLatch l = new CountDownLatch(N);
    for (int i = 0; i < N; i++) goi(l::countDown); l.await(); }

long t0 = System.nanoTime();
CountDownLatch xong = new CountDownLatch(N);
for (int i = 0; i < N; i++) goi(xong::countDown);
xong.await();
System.out.printf("%.2f µs/lan%n", (System.nanoTime()-t0)/(double)N/1000);

Nếu con số của lần chạy đầu khác hẳn lần chạy thứ hai, phần hâm nóng của bạn chưa đủ — và mọi so sánh dựa trên nó đều không dùng được.

Phần sau nối PostgreSQL thật bằng client reactive của Vert.x và đo nó với JDBC ở cùng mức đồng thời.