Minh bạch. Codemagic đã trả tiền để tôi thực hiện đợt di chuyển này và viết về nó. Họ được đọc duyệt một lượt để kiểm tra tính chính xác về mặt sự kiện, chứ không phải về giọng văn, và không có chi tiết nào trong bài bị sửa theo yêu cầu của họ. Chỗ nào tôi gặp vấn đề và đã báo cáo, tôi nói rõ; nếu tới lúc bạn đọc bài này mà vấn đề đó đã được sửa, thì bài cũng ghi là đã sửa.
Hồi tháng Hai, tôi có viết về chuyện tự dựng máy chủ cập nhật cho Expo bằng Django và Tigris. Hệ thống đó vẫn chạy tốt. maru, ứng dụng cộng đồng tôi đang vận hành, từ đó tới nay nhận mọi bản sửa lỗi qua mạng (over the air) thông qua nó, trên cả hai nền tảng, với chi phí khoảng một đô mỗi tháng.
Nhưng điều đó cũng có nghĩa là tôi phải tự gánh từng mảnh của nó: hai mô hình dữ liệu (model) Django kèm một bản chuyển đổi lược đồ (migration), một viewset 430 dòng, một lệnh quản trị (management command) 400 dòng, một script phát hành 210 dòng, một khóa bí mật dùng để nhập dữ liệu, và một loạt cái bẫy mà tôi học được qua từng bản cập nhật hỏng. Khi ngồi xuống lên kế hoạch cho đợt di chuyển này, tôi mới phát hiện là script kia đã in ra dòng “Sent 0 push notifications” sau mỗi lần phát hành lên production, ngay từ ngày tôi viết nó, vì nó đọc một trường mà máy chủ này chưa bao giờ trả về. Chẳng ai để ý, và người ít để ý nhất chính là tôi. Tự vận hành một thứ gì đó thì đại khái là sẽ thành ra như vậy.
Nên khi Codemagic hỏi tôi có muốn thử Patch, phiên bản mã nguồn mở, tự vận hành được (self-hosted) của họ theo mô hình CodePush, tôi đồng ý với một điều kiện: phải là một ứng dụng thật chứ không phải bản demo. Ký mã (signing) thật, quy trình phát hành thật, dự án native đã commit sẵn với những chỗ sửa tay bên trong, đủ cả. Phần lớn những người rời EAS Update đều đang mang theo một ứng dụng như thế, chứ không phải một template mới tinh.
Dưới đây là toàn bộ quá trình, giữ nguyên cả những chỗ còn xù xì.
Patch là gì#
Patch gồm một máy chủ do bạn tự vận hành và một SDK bạn nhúng vào ứng dụng. Phía máy chủ có một API, một tiến trình chạy nền (worker), Postgres, kho lưu trữ đối tượng (object storage) tương thích S3, và một bảng điều khiển (dashboard) trên web, tất cả được đóng gói thành một bộ Docker Compose. Phía máy khách là @codemagic/react-native-patch, một TurboModule cho iOS và Android kèm plugin cấu hình cho Expo, cộng thêm một công cụ dòng lệnh (CLI) tên cmpatch để đóng gói (bundle) JavaScript của bạn và phát hành nó.
Sau khi đọc mã nguồn, có hai quyết định thiết kế khiến tôi chú ý.
Thứ nhất, ứng dụng không bao giờ hỏi API xem có bản cập nhật hay không. Khi một bản phát hành được công bố, worker ghi các manifest dạng JSON thuần vào kho lưu trữ đối tượng, và ứng dụng chỉ việc tải chúng về. Một lần kiểm tra cập nhật chỉ là hai yêu cầu GET tĩnh tới kho lưu trữ hoặc CDN; API chỉ nhận số liệu (metrics). Endpoint Django của tôi trước đây nằm ngay trên đường đi của mọi lần mở ứng dụng. Với Patch thì chuyện đó không thể xảy ra.
Thứ hai, mỗi bản phát hành đều ghi rõ phiên bản nhị phân (binary) chính xác mà nó dành cho, và CLI tính ra một dấu vân tay (fingerprint) của dự án native rồi từ chối phát hành JavaScript lên một bản nhị phân có mã native không khớp. Máy chủ của tôi thì chỉ có một cột runtime_version và rất nhiều hy vọng.
Chạy thử trên laptop trước đã#
Tài liệu hướng dẫn bắt đầu bằng một bộ đánh giá chạy cục bộ (local evaluation stack), và đó đúng là chỗ nên bắt đầu.
npm install -g @codemagic/patch-cli
cmpatch selfhost local-eval upCLI cài xong trong hai giây. Còn cả bộ thì lần đầu mất 3 phút 14 giây, phần lớn là để dựng image của máy chủ và bảng điều khiển ngay trên máy, và ở chế độ không tương tác thì nó không in ra bất cứ thứ gì trong suốt quãng đó, trông y hệt như bị treo. Rồi bốn container khỏe mạnh hiện lên, tất cả đều gắn vào localhost: bảng điều khiển ở cổng 8080, API ở 3000, Postgres, và MinIO ở 9100. Nó khéo léo tránh những cổng mà bộ công nghệ của tôi đang dùng sẵn.
Ở chế độ này, đăng nhập bị tắt, và trang nào cũng có một cảnh báo là đừng đưa nó ra ngoài Internet.

Bộ này có sẵn dữ liệu mẫu cho một ứng dụng tên Example Data, rất đáng xem trước khi bạn phát hành bất cứ thứ gì, vì nó cho thấy bảng điều khiển dùng để làm gì. Các con số trong hai ảnh chụp màn hình tiếp theo là dữ liệu mẫu đó, không phải người dùng thật.


Để so sánh, bảng điều khiển của tôi trước đây là danh sách các bản ghi cập nhật trong Django admin với một ô đánh dấu “is active”. Tôi hoàn toàn không biết có bao nhiêu điện thoại đã thật sự nhận được bản cập nhật.
Đăng nhập cho CLI là một luồng trên trình duyệt đàng hoàng, dùng PKCE và một callback về localhost, và trang phê duyệt cho bạn biết tài khoản nào đang yêu cầu, cùng với việc mã sẽ hết hạn sau chừng một phút.

Trỏ nó vào một ứng dụng thật#
Patch muốn mỗi nền tảng là một ứng dụng riêng, mỗi ứng dụng được tạo sẵn hai môi trường triển khai (deployment) Staging và Production. Tôi tạo maru-ios trên bảng điều khiển và maru-android bằng cmpatch app create, chủ yếu để thử cả hai cách. Hộp thoại hiện ra sau khi tạo ứng dụng là màn hình hữu ích nhất của cả sản phẩm cho một đợt di chuyển: cả hai khóa triển khai (deployment key), hai URL mà SDK cần, và một ghi chú rằng khóa triển khai không phải là thông tin bí mật.

Hai điểm nhỏ. cmpatch init chỉ liên kết dự án với những ứng dụng đã tồn tại, nên nó không thể là lệnh đầu tiên, dù hướng dẫn di chuyển có ngụ ý thế nào đi nữa. Và hộp thoại đưa URL của API là http://localhost:8080, trong khi tài liệu và CLI thì nói 3000. Cả hai đều chạy được, vì mỗi cổng đều phục vụ cả hai thứ, nhưng bạn sẽ thắc mắc đấy.
Sau đó tôi chạy cmpatch doctor trên ứng dụng còn nguyên chưa sửa gì, và nó tìm ra một vấn đề thật sự trong ba giây. Nó báo phiên bản nhị phân iOS là 2.0.0. Trong khi ứng dụng đang ở 2.5.1.
CLI tìm mọi tệp Info.plist bên dưới ios/, và nếu không tệp nào có chữ “test” trong đường dẫn, nó lấy tệp đầu tiên theo thứ tự bảng chữ cái. maru có một tiện ích mở rộng WidgetKit, và ios/ExpoWidgetsTarget/Info.plist xếp trước ios/maru/Info.plist. Tệp plist của tiện ích mở rộng đó đã cũ, vẫn còn ghi 2.0.0, đây là lỗi của tôi, nhưng hậu quả thì rất khó chịu: cơ chế tự phát hiện sẽ nhắm mọi bản phát hành iOS vào một phiên bản mà không điện thoại nào đang chạy, và sẽ chẳng có lỗi nào bắn ra. Bản cập nhật đơn giản là không bao giờ tới nơi, đúng kiểu thất bại mà tôi đã cảnh báo trong bài về Tigris. Trên Android thì versionName được đọc đúng. Cách khắc phục là truyền --target-binary-version hoặc --plist-file, và script phát hành của tôi giờ truyền phiên bản một cách tường minh, sau khi kiểm tra rằng app.config.js và cả hai dự án native đều thống nhất với nhau. Về sau, cũng chính lỗi này khiến bước kiểm tra manifest của doctor thất bại trên một môi trường triển khai đang chạy hoàn toàn bình thường, vì nó đi tìm manifest của phiên bản 2.0.0. Sau khi tôi sửa tệp plist cũ kia, doctor vượt qua cả 24 bước kiểm tra.
Phía JavaScript#
Gỡ expo-updates ra và thêm SDK vào chỉ là thay đổi hai dòng. Còn thay thế những gì ứng dụng vốn làm với các bản cập nhật thì phải suy nghĩ nhiều hơn.
maru kiểm tra cập nhật khi mở ứng dụng và mỗi khi quay lại từ chế độ nền (foreground), tối đa mười lăm phút một lần. Nếu có bản mới, nó tải về rồi đề nghị khởi động lại; bạn từ chối thì bản đó sẽ được áp dụng ở lần khởi động nguội (cold start) tiếp theo. Trong phần Cài đặt còn có nút kiểm tra thủ công, cho biết bạn đang chạy gói mã (bundle) nào, và trình báo cáo lỗi gắn nhãn mọi báo cáo bằng bản cập nhật mà nó xuất phát.
Điều đầu tiên tôi học được từ mã nguồn SDK là nó tra cứu mô-đun native ngay lúc import bằng TurboModuleRegistry.getEnforcing, hàm này sẽ ném lỗi nếu mô-đun không tồn tại. maru còn có cả bản web, và một lệnh import ở cấp cao nhất sẽ làm sập luôn ứng dụng web. Nên mọi thứ đi qua một mô-đun nhỏ nạp SDK theo kiểu lười (lazy), kèm một bản song sinh .web.ts trả về rỗng, cách này cũng giữ SDK hoàn toàn nằm ngoài gói mã web.
// services/ota.ts
export function patchSdk(): PatchSdk | null {
if (sdk !== undefined) return sdk;
if (Platform.OS === "web") return (sdk = null);
try {
sdk = require("@codemagic/react-native-patch") as PatchSdk;
} catch {
sdk = null;
}
return sdk;
}Bản thân bước kiểm tra tự động thì chủ yếu là sync(), hàm này không bao giờ ném lỗi mà trả về một trạng thái:
const status = await patch.sync({
installMode: "ON_NEXT_RESTART",
mandatoryInstallMode: "ON_NEXT_RESUME",
});
if (status === "update-installed") offerRestart();Mặc định của SDK cho các bản phát hành bắt buộc (mandatory) là IMMEDIATE, tức là nạp lại JavaScript ngay bất kể người dùng đang ở đâu. Tôi đổi thành áp dụng vào lần ứng dụng quay lại từ chế độ nền tiếp theo, vì mất một bài viết đang gõ dở là một lỗi còn tệ hơn lợi ích của hầu hết các bản sửa. Cũng lưu ý rằng InstallMode là một kiểu hợp chuỗi (string union), chứ không phải đối tượng enum như ví dụ trong tài liệu gợi ý.
Phía native, khi bạn đã commit ios/ và android/#
Hướng dẫn di chuyển bảo chạy npx expo prebuild --clean sau khi thêm plugin cấu hình. Tôi chạy nó trong một bản checkout riêng, và nó xóa sáu ảnh hướng dẫn, tệp cấu hình bảo mật mạng của Android, tệp kê khai quyền riêng tư (privacy manifest) của iOS và Podfile.lock, đồng thời hoàn tác một bản sửa cho Detox trong build.gradle cùng vài trăm dòng của Info.plist. Bỏ --clean đi thì Expo vẫn dọn sạch cả hai thư mục.
Đây không phải lỗi của Patch. maru khởi đầu là một ứng dụng Expo dạng quản lý (managed) rồi dần có thêm những tùy biến native, nên ios/ và android/ được commit vào kho và EAS dựng chúng nguyên trạng. Nhưng có rất nhiều ứng dụng trông giống như vậy, và hướng dẫn thì không cảnh báo bạn.
Hóa ra những gì plugin thật sự thay đổi lại đủ nhỏ để làm bằng tay. Trên iOS, một dòng import và một dòng trong AppDelegate.swift:
return CodemagicPatch.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")Trên Android, một dòng import và một tham số trong MainApplication.kt (đây là dạng của React Native 0.82 trở lên; các phiên bản cũ hơn thì ghi đè getJSBundleFile()):
ExpoReactHostFactory.getDefaultReactHost(
jsBundleFilePath = CodemagicPatch.getJSBundleFile(applicationContext),
// ...Cộng thêm ba giá trị cấu hình cho mỗi nền tảng và việc gỡ bỏ các thiết lập expo-updates cũ: 13 dòng thêm vào và 20 dòng xóa đi trên cả hai dự án.
Plugin ghi những giá trị cấu hình đó dưới dạng hằng (literal) vào lúc prebuild, mà với dự án native đã commit thì điều đó có nghĩa là mọi bản dựng (build) đều bị gắn cứng vào một môi trường triển khai duy nhất. Tôi muốn bản dựng xem trước (preview) đi vào Staging và bản dựng cho cửa hàng đi vào Production từ cùng một dự án, nên các giá trị này được lấy từ môi trường dựng thay vì ghi cứng. Trong Info.plist, Xcode tự mở rộng các thiết lập dựng:
<key>CodemagicPatchDeploymentKey</key>
<string>$(PATCH_IOS_DEPLOYMENT_KEY)</string>Và trong android/app/build.gradle:
def patchEnv = { name -> System.getenv(name) ?: (findProperty(name) ?: "") }
resValue "string", "CodemagicPatchDeploymentKey", patchEnv("PATCH_ANDROID_DEPLOYMENT_KEY")Các khóa sau đó nằm trong từng hồ sơ dựng (build profile) của eas.json, ngay cạnh các giá trị EXPO_PUBLIC_. Giá trị rỗng nghĩa là SDK lặng lẽ không làm gì cả, với bản dựng cục bộ thì không sao, nhưng với bản dựng cho cửa hàng thì là thảm họa, nên app.config.js giờ sẽ ném lỗi nếu một bản dựng EAS preview hoặc production thiếu chúng, hoặc vẫn còn trỏ về localhost.
Bản cập nhật đầu tiên#
Cho bản phát hành đầu tiên, tôi đổi dòng tiêu đề của màn hình onboarding, thứ bạn thấy được mà không cần đăng nhập, rồi phát hành:
./scripts/publish-patch-update.sh ios --local --notes "Onboarding headline over the air"Mất 30 giây, phần lớn là Metro và Hermes. Worker của máy chủ công bố manifest 0.6 giây sau khi tải lên xong. Bản cập nhật là một tệp tarball nén 7.3 MB, so với 11 MB gồm gói mã Hermes cộng tài nguyên mà cách kết xuất (export) cũ của tôi tạo ra.
Có một chỗ phải làm cho đúng ở đây: gói mã JavaScript nhúng thẳng các giá trị EXPO_PUBLIC_ vào lúc dựng, nên bản cập nhật phải được dựng với đúng những giá trị của bản nhị phân mà nó sẽ đáp xuống, nếu không bạn sẽ đẩy đi một đoạn JavaScript nói chuyện với sai backend. Script của tôi nạp chúng từ đúng hồ sơ eas.json đã dùng để dựng bản nhị phân đó.
Rồi tôi mở lại ứng dụng. Nhật ký (log) của thiết bị cho thấy toàn bộ lần kiểm tra cập nhật chỉ là hai yêu cầu tới kho lưu trữ đối tượng, meta.json và 2.5.1/manifest.json. SDK ghi một sự kiện Downloaded ba giây sau khi mở, xếp bản cập nhật vào hàng chờ, và ứng dụng đề nghị khởi động lại.
Lỗi là của tôi#
Sau đó tôi nhìn vào bảng điều khiển. Một trình giả lập (simulator) đã cài bản phát hành v1, mà nó lại hiện hai lượt cài thành công và hai người dùng đang hoạt động.

SDK lưu trạng thái và các sự kiện số liệu đang chờ gửi dưới dạng tệp trong container của ứng dụng, nên việc truy vết khá dễ. Có hai sự kiện Success với dấu thời gian (timestamp) giống hệt nhau nhưng id khác nhau. Hook của tôi xác nhận gói mã đang chạy bằng notifyAppReady() ngay khi được gắn (mount), rồi gọi sync() ngay sau đó, mà sync() thì tự gọi notifyAppReady() bên trong. Hai lời gọi chồng lên nhau, mỗi lời gọi đều thấy bản phát hành đang ở trạng thái chờ, và mỗi lời gọi đều ghi lại sự kiện. Máy chủ có khử trùng lặp theo id sự kiện, nhưng hai sự kiện này có id khác nhau, nên cả hai đều được đếm, và những bản ghi trùng đó nằm lại vĩnh viễn.
Cách sửa ở phía tôi là chỉ có một lần xác nhận dùng chung cho mỗi lần mở ứng dụng, mọi thứ khác đều phải chờ nó. SDK cũng có thể tự phòng ngừa các lời gọi chồng chéo, và tôi đã đề xuất điều đó.
Phần thú vị là chuyện đẩy bản sửa đi. Đây là thay đổi thuần JavaScript, nên nó được phát hành thành bản v2, và vì thiết bị đang ở v1 có thể nhận một bản vá nhị phân (binary diff), v2 tải về dưới dạng bản vá 543 KB thay vì gói 7.3 MB, tức khoảng 7% kích thước. Sau khi khởi động lại: một lượt tải, một lượt cài, một lượt thành công, một thiết bị hoạt động.

Cố tình làm hỏng#
Khôi phục (rollback) là tính năng tôi quan tâm nhất, vì hệ thống cũ của tôi không có cơ chế nào trên thiết bị cả. Một bản cập nhật hỏng sẽ làm sập mọi lần mở ứng dụng cho tới khi tôi để ý và bỏ đánh dấu một ô.
Nên tôi phát hành bản v3 ném lỗi trước lần kết xuất giao diện (render) đầu tiên, nghĩa là nó không bao giờ gọi được notifyAppReady(). Ứng dụng tải về, đề nghị khởi động lại, rồi chết ngay về màn hình chính.
Lần mở tiếp theo lại khởi động v3 và lại sập, điều này làm tôi bất ngờ, vì tài liệu nói rằng gói mã nào sập trước khi gọi notifyAppReady() sẽ được khôi phục. Mã nguồn giải thích: SDK cho phép ba lần mở chưa được xác nhận trước khi kết luận là sập, vì một lần mở chưa xác nhận có thể chỉ là iOS đã diệt một tiến trình khỏe mạnh trước khi JavaScript kịp chạy. Lần mở thứ tư thì ứng dụng quay lại v2.
Thiết bị sau đó tự báo cáo thất bại này dưới dạng crash_rollback, và bảng điều khiển ghi nhận nó cho v3. Đó là một thiết kế hợp lý, nhưng cần nói thẳng ý nghĩa của nó: một bản phát hành hỏng khiến mỗi người dùng phải chịu ba lần mở ứng dụng bị sập trước khi hồi phục, và tài liệu không nhắc tới con số này. Đó chính là lý do nên đẩy lên Staging trước, rồi mới phát hành theo từng đợt (rollout) nhỏ lên Production.
Chặn một bản phát hành hỏng cho tất cả những người còn lại chỉ là một lệnh, và mất chưa tới một giây:
cmpatch release rollback --app maru-ios --deployment StagingNó không viết lại lịch sử. Nó công bố lại bản phát hành trước đó thành một bản mới (v4, được đánh dấu là bản khôi phục của v2), nên bản đang gây sập vẫn nằm trong danh sách với số lượt thất bại của nó, đây là cách ghi chép trung thực. Bảng điều khiển làm điều tương tự qua một hộp thoại.

Android#
Android có hai bất ngờ, đều không phải do Patch. Quy tắc keep cho R8 mà tôi thêm cho SDK nằm trong app.config.js, thứ chỉ tới được dự án native thông qua prebuild, nên bản dựng release đầu tiên của tôi vô tình trở thành bài kiểm thử xem SDK có sống sót qua R8 mà không cần quy tắc đó hay không. Kết quả là có: R8 đổi tên toàn bộ các lớp của nó, mà bản cập nhật vẫn tải, cài và khôi phục bình thường. Còn trình giả lập (emulator), vừa khởi động nguội xong trong khi Gradle vẫn đang chiếm bộ nhớ, chậm đến mức bài kiểm thử đầu tiên của tôi bỏ cuộc 45 giây trước khi SDK tải xong bản cập nhật mà nó đã bắt đầu tải.
Bản v1 được áp dụng, với một lượt thành công và một thiết bị hoạt động, còn bản v2 gây sập thì được khôi phục sau cùng một ngân sách ba lần mở như trên.
Vượt ra ngoài kịch bản suôn sẻ#
Bản cập nhật tới nơi và khôi phục được là phần lõi, nhưng một đội (team) sống với những tính năng còn lại, nên tôi đi qua từng cái một.
Phát hành theo từng đợt là tất định (deterministic), điểm này tôi thích. Một thiết bị được nhận bản mới nếu tám chữ số hex đầu của md5(deviceId + "-" + releaseLabel), lấy modulo 100, nhỏ hơn tỷ lệ phát hành, nên với một bản phát hành nhất định, cùng một chiếc điện thoại sẽ luôn được nhận hoặc luôn không được nhận. Tôi tính ra trình giả lập của mình rơi vào nhóm 91 cho bản phát hành kế tiếp, công bố nó ở mức 25%, và trình giả lập đúng là không nhận được gì. Nâng lên 95% bằng cmpatch release patch, mất chưa tới một giây, thì nó cập nhật ở lần mở tiếp theo.
Rồi bản phát hành tiếp theo của tôi thất bại với mã 409: “deployment has an active rollout below 100 percent”. Mỗi môi trường triển khai chỉ được phép có một đợt phát hành một phần tại một thời điểm, nên bạn phải hoàn tất, tắt hoặc khôi phục bản canary trước khi đẩy bất cứ thứ gì khác. Điều đó hợp lý, và có ghi trong tài liệu, nhưng một quy trình (pipeline) tự phát hành sau mỗi lần merge sẽ đâm phải nó ngay giữa một đợt canary.
Bản phát hành bắt buộc dùng mandatoryInstallMode của SDK. Với thiết lập của tôi là áp dụng vào lần quay lại tiếp theo, ứng dụng tải bản cập nhật về và hiện hộp thoại, và khi tôi lờ hộp thoại đi, đưa ứng dụng xuống nền rồi mở lại, phiên bản mới đơn giản là đã ở đó.
Tắt một bản phát hành không chỉ là ngừng cung cấp nó. Ngay khi tôi tắt bản mới nhất, các manifest trỏ về bản phát hành mới nhất còn đang bật, và các thiết bị đang chạy bản bị tắt được đề nghị một bản vá để quay về đó. Tắt hết mọi bản cho một phiên bản nhị phân thì đích của manifest trở thành null, và SDK đưa ứng dụng về gói mã đi kèm ban đầu. Hook cập nhật của tôi lúc đầu không hiện hộp thoại cho trường hợp này, nên giờ tôi đã bổ sung.
Chuyển (promote) từ Staging lên Production mất chưa tới một giây và dùng lại đúng gói đã được kiểm thử, cùng mã băm (hash) và ghi chú, thay vì dựng lại. Lệnh này cũng nhận tỷ lệ phát hành, nên “chuyển lên 10% của Production” chỉ là một lệnh.
Dựng và phát hành cũng có thể tách thành hai bước riêng. cmpatch bundle tạo ra một tệp .cmpatch, và tùy chọn “Bundle upload” trên bảng điều khiển sẽ đọc nó trước khi tải lên: nền tảng, phiên bản đích, dấu vân tay, trạng thái ký, kích thước và công cụ đóng gói. Cách này hợp với mô hình CI dựng tệp, còn một người khác quyết định khi nào phát hành.
Đó cũng là chỗ tôi thật sự va phải cơ chế bảo vệ bằng dấu vân tay. Sau khi sửa tệp plist cũ của widget, máy chủ từ chối bản phát hành tiếp theo với mã 409, vì dấu vân tay của dự án native không còn khớp với cái nó đã ghi nhận cho 2.5.1. Trên Android, chỉ sửa một dòng chú thích (comment) trong app.config.js cũng đủ, vì Expo đưa cả cấu hình ứng dụng vào dấu vân tay. Bảng điều khiển hiện cùng kiểm tra đó dưới dạng cảnh báo với cả hai dấu vân tay và một nút “Upload anyway”. Trong cả hai trường hợp, phần native trên bản nhị phân đã cài không hề thay đổi, nên bỏ qua cảnh báo là đúng. Nhưng trong thực tế, câu trả lời trung thực gần như luôn là tăng phiên bản và dựng bản mới cho cửa hàng, và script phát hành của tôi giờ nói đúng như vậy khi chuyện này xảy ra.
Ký mã dùng RS256: CLI ký mã băm của gói bằng một khóa riêng RSA, và SDK đối chiếu với khóa công khai được biên dịch sẵn vào bản nhị phân. Khóa được đưa vào dưới dạng một dòng base64 duy nhất, khớp với cách tiếp cận qua thiết lập dựng của tôi, và tôi đã kiểm thử trên cả hai nền tảng. Khi ứng dụng đã có khóa, một bản phát hành chưa ký bị từ chối trước cả khi tải về. Khi bật “require code signing” cho ứng dụng, máy chủ từ chối một lần phát hành chưa ký với thông báo lỗi nêu rõ cách sửa và dẫn liên kết tới hướng dẫn. Bản phát hành đã ký thì cài được, và SDK ghi nhận chữ ký là đã xác minh. Nó cũng từ chối một bản phát hành cũ chưa ký mà máy chủ quay về khi tôi tắt bản đã ký. Điểm cần lưu ý là khóa công khai phải có mặt trong bản nhị phân đầu tiên bạn phát hành, vì bản cập nhật không thể thêm nó vào được.

Phần đội nhóm và CI cũng được lo. Thành viên nhận một trong bốn vai trò áp dụng toàn đội (owner, admin, developer, viewer), và lời mời sẽ chờ cho tới khi người đó đăng nhập lần đầu. Token truy cập cá nhân (personal access token) chạy được với thư mục home trống, truyền qua --token hoặc qua biến môi trường, và danh sách token cho biết mỗi token được dùng lần cuối khi nào. Tôi cũng đã viết một workflow GitHub Actions chạy thủ công, chạy cùng script phát hành đó trên một runner Linux với khóa ký lấy từ secret, dù tôi chưa chạy nó, vì nó cần máy chủ thật.
Một công cụ tôi sẽ tạm bỏ qua là cmpatch debug ios. Nó phát trực tiếp nhật ký hệ thống được lọc theo chữ “OTA” không phân biệt hoa thường, mà chuỗi này khớp cả với “Rotation”, nên một lần mở ứng dụng kéo dài hai mươi giây cho ra cả màn hình thông điệp của SpringBoard mà không có dòng nào từ ứng dụng.
Rốt cuộc thì khó tới mức nào#
Codemagic hỏi ý kiến của tôi, nên đây là ý kiến đó, nói thẳng nhất có thể.
Chạy thử thì dễ. Hai lệnh, vài phút, và bạn có máy chủ thật, một bảng điều khiển với dữ liệu demo, và một CLI trỏ được vào ứng dụng của chính bạn. Tôi đánh giá đây là phần tốt nhất trong giờ đầu tiên.
Tích hợp SDK là việc ở mức vừa phải, và phần lớn cái khó nằm ở dự án của chính bạn chứ không phải ở Patch. Với một ứng dụng Expo mới tinh có thư mục native được sinh tự động, thì đúng là chỉ cần plugin cộng vài dòng thay thế các lời gọi expo-updates. Với một ứng dụng như maru, có dự án native đã commit, một tiện ích mở rộng widget, một bản web, R8 và các hồ sơ dựng, thì công việc nằm ở những chỗ rìa. Tôi phải áp dụng các thay đổi native bằng tay để tránh prebuild, đưa cấu hình về lúc dựng để bản Staging và Production có thể khác nhau, và giữ SDK tránh xa bản web. Tôi cũng phải đảm bảo JavaScript trong bản cập nhật được dựng với cùng môi trường như bản nhị phân. Không việc nào khó khi đã hiểu, nhưng phần lớn chưa có trong tài liệu.
Dùng hằng ngày thì tốt. Phát hành là một lệnh và chừng nửa phút. Phát hành theo từng đợt, chuyển môi trường, tắt và khôi phục đều dưới một giây từ CLI hoặc bảng điều khiển, và thông báo lỗi nói rõ đã sai gì và phải làm gì. Các cơ chế bảo vệ của máy chủ (kiểm tra dấu vân tay, mỗi lần chỉ một canary, ký mã) nghiêm ngặt theo cách đã hơn một lần ngăn tôi làm sai.
Vận hành nó trên production là cam kết lớn hơn. Đó là một bộ Docker Compose với Postgres và kho lưu trữ đối tượng mà bạn phải tự lưu trữ, sao lưu và nâng cấp, cộng thêm hai tên miền và một ứng dụng OAuth. Với một đội đã quen vận hành máy chủ thì đó là một buổi chiều yên ả. Với một đội dùng EAS chính vì không muốn dính tới máy chủ, đây mới là chi phí thật của việc chuyển đổi, hơn cả phần việc với SDK.
Tính theo thời gian thực, tôi cài CLI lúc 12:29 và có bản cập nhật đầu tiên áp dụng trên trình giả lập lúc 13:34, trong đó nửa tiếng là một lần dựng Xcode bị treo vì lý do không liên quan tới Patch. Mọi thứ trong bài này, trên cả hai nền tảng, đều xong trước giữa buổi chiều, chưa tính máy chủ thật.
Những thứ ngốn của tôi nhiều thời gian nhất, theo thứ tự: expo prebuild --clean xóa sạch dự án native đã commit, rồi việc tìm cách nối mọi thứ bằng tay; lỗi phát hiện phiên bản trên iOS, thứ suýt gửi bản cập nhật tới một phiên bản không ai chạy; các lượt cài bị đếm trùng, lỗi do chính tôi gây ra nhưng ai cũng dễ lặp lại, vì sync() tự gọi notifyAppReady(); và ngân sách ba lần mở khi sập chưa được ghi trong tài liệu, thứ làm tôi bất ngờ giữa chừng một bài kiểm thử. Sau đó là những thứ đúng nhưng cần lên kế hoạch: cơ chế bảo vệ dấu vân tay kích hoạt sau những chỉnh sửa vô hại gần với native, mỗi môi trường triển khai chỉ một canary tại một thời điểm, và các tác vụ vòng đời không được chồng lên nhau, điều này quan trọng ngay khi một script làm nhiều việc liên tiếp. Phần còn lại đều nhỏ: lần thiết lập đầu tiên chạy im lặng, một lệnh gỡ lỗi ồn ào, và vài ví dụ trong tài liệu không khớp với mã.
Tôi đã viết toàn bộ những điều này, kèm chi tiết, gửi cho Codemagic.
Đưa lên production#
Mọi thứ ở trên đều chạy trên bộ đánh giá. Production là cùng ứng dụng và cùng script đó trỏ vào một máy chủ thật, và đây là kế hoạch cho maru.
Cách vận hành máy chủ được hỗ trợ chính thức là cài bằng Docker Compose: một máy Linux mở cổng 80 và 443, hai tên miền (một cho API và bảng điều khiển, một cho tải xuống), và một ứng dụng GitHub OAuth để đăng nhập, không có nó thì máy chủ không chịu khởi động. Trình cài đặt lo phần còn lại, kể cả TLS:
scripts/selfhost/install.sh \
--api-domain updates.example.com \
--storage-domain storage.updates.example.com \
--email [email protected] \
--github-oauth-client-id <id> --github-oauth-client-secret <secret>Nó ghi ra một tệp .env.selfhost chứa các khóa bí mật của cơ sở dữ liệu và kho lưu trữ, thứ nên nằm trong trình quản lý mật khẩu của bạn.
Nhưng bạn không bắt buộc phải dùng cơ sở dữ liệu và kho lưu trữ đi kèm, và đó chính là điểm khiến nó vừa với một bộ công nghệ như của tôi. API của maru đã chạy trên Fly.io và tệp của nó nằm trên Tigris, mà Patch chạy được trên cả hai. Bên dưới tệp Compose, máy chủ là một container chạy ở MODE=all (API và worker phát hành gộp chung) cần một cơ sở dữ liệu Postgres, kho lưu trữ tương thích S3 và một URL HTTPS công khai cho các tệp. Tức là một ứng dụng Fly dựng từ Dockerfile trong kho mã (chưa có image được công bố, nên fly deploy sẽ tự dựng), một cơ sở dữ liệu Fly Postgres, và một bucket Tigris:
MODE=all
DATABASE_URL=postgresql://… # Fly Postgres
STORAGE_ADAPTER=s3
S3_ENDPOINT=https://fly.storage.tigris.dev
S3_REGION=auto
S3_BUCKET=maru-patch
S3_FORCE_PATH_STYLE=true
PUBLIC_BASE_URL=https://maru-patch.fly.storage.tigris.dev/codemagic-patchHai điều cần lưu ý. Chạy ngoài Compose được ghi trong kho mã như tài liệu tham khảo, chứ không phải cách được hỗ trợ trong bản mã nguồn mở đầu tiên này, nên trên Fly bạn phải tự lo nâng cấp và sao lưu. Tôi cũng chưa triển khai theo cách đó; đây là kế hoạch, chưa phải báo cáo. Và nếu bạn đã đọc bài của tôi về cơ chế thu về 0 của Fly, hãy giữ ít nhất một máy luôn chạy. Điện thoại không bao giờ phải chờ API khi kiểm tra cập nhật, vì manifest lấy thẳng từ kho lưu trữ, nhưng nếu không thì mọi lần tải số liệu lên và mọi tác vụ phát hành sẽ bắt đầu bằng một lần khởi động nguội.
Tigris là phần tôi hào hứng nhất. Đó là nơi tệp của maru vốn đã nằm, và nó đặt một CDN phía trước cái URL mà mọi lần mở ứng dụng đều gọi tới. Nếu làm theo cách này, hãy để ý việc lưu đệm (cache) manifest: với bộ điều hợp (adapter) phân phối theo URL gốc đơn giản, Patch trả manifest với no-cache, vì nó không thể xóa bộ nhớ đệm trên CDN, và một manifest bị lưu đệm theo TTL riêng sẽ tiếp tục phục vụ một bản phát hành mà bạn vừa khôi phục xong.
Trên máy chủ thật, bạn tạo lại các ứng dụng, đưa khóa mới vào hồ sơ preview và production trong eas.json (khóa Staging và khóa Production), và tạo một token để phát hành:
cmpatch token create --name ciNếu muốn ký mã, hãy quyết định ngay từ bây giờ: tạo cặp khóa cho production, giữ khóa riêng làm secret trong CI, đưa khóa công khai vào các hồ sơ dựng, và bật “require code signing” cho cả hai ứng dụng. Bản nhị phân đầu tiên có Patch cũng cần một số phiên bản mới, vì các bản nhị phân đang nằm trên cửa hàng vẫn chạy expo-updates. Dựng nó bằng hồ sơ preview cho TestFlight và kiểm thử nội bộ trên Play trước, rồi kiểm tra xem các khóa có thật sự vào được Info.plist đã dựng và tài nguyên Android hay chưa, trước khi đưa cho bất kỳ ai. Đó là mắt xích duy nhất mà các bài kiểm thử cục bộ của tôi chưa chạm tới, vì tôi đã xuất biến môi trường bằng tay. Sau đó phát hành lên Staging, xác nhận mỗi người thử nghiệm có một lượt tải và một lượt thành công, rồi mới dựng bản cho cửa hàng.
Bản phát hành lên production thì bắt đầu nhỏ rồi tăng dần:
npm run publish-update:prod -- --rollout 10
cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage 100Trong lúc đó, máy chủ Django cũ vẫn giữ nguyên. Mọi bản 2.5.1 mà người dùng chưa cập nhật vẫn kiểm tra ở đó, và bản sửa nóng (hotfix) cho họ vẫn đi qua script cũ. Khi nhật ký cho thấy những yêu cầu đó đã dừng hẳn, thì mô hình dữ liệu, viewset, lệnh quản trị, script và khóa bí mật đều có thể bỏ đi.
Điều tôi muốn nói với ai đang trả hóa đơn cho Expo#
Nếu bạn đang trả tiền cho EAS Update vì thấy tự dựng máy chủ có vẻ quá nhiều việc, thì Patch là một câu trả lời thật sự: bạn vẫn phải vận hành một máy chủ, nhưng đó là một bộ Compose và một trình cài đặt, chứ không phải mã mà bạn phải tự bảo trì. So với thứ tôi đã xây, bạn có thêm phát hành theo từng đợt, số lượt cài và thất bại của từng bản, bản vá nhị phân, cơ chế bảo vệ bằng dấu vân tay, ký mã, khôi phục khi sập ngay trên thiết bị, và một nút khôi phục. Tôi chưa từng xây được cái nào trong số đó.
Cái giá phải trả: một bản nhị phân mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng; một máy chủ phải giữ cho luôn được nâng cấp và sao lưu; ba lần mở ứng dụng bị sập cho mỗi người dùng khi một bản phát hành hỏng đủ nặng; và một ít việc làm tay nếu dự án native của bạn đã commit. Nó cũng còn non. Đây là phiên bản 0.3.0, và tôi gặp một lỗi phát hiện phiên bản, một lệnh gỡ lỗi ồn ào và vài chỗ thiếu trong tài liệu, cộng thêm một lỗi do chính tôi gây ra mà SDK lẽ ra có thể ngăn được. Không điều nào trong số đó cản trở đợt di chuyển, và tất cả đều nhìn thấy được, trên CLI, trên bảng điều khiển, hoặc ngay trong các tệp của SDK trên thiết bị, điều mà tôi không thể nói về hệ thống cũ của mình.


