Bỏ qua tới nội dung
Kiên

Những interface mình không phá

· 5 phút đọc

Một danh sách những thứ là contract mà nhìn không giống contract — một định danh client cầm giữa hai lần gọi, một mã lỗi đã ghi thành tài liệu, và ba nghĩa khác nhau của thiếu field, field rỗng và field null. Vì sao thêm thì gần như miễn phí còn bỏ đi là một đợt release phối hợp qua những repo mình không deploy, và giữ cho mọi thứ chạy được thì tốn thật những gì.

  • API design
  • tương thích ngược
  • multi-repo
  • release
Mục lục
  1. Một định danh client cầm giữa hai lần gọi
  2. Mã lỗi đã ghi vào tài liệu là những nhánh rẽ
  3. Thiếu, rỗng và null là ba câu trả lời khác nhau
  4. Thêm thì gần như miễn phí; bỏ đi là một đợt release
  5. Khi buộc phải phá một thứ
  6. Thứ tự merge là thứ duy nhất giữ cho không sập
  7. Cái giá của nó

Mình có một danh sách những thứ sẽ không đổi nếu chưa có kế hoạch, và không mục nào trong đó được dán nhãn "public API". Chúng là những phần của hệ thống trở thành interface mà chẳng ai tuyên bố gì cả, vì có một consumer nào đó đã bắt đầu dựa vào một thứ mà nhìn từ bên trong ra thì tưởng là chuyện nội bộ.

Một định danh client cầm giữa hai lần gọi

Ví dụ rõ nhất ở nền tảng mình đang làm là cái offer ID tổ hợp: source, contract, hotel, uuid, gói chung lại và decode ở bước prebook, nên không cần session phía server. Nó tới tay client dưới dạng một chuỗi trông mờ đục, và đúng cái vẻ mờ đục đó mời gọi người ta coi layout của nó là thứ mình muốn đổi lúc nào cũng được.

Không phải vậy. Client cầm nó giữa search và prebook, qua các lần retry của họ, đôi khi qua cả một lần deploy của mình ngay giữa lúc khách checkout. Đổi layout lặng lẽ là mọi định danh sinh ra trước lần deploy đó hoặc decode không được, hoặc tệ hơn, decode ra một thứ nghe hợp lý mà sai. Chữ có version trong mô tả kia chính là toàn bộ phần phòng thủ: cái ID tự nói nó thuộc layout nào, nên một layout mới sống song song được với layout cũ thay vì thay chỗ nó vào một thời điểm không ai chọn.

Luật này khái quát ra được. Bất cứ thứ gì client mang từ lần gọi này sang lần gọi sau đều là một contract công khai, dù nó trông như thế nào — một cursor, một token, một "reference" mình tự nghĩ ra cho tiện. Mờ đục không phải là kín đáo; nó chỉ nghĩa là lúc mình làm hỏng nó thì không ai nhìn ra vì sao.

Mã lỗi đã ghi vào tài liệu là những nhánh rẽ

Cái engine duyệt nhiều cấp mình sở hữu ở VNTrip có 11 mã lỗi đã ghi thành tài liệu. Việc ghi ra là việc có ích; hệ quả là chúng thành một interface. Đâu đó có một màn hình hiện một thông báo khác nhau theo từng mã, chỗ khác lại có một caller coi một mã trong đó là lỗi tạm thời rồi retry.

Đổi tên một mã là mình vừa đổi hành vi một cách âm thầm trong một repo mình không deploy. Mã cũ biến mất, một cái switch rơi xuống nhánh default, và người dùng nhận một thông báo chung chung ở đúng chỗ trước đây họ nhận được câu nói cho họ biết phải làm gì. Không có exception nào. Không có test nào của mình đỏ.

Tái sử dụng mã còn tệ hơn đổi tên: vẫn mã đó, nghĩa mới, consumer vẫn rẽ nhánh theo nghĩa cũ, và giờ hệ thống sai một cách rất tự tin ở đúng chỗ không báo lỗi gì cả. Mã lỗi thì được thêm vào. Không đổi tên, và không bao giờ dùng lại.

Thiếu, rỗng và null là ba câu trả lời khác nhau

Consumer đã code theo cái khác biệt đó rồi, kể cả khi họ không cố ý. Chỗ này kiểm tra bằng if (field), tức là gộp thiếu, rỗng và null vào chung một nhánh; chỗ kia kiểm tra key có tồn tại hay không, tức là không gộp. Cả hai giờ đều là hành vi mình đã hứa.

Quyết định phá vỡ chuyện này không bao giờ được công bố như một thay đổi contract. Nó tới dưới dạng một setting của serializer: từ nay bỏ null ra khỏi output, hoặc từ nay trả chuỗi rỗng ở chỗ trước đây bỏ hẳn key. Một dòng config, và ý nghĩa của response đổi với mọi consumer từng phân biệt hai thứ đó.

Có một chỗ tụi mình dựa vào khác biệt này một cách có chủ đích: bảng settings theo từng supplier là bảng thưa, chỉ giữ những key khác với mặc định, nên vắng mặt ở đó nghĩa là "dùng mặc định". Điền đầy bảng đó bằng những giá trị tường minh đúng bằng mặc định thì hôm nay không đổi gì — rồi tới ngày một giá trị mặc định thay đổi, và những row đáng lẽ phải đi theo nó thì không đi. Sự vắng mặt mang nghĩa từ đầu tới giờ.

Thêm thì gần như miễn phí; bỏ đi là một đợt release

Sự bất đối xứng đó là phần lõi thực dụng của cả bài này. Thêm một field gần như không tốn gì: consumer nào không biết nó thì bỏ qua, ai cần thì tự chọn lúc dùng. Vẫn có ngoại lệ đáng nhớ — một parser chặt chẽ từ chối field lạ, một test assert đúng nguyên hình dạng response — và đó là ngoại lệ.

Bỏ một field, đổi tên nó, hay lặng lẽ đổi nghĩa của nó thì không phải chuyện sửa code. Nó là một cái lịch: tìm hết consumer, thống nhất với người đang giữ chúng, xếp thứ tự merge, rồi ship theo đúng thứ tự đó. Cái diff có khi chỉ một dòng; phần việc thật là phối hợp, và cẩn thận cỡ nào bên trong repo của mình cũng không thay được nó.

Khi buộc phải phá một thứ

Có lúc không còn đường nào thêm-vào-là-xong. Khi đó thay đổi cần hai thứ mà một dòng changelog không cho được: một version, để hai hình dạng cùng tồn tại, và một khoảng chuyển tiếp đủ dài cho consumer chậm nhất kịp đi. Cái định danh có version ở trên là đúng pattern đó — cắm trường version vào ngay ngày đầu, lúc nó chưa tốn gì, vì nhét version vào sau chính là cái thay đổi phá vỡ mà mình đang muốn tránh.

Khoảng chuyển tiếp mới là phần hay bị bỏ qua. Không có nó thì "tụi mình đã đánh version rồi" chỉ có nghĩa là "tụi mình phá rồi, và viết chữ v2 lên hộp".

Thứ tự merge là thứ duy nhất giữ cho không sập

Một lần go-live ở đây có thể trải qua 11 repo, và những consumer của contract mình viết nằm ở các repo mình không deploy. Contract phía backend merge trước những consumer đọc nó, và cái thứ tự đó được thiết kế từ đầu như một phần của thay đổi, chứ không phải nghĩ ra vào sáng ngày release. Có một cái checker kiểm xem tính năng đã vào master ở đủ mọi repo nó chạm tới chưa, vì kiểu hỏng đặc trưng của một đợt release nhiều repo không phải là merge sai, mà là merge thiếu.

Chính mấy luật tương thích ở trên mới làm cho thứ tự merge đủ. Nếu mọi thay đổi đều là thêm vào, xếp sai thứ tự chỉ tốn một khoảng chờ: consumer lên trước, thấy một field chưa có, rồi đợi. Nếu thay đổi mang tính phá vỡ, xếp sai thứ tự là sập.

Cái giá của nó

Tính tương thích thì tích tụ lại, và hoá đơn là có thật. Có những field không ai set, những mã lỗi không còn chỗ nào ném ra, một bộ decode cho layout định danh do một phiên bản mình không còn chạy sinh ra. Từng cái một thì rẻ; gộp lại thì thành một hình dạng hôm nay mình sẽ không chọn, và việc mới thì đi vòng qua field cũ thay vì thay thế nó.

Mình không biết trong response công khai của tụi mình còn bao nhiêu field không còn consumer nào đọc. Muốn biết thì phải đo lượt đọc ở phía client, việc đó chưa ai yêu cầu, nên nó vẫn là một nghi ngờ chứ không phải một con số.

Mình vẫn trả cái hoá đơn đó, vì một lý do: chi phí của việc giữ tương thích thì đoán trước được và là của mình, còn chi phí của việc phá thì không đoán được, là của người khác, và tới vào một thời điểm mình không chọn. Hai thói quen làm nó chịu được — đánh version cho những thứ chắc chắn sẽ đổi, ngay hôm chúng ra đời, và giữ cái danh sách "thứ gì được tính là contract" ở dạng viết ra chứ không ở trong đầu mình.

Bài liên quan

· 4 phút đọc

Tám gateway service, một nhánh tích hợp chung, và một cú đẩy lên production luôn là hành động có chủ đích, gom theo đợt, với một con người đứng ở nút bấm. "Đơn vị release là cả cụm, không phải từng repo" trông như thế nào khi nó phải chạy hàng tuần, thay vì nằm yên trong danh sách nguyên tắc.

  • release engineering
  • multi-repo
  • quy trình
  • CI/CD

· 5 phút đọc

"Tức là gọi API của người khác hả" — câu tóm tắt mình nghe nhiều nhất về công việc của mình, và nó sai theo một cách đáng để mổ xẻ. Khoảng 150 supplier code trên chừng 90 tích hợp, hai pattern offer được ghi thành tài liệu, hai mô hình thực thi sau cùng một contract, và những supplier trả 200 kèm lỗi bên trong — một vòng quanh việc này thật sự gồm những gì, và vì sao nó là kiến trúc.

  • tích hợp
  • kiến trúc
  • API design
  • hệ phân tán