Hình dung việc gửi thông điệp giữa hai dịch vụ như gửi thư cho một người. Bạn có thể ghi chức danh — "gửi Trưởng phòng Kế toán" — thì thư vẫn tới đúng người dù họ chuyển nhà hay có người mới nhận chức. Hoặc bạn ghi thẳng địa chỉ nhà riêng, và lá thư gãy ngay khi họ dọn đi. Phần trước đo được rằng convertAndSend mặc định gửi Java serialization: 82 byte cho một record đáng lẽ 29 byte JSON, kèm tên class nằm trong thân. Bài này đổi sang JSON và đo xem việc đó gỡ được bao nhiêu phần của sự ràng buộc — và chỗ nó không gỡ được.

Jackson không có sẵn

Chi tiết đầu tiên, và nó làm tôi mất mấy phút: spring-boot-starter-amqp không kéo Jackson theo. Đếm số jar Jackson trong classpath trước khi khai thêm: 0. Thiếu nó thì new Jackson2JsonMessageConverter() ném NoClassDefFoundError lúc chạy, không phải lúc biên dịch.

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
</dependency>

JSON gửi ra gì

@Bean
MessageConverter jsonConverter() { return new Jackson2JsonMessageConverter(); }
contentType : application/json
độ dài thân : 29 byte   (Java serialization ở phần 24: 82 byte)
thân        : {"ma":"DH-001","tien":250000}
headers     :
  __TypeId__ = lab.donhang.DonHang

Thân gọn hơn 2,8 lần và đọc được bằng mắt. Nhưng nhìn dòng cuối.

__TypeId__ là sợi dây vẫn còn nguyên

Jackson converter ghi tên class đầy đủ vào header để bên nhận biết dựng lại thành gì. Nghe hợp lý, cho tới khi bên nhận là một dịch vụ khác. Gửi một thông điệp với __TypeId__ trỏ tới class mà bên nhận không có:

LỖI: ClassNotFoundException
     com.doitac.donhang.DonHang
Đổi từ Java serialization sang JSON không tự gỡ được sự ràng buộc — nó chỉ chuyển chỗ. Thân thông điệp giờ ai cũng đọc được, nhưng header vẫn đòi bên nhận có đúng class đó, đúng package. Đổi tên package ở bên gửi là bên nhận hỏng, y như cũ.

Cách gỡ: đặt tên logic

DefaultJackson2JavaTypeMapper cho phép ánh xạ một tên do bạn đặt sang class ở mỗi phía:

// bên gửi
var mapper = new DefaultJackson2JavaTypeMapper();
mapper.setIdClassMapping(Map.of("don-hang", lab.donhang.DonHang.class));

// bên nhận — class khác package hoàn toàn
var mapper = new DefaultJackson2JavaTypeMapper();
mapper.setIdClassMapping(Map.of("don-hang", lab.ketoan.DonHang.class));
__TypeId__ gửi đi : don-hang
bên nhận đọc được : DonHang[ma=DH-003, tien=7]  (kiểu lab.ketoan.DonHang)

Giờ trên dây chỉ còn một chuỗi don-hang — một hợp đồng, không phải một đường dẫn class. Hai dịch vụ đổi tên package thoải mái, miễn giữ nguyên tên logic đó. Đây đúng là "gửi theo chức danh" thay vì "gửi theo địa chỉ nhà".

Mapper tự khai phải khai luôn setTrustedPackages

Cái bẫy đi kèm: DefaultJackson2JavaTypeMapper vừa tạo ra chỉ tin java.util và java.lang.

mapper mới tạo, đọc một kiểu ngoài java.*:
  LỖI: The class 'lab.donhang.DonHang' is not in the trusted packages: [java.util, java.lang]

sau khi setTrustedPackages("lab.donhang"): đọc được lab.donhang.DonHang

Converter mặc định (không đặt mapper riêng) thì tin tất cả — nên mọi thứ chạy cho tới lúc bạn động vào mapper để làm đúng, và lúc đó nó vỡ. Đây là bảo vệ chống deserialize class tuỳ ý, nên đừng đặt "*" cho xong; hãy liệt kê đúng package của bạn.

Java serialization nhanh hơn, JSON nhẹ hơn

20 000 thông điệp, cùng dữ liệu, chỉ đổi converter:

Converter Thông lượng Kích thước thân
SimpleMessageConverter (Java) 234 982 msg/s 82 byte
Jackson2JsonMessageConverter 190 909 msg/s 29 byte

JSON chậm hơn 19% trên đường gửi vì phải tuần tự hoá sang văn bản, nhưng thân nhẹ hơn 2,8 lần. Với thông điệp nhỏ và mạng nội bộ, chênh lệch CPU đó không đáng gì so với việc bên nhận không cần là Java. Với luồng cực lớn thì hãy đo lại bằng dữ liệu thật của bạn — và cân nhắc định dạng nhị phân thay vì quay về Java serialization.

Nên đặt gì

@Bean
MessageConverter jsonConverter() {
    var mapper = new DefaultJackson2JavaTypeMapper();
    mapper.setTrustedPackages("com.congty.hopdong");
    mapper.setIdClassMapping(Map.of(
            "don-hang.tao", DonHangTao.class,
            "don-hang.huy", DonHangHuy.class));
    var conv = new Jackson2JsonMessageConverter();
    conv.setJavaTypeMapper(mapper);
    return conv;
}

Tên logic ở bên trái là hợp đồng giữa các dịch vụ — đặt nó cùng chỗ và cùng cẩn thận như bạn đặt routing key ở phần 10.

Muốn biết ngay hợp đồng của mình đang là chức danh hay địa chỉ nhà thì soi một thông điệp:

curl -su guest:guest -X POST 'localhost:15672/api/queues/%2F/<ten>/get' \
  -H 'Content-Type: application/json' \
  -d '{"count":1,"ackmode":"ack_requeue_true","encoding":"auto"}' \
  | jq '{content_type: .[0].properties.content_type,
         typeId: .[0].properties.headers.__TypeId__}'

__TypeId__ ra một tên class đầy đủ nghĩa là hợp đồng giữa các dịch vụ của bạn đang là đường dẫn package — thứ sẽ gãy trong lần tái cấu trúc tiếp theo.

Mẫu số chung

Cái đi trên dây giữa hai bên độc lập phải là một tên logic hai bên cùng thoả thuận, không phải chi tiết cài đặt nội bộ của một bên. __TypeId__ = lab.donhang.DonHang là để lọt một đường-dẫn-class riêng tư lên giao thức chung, và thế là hai dịch vụ bị buộc chặt: bên gửi đổi tên package thì bên nhận gãy. Lời giải — một tên logic (don-hang) ánh xạ ra class riêng ở mỗi phía — chính là lược đồ ở ranh giới tách khỏi kiểu bên trong, cùng một ý với số-hiệu-trường của Protobuf, schema của Avro, chuỗi event-type làm khoá phân loại, hay tách DTO khỏi domain model. Và cái bẫy sâu hơn: đổi định dạng (Java→JSON) không gỡ ràng buộc — nó chỉ dời chỗ; thân giờ đọc được nhưng ràng buộc trốn vào metadata (cái header) và nằm im cho tới khi ai đó tái cấu trúc mới lộ. Muốn biết mình có bị trói không, hỏi: đổi tên/dời package một bên, bên kia có gãy không?

Điều thứ hai: dựng lại một kiểu tuỳ ý do bên gửi đặt tên là một lỗ hổng thực thi mã, nên phải allow-list, đừng bao giờ "*". setTrustedPackages chặn đúng chuyện đó, và nó thuộc họ lỗ hổng deserialize-dữ-liệu-không-tin-được kinh điển: readObject của Java, các CVE polymorphic typing của Jackson, pickle của Python, yaml.load không an toàn. Đáng chú ý là cách nó ẩn: converter mặc định tin tất cả, nên mọi thứ chạy trơn cho tới khi bạn cầm dây cương và nó gãy — hoặc tệ hơn, nó cứ mở toang mà chẳng ai thấy. Mặc-định-rộng-rãi luôn dễ chịu lúc đầu và nguy hiểm về sau; danh sách cho phép hẹp, khai tường minh, là cái giá rẻ mua lấy sự yên tâm.

Bài sau: khai báo topology bằng bean Spring, và vì sao ứng dụng vẫn khởi động thành công khi broker đã tắt.