Hệ thống RPC mới Cap'n Web cho trình duyệt và máy chủ web
(blog.cloudflare.com)- Cap'n Web là giao thức RPC mới được triển khai bằng TypeScript, được tối ưu cho môi trường web và hoạt động trên nhiều runtime JavaScript khác nhau
- Không cần schema hay boilerplate rườm rà, cung cấp tuần tự hóa dựa trên JSON và định dạng dữ liệu con người có thể đọc được
- Thông qua mô hình dựa trên object-capability, nó hỗ trợ gọi hai chiều, truyền tham chiếu hàm và đối tượng, promise pipelining, và triển khai các mẫu bảo mật
- Hỗ trợ nhiều môi trường mạng như WebSocket, HTTP, postMessage, đồng thời là mã nguồn mở nhẹ dưới 10kB
- Không chỉ giải quyết vấn đề waterfall tương tự GraphQL, mà còn cho phép mô hình hóa RPC tự nhiên như API JavaScript thông thường
Cap'n Web là gì
- Cap'n Web là hệ thống RPC (protocol) mã nguồn mở dựa trên TypeScript do Cloudflare phát triển
- Lấy cảm hứng từ Cap'n Proto, nhưng hoạt động không cần định nghĩa schema riêng, và áp dụng cách tuần tự hóa thân thiện với con người bằng JSON
- Tích hợp với TypeScript để cải thiện trải nghiệm lập trình viên như tự động hoàn thành và kiểm tra kiểu; việc xác thực kiểu ở runtime có thể xử lý riêng (như type guard)
- Hỗ trợ các giao thức mạng như HTTP, WebSocket, postMessage và chạy trên các trình duyệt chính, Cloudflare Workers, Node.js
- Cấu trúc nhẹ không phụ thuộc thư viện, có kích thước dưới 10kB sau khi minify + gzip
Mô hình object-capability (OCap) của Cap'n Web
- Áp dụng mô hình dựa trên object-capability, cho phép biểu đạt phong phú hơn các hệ thống RPC hiện có
- Gọi hai chiều: client và server có thể gọi hàm của nhau
- Truyền tham chiếu hàm và đối tượng: khi truyền hàm hoặc đối tượng qua RPC, phía nhận sẽ nhận stub và khi gọi thì thực thi tại vị trí gốc
- Promise Pipelining: khi nối chuỗi nhiều RPC, có thể xử lý chỉ với một lần round trip mạng
- Mẫu bảo mật: có thể triển khai tự nhiên các cơ chế kiểm soát bảo mật như cấp quyền và quản lý phiên
Cách sử dụng cơ bản
-
Ví dụ phía client
import { newWebSocketRpcSession } from "capnweb" let api = newWebSocketRpcSession("wss://example.com/api") let result = await api.hello("World") console.log(result) -
Ví dụ phía server (dựa trên Cloudflare Worker)
import { RpcTarget, newWorkersRpcResponse } from "capnweb" class MyApiServer extends RpcTarget { hello(name) { return `Hello, ${name}!` } } export default { fetch(request, env, ctx) { let url = new URL(request.url) if (url.pathname === "/api") { return newWorkersRpcResponse(request, new MyApiServer()) } return new Response("Not found", {status: 404}) } } -
Có thể dễ dàng thêm method vào API, truyền callback từ client, cũng như định nghĩa và áp dụng interface TypeScript
RPC là gì và đặc điểm trong Cap'n Web
- RPC (Remote Procedure Call) là khái niệm cho phép hai chương trình trên mạng giao tiếp như thể đang gọi hàm
- Khác với giao thức HTTP/REST truyền thống, RPC cung cấp lớp trừu tượng gọi hàm, cho phép viết mã phù hợp với cách tư duy của lập trình viên
- Cap'n Web phù hợp với luồng JavaScript hiện đại nhờ hỗ trợ async/await, Promise, Exception
- Không giống những tranh cãi lịch sử về RPC (gọi đồng bộ, lỗi mạng), trong môi trường JS hiện đại nó có thể được dùng an toàn và hiệu quả hơn
Các kịch bản sử dụng Cap'n Web
- Phù hợp với mọi môi trường cần giao tiếp mạng giữa hai ứng dụng JavaScript
- Gọi giữa client-server, giữa các microservice, v.v.
- Đặc biệt phù hợp với web app cộng tác thời gian thực và các tương tác vượt qua ranh giới bảo mật phức tạp
- Vẫn đang ở giai đoạn thử nghiệm, nên càng hữu ích hơn với các nhà phát triển cởi mở với công nghệ mới
Nhiều tính năng khác nhau
Chế độ batch HTTP
-
Khi không cần kết nối liên tục, có thể dùng chế độ batch HTTP để gộp nhiều lệnh gọi RPC và xử lý cùng lúc
import { newHttpBatchRpcSession } from "capnweb" let batch = newHttpBatchRpcSession("https://example.com/api") let result = await batch.hello("World") console.log(result) -
Có thể thực thi đồng thời nhiều lời gọi trong cùng một batch và nhận kết quả song song
let promise1 = batch.hello("Alice") let promise2 = batch.hello("Bob") let [result1, result2] = await Promise.all([promise1, promise2])
Promise Pipelining (gọi chuỗi)
-
Hỗ trợ cách dùng ngay kết quả làm đối số cho lệnh gọi tiếp theo mà không cần chờ lệnh gọi trước hoàn tất
-
Ví dụ: truyền trực tiếp Promise kết quả của
getMyName()vàohello()để xử lý chỉ với một lần round trip mạnglet namePromise = batch.getMyName() let result = await batch.hello(namePromise) -
Promise của Cap'n Web hoạt động như đối tượng proxy, cho phép nối thêm method mà không bị trì hoãn
let sessionPromise = batch.authenticate(apiKey) let name = await sessionPromise.whoami()
Bảo mật: xác thực và object-capability
- Thông qua method authenticate, hệ thống cấp đối tượng quyền hạn (session) khi thành công, sau đó có thể gọi chức năng mà không cần thêm bước xác thực
- Khác với RPC truyền thống, không thể giả mạo đối tượng session, và cũng không thể truy cập method yêu cầu quyền nếu chưa xác thực
- Tự nhiên khắc phục các giới hạn cấu trúc của WebSocket, đồng thời bảo đảm tính nhất quán của logic xác thực
- Khi khai báo interface API bằng TypeScript, có thể tự động áp dụng từ client đến server, đồng thời bảo đảm tự động hoàn thành và độ an toàn kiểu
So sánh với GraphQL và điểm khác biệt của Cap'n Web
-
GraphQL làm giảm vấn đề waterfall nhiều tầng của REST, nhưng đòi hỏi đưa vào ngôn ngữ, schema và toolchain mới
-
Cap'n Web giải quyết vấn đề waterfall chỉ bằng mã JavaScript, đồng thời
- nhờ hỗ trợ promise pipelining/tham chiếu đối tượng, có thể mô hình hóa tự nhiên các lời gọi lồng nhau hoặc logic giao dịch phức hợp
let user = api.createUser({ name: "Alice" }) let friendRequest = await user.sendFriendRequest("Bob") -
Có thể sử dụng giống API JavaScript mà không cần gánh độ phức tạp và chi phí học tập/quản lý của GraphQL
Phép toán mảng (array.map v.v.) và tối ưu hóa
-
Trong Cap'n Web, có thể thực hiện phép
maptrên từng phần tử mảng mà không phát sinh thêm round trip mạng -
Hàm callback của
mapđược chạy một lần ở client để ghi lại nội dung tính toán (record-replay), sau đó gửi lên server để xử lý hàng loạt phía serverlet friendsWithPhotos = friendsPromise.map(friend => { return {friend, photo: api.getUserPhoto(friend.id)} }) let results = await friendsWithPhotos -
Thông qua một DSL chuyên biệt trong phạm vi giới hạn, nó cho phép biểu đạt như hàm JavaScript, nhưng thực tế dùng giao thức Cap'n Web để tối ưu nhiều lời gọi
Cấu trúc giao thức nội bộ và luồng giao tiếp
- Truyền dữ liệu có cấu trúc thông qua JSON + tiền xử lý đặc biệt, hỗ trợ các kiểu đặc biệt như mảng, ngày tháng
- Giao thức đối xứng cho phép giao tiếp hai chiều không phân biệt client và server
- Mỗi bên (ví dụ: Alice và Bob) quản lý bảng export/import, và phân biệt tham chiếu đối tượng/hàm bằng ID
- Thông qua các thông điệp push/pull và việc cấp phát Promise ID, có thể phản ánh nhiều lời gọi trong một round trip
Tình hình hiện tại và các trường hợp áp dụng
- Cap'n Web hiện vẫn là mã nguồn mở mang tính thử nghiệm, nhưng đã được dùng trong dịch vụ thực tế như remote bindings của Cloudflare Wrangler
- Dự kiến sẽ có thêm các bài blog và nhiều thử nghiệm frontend khác
- Được phát hành theo giấy phép MIT, bất kỳ ai cũng có thể tự do áp dụng
- Đi tới kho GitHub
1 bình luận
Ý kiến trên Hacker News
Tôi có hai điều thắc mắc
grpc/avrov.v.) cố gắng giải quyết trực tiếp vấn đề nàyTôi nghĩ đây là một công trình thật sự mang tính đột phá
Nếu có đối tượng subscription dùng callback, bạn nên thiết kế API sao cho khi bắt đầu có thể chỉ định “tin nhắn đã thấy gần nhất”. Như vậy có thể tiếp tục nhận dữ liệu ngay từ đó mà không bị bỏ lỡ đoạn nào ở giữa
Có lẽ nên viết một loạt bài blog để tổng hợp các mẫu thiết kế như thế này
Phần nói về cách họ giải quyết vấn đề mảng thực sự rất thú vị nhưng đồng thời cũng hơi đáng sợ liên kết blog
Với
.map(), họ không gửi trực tiếp mã JavaScript lên server, nhưng lại gửi một thứ giống như “mã”, bằng cách dùng một DSL miền hẹp. Ở phía client, callback được chạy thử một lần với giá trị placeholder, rồi hành vi của nó được theo dõi theo kiểu record-replay để gửi một instruction set lên server. Ở server, các instruction đó được nhận và thực thi cho từng phần tử của mảng.Tức là nhà phát triển chỉ dùng method JS bình thường, nhưng bên dưới thực chất có một mẹo biến nó sang một DSL hẹp. Callback chỉ được hoạt động đồng bộ, không thể
await. Thay vào đó chỉ cho phép promise pipelining, để bắt toàn bộ quá trình và gửi sang server, nơi nó sẽ được chạy lại mỗi khi cầnC# có expression tree để xử lý kiểu vấn đề này. Entity Framework dùng nó khi nhận lambda rồi chuyển thành truy vấn SQL. Tức là có thể dùng bằng cách quét hoặc biến đổi mã mà không cần thực thi mã đó
Ví dụ,
db.People.Where(p => p.Name == "Joe")không phải làWherenhận một predicate function thực sự, mà là nhận expression, nên nó có thể quét đoạn mã được truyền vào để kiểm tra trườngNamecó khớp với"Joe"hay không rồi chuyển thành mệnh đề SQLWHEREJavaScript không có cơ chế như vậy, nên họ phải bắt chước bằng cách đưa giá trị placeholder vào và ghi lại từng bước nó hoạt động ra sao
Gần đây khi làm DSL truy vấn cho Tanstack DB, họ cũng dùng mẹo record-replay này liên kết hướng dẫn. Họ truyền đối tượng
RefProxyvào các callbackwhere/select/joinrồi theo dõi các prop/phép toán nào xảy ra trên đối tượng đó.Trong JS, không thể chặn trực tiếp các toán tử thông thường (
==,>v.v.), nên họ tạo các hàm nhỏ có thể trace nhưeq/gt/notv.v., chạy callback đúng một lần để bắt biểu thức được liên kết rồi biến nó thành IRĐiều thú vị là họ còn trace được cả toán tử spread của JS
Kenton, tôi tự hỏi liệu khái niệm này có thể được thêm vào capnweb dưới dạng fake operator (
eq,gt,inv.v.) để có tính năng tracing từ xa hay khôngCó vẻ như câu lệnh điều kiện bị cấm (giống quy tắc hook của React), nên tôi thắc mắc họ thực thi kiểu ràng buộc đó như thế nào
Dự án này rất thú vị
Nó có nhiều điểm giống với các thư viện compiler cho ML (
TensorFlow 1,JAX jit,PyTorch compilev.v.). Chúng tạo operation graph bằng tracing, rồi compile hoặc chuyển đổi nó để thực thi phù hợp với VMHiện nay, ngôn ngữ động được dùng làm frontend để định nghĩa thứ gì đó mà không cần tạo DSL mới, thay vào đó giấu phần biến đổi AST vào ngay trong ngôn ngữ script sẵn có
Trong ML, ta trì hoãn việc chạy kernel GPU/linalg để gộp nhiều kernel lại; còn với RPC như Cap'n Web, có thể trì hoãn các yêu cầu mạng để gộp nhiều network call lại với nhau
Cuối cùng, điểm cốt lõi là tách instruction plane và data plane; ngay cả một CPU đơn lẻ ở quy mô rất nhỏ cũng có cấu trúc hệ phân tán kiểu này (tách cache lệnh/cache dữ liệu)
Trong Cap'n Web, chính RPC graph đóng vai trò instruction
Mẫu này thật sự rất hấp dẫn, nhưng cũng tạo cảm giác như cấu trúc stack (compiler trên interpreter, interpreter trên compiler...) lặp vô hạn. Nó giống một phiên bản khác của kiểu tư duy Lispy: code là data, data là code. Có vẻ như đằng sau còn một câu chuyện sâu sắc hơn nữa
Giờ đây ngôn ngữ động trở thành frontend cho DSL mới, nhưng thay vì định nghĩa cú pháp mới, ta nhúng việc tạo AST ngay vào script
Tôi nghĩ TypeScript là yếu tố thay đổi cuộc chơi ở đây. Nó cho phép kết hợp sự linh hoạt ở runtime của JavaScript (như cách Cap'n Web dùng
Proxyrất khéo) với độ an toàn kiểuDạo này tôi rất hứng thú với khái niệm này trong mảng ORM. Hầu hết ORM đều tuần tự và eager, nên chỉ có thể can thiệp ngay trước khi thực thi truy vấn
Một ORM thật sự composable theo tôi phải hoạt động như compiler: định nghĩa một DSL hoàn toàn type-safe trên SQL bằng TypeScript để tạo ra AST truy vấn, rồi chỉ đến cuối cùng mới compile thành SQL
Typegres mà tôi đang phát triển cũng chính là ý tưởng đó. Nếu bạn thấy mẫu này thú vị thì có thể tham khảo
Vấn đề cốt lõi của thư viện RPC là chúng cố che giấu việc round-trip xảy ra ở đâu và như thế nào
Chỉ nhìn
.map()của mảng trong Cap'n Web thôi cũng khó biết thực sự network round-trip xảy ra ở đâu.Tôi nghĩ đây không phải là một “tính năng” mà ngược lại là một “lỗi” — nhìn vào code thì đáng lẽ phải hiểu ngay hành vi, việc che khuất điều đó là không tốt
liên kết tham khảo
awaitpromise pipelining cho phép bạn thiết lập nhiều statement liên tiếp mà không cần
await, nên sẽ không có thêm vòng gọi mạng nào ở giữa. Chỉ cầnawaitmột lần ở cuối là xongNếu từng dùng gRPC với web thì bạn sẽ biết việc áp Protobuf lên web đau đớn đến mức nào
Tôi thực sự thích sự đơn giản của Cap'n Web tài liệu capnproto
Khác với Cap'n Proto, Cap'n Web hoàn toàn không có schema. Gần như không có boilerplate thừa, nên nó mang cảm giác rất giống RPC JavaScript native của Cloudflare Workers
tham khảo github
Tôi vừa thấy thư viện mới của kentonv nên chạy vào ngay
Nhìn mã trên GitHub, tôi ngạc nhiên vì quy mô của nó nhỏ đến không ngờ. Không biết có đúng là chỉ chừng đó thôi không
Về lý thuyết, có vẻ việc port phần server-side sang ngôn ngữ khác cũng không quá khó; tôi muốn thử dùng nó với server Elixir và frontend JS/TS
Cũng thú vị nếu để LLM làm việc port ngôn ngữ kiểu này. Không biết repo này có dùng mã do LLM tạo hay không. Tôi nhớ mấy tháng trước từng thấy kentonv nói về một POC do AI tạo ra (đã có người review)
Ở thời điểm hiện tại, có lẽ LLM vẫn khó mà tạo ra được thư viện này. Cấu trúc bên trong của nó được thiết kế như một câu đố với nhiều mảnh ghép khớp với nhau rất tinh vi
Thời gian dành cho việc suy nghĩ thiết kế còn nhiều hơn cả thời gian viết code thực tế
Nó hoàn toàn khác với thư viện
workers-oauth-provider, vốn là một cách triển khai mới mẻ của một spec đã quen thuộcCấu trúc mã có thể dễ port sang các ngôn ngữ động như Python, nhưng tôi nghĩ sẽ khó hơn nhiều với ngôn ngữ kiểu tĩnh. Nó phụ thuộc mạnh vào các kiểu đối tượng tùy ý
Có những điểm giống OCapN và cũng có khác biệt quan trọng tham khảo
Cả hai đều hỗ trợ capability transfer, promise pipelining và mô hình không schema
Cap'n Web không có capability ngoài băng như
sturdyrefcủa OCapN (URI có thể khôi phục). Vì vậy tôi đoán nó cần xác thực bằng API key.sturdyreflà một loại token không thể đoán được; chỉ cần sở hữu nó là có quyền truy cập endpoint tương ứngNgoài ra, Cap'n Web cũng không có chức năng handoff ba bên, nơi Alice giới thiệu Bob cho Carol. Đây là thứ thiết yếu với ứng dụng phân tán, nên Cap'n Web có vẻ gần hơn với dịch vụ kiểu client-server SaaS truyền thống nhưng có thêm vài đặc tính của ocap
Với SturdyRef, tôi nghĩ cách khôi phục sẽ khác nhau theo từng nền tảng, nên hợp lý hơn là triển khai theo từng nền tảng thay vì đưa vào cấp độ giao thức RPC
Ví dụ, trên Cloudflare Workers, sắp tới sẽ có khả năng lưu bền capability trong Durable Object storage, nhưng cách triển khai lại rất đặc thù cho nền tảng Workers
Sandstorm cũng có persistent capability, nhưng chỉ trong phạm vi dịch vụ nội bộ
Vì vậy mà Cap’n Proto đã bỏ hẳn khái niệm persistent capability, và trong chuẩn web thì khái niệm gần nhất có lẽ là OAuth
Ta có thể tưởng tượng ra một sturdyref dựa trên OAuth refresh token, nhưng đó không phải thứ có thể dùng chung trên mọi nền tảng
Theo như tôi xem nhanh, hệ thống này dường như yêu cầu (hoặc khuyến khích) lưu state của import/export table hay trạng thái đối tượng ở phía server theo kiểu stateful
Trong RPC truyền thống, mọi lời gọi đều đi vào từ tầng trên cùng và mỗi lời gọi đều mang theo key v.v., nên dù yêu cầu được phân tán qua nhiều server cũng không sao; còn Cap’n Web thì không như vậy
Tôi muốn biết liệu có thể serialize bảng đó để lưu vào DB rồi vẫn scale-out server theo cùng cách hay không, hay là bắt buộc phải có server affinity hoặc cấu trúc như Durable Objects
Trạng thái chỉ được duy trì trong đúng một phiên RPC
Nếu dùng WebSocket, trạng thái tồn tại chừng nào kết nối WebSocket còn sống
Nếu dùng truyền HTTP batch, phiên chỉ giới hạn trong toàn bộ một request HTTP, và mọi lời gọi sẽ được xử lý cùng một lần trong request đó
Vì vậy Cap’n Web không cần duy trì trạng thái qua nhiều request/kết nối HTTP khác nhau
Tuy vậy, nếu thiết kế của bạn khiến việc phiên bị ngắt đồng nghĩa mất toàn bộ capability, thì đó là thiết kế nên tránh. Hãy đảm bảo luôn có thể khôi phục capability sau khi reset kết nối
Đọc tài liệu thì có vẻ cấu trúc này dùng websocket để giữ affinity
HTTP batching là cách gửi toàn bộ yêu cầu trong một lần rồi chờ phản hồi
Kiểu này sẽ khiến load balancing trở nên khó hơn. Nếu có nhiều chat client, kết nối có thể dồn vào một số server nhất định, làm tăng nguy cơ quá tải ở các server đó
Việc scale in/out server cũng trở nên phiền phức. Khi vừa phải giữ kết nối dài hạn vừa phải xử lý nhiều request đồng thời, việc quản lý sẽ rất khó
Thêm một điều nữa, nếu client cứ liên tục gửi push event mà không bao giờ nhận response, server sẽ phải giữ các response đó trong bộ nhớ, nên tôi nghĩ rất dễ trở thành mục tiêu tấn công DDoS
Theo những gì tôi từng đọc trong tài liệu Cap'n Proto, server và client có thể trao đổi peer stub cho nhau
Nếu server C nhận được từ client B một stub được tạo ở A, thì C cũng có thể gọi trực tiếp A
“RPC” ban đầu là một mô hình lập trình cố làm cho lời gọi từ xa trông như không thể phân biệt với lời gọi hàm nội bộ
Để làm được vậy, trên thực tế cần có wire protocol, thư viện client/server, v.v.
Gần đây cách hiểu đã thay đổi nhiều, và mô hình phổ biến hơn là cấu trúc giống endpoint REST nhưng có function signature
Với sự xuất hiện của các tính năng ngôn ngữ như
Future,Optionalv.v., giờ đây có thể phân biệt rõ những đặc tính như “hành động này có thể bị trì hoãn” hoặc “có thể thất bại”Trong RPC đời đầu, tất cả những thuộc tính đó đều bị che giấu
Tôi không hiểu chính xác ý đó là gì. Lập trình bất đồng bộ có mặt trong rất nhiều ngôn ngữ. Tôi đã dùng gần như tất cả: JavaScript, C++, Python, Rust, C# v.v.
Ý chính là các hệ thống RPC thời kỳ đầu chặn luồng gọi trong lúc request mạng đang diễn ra, mà đó là thiết kế rất tệ, nên giờ việc bất đồng bộ đã trở thành điều hiển nhiên
Tôi rất hào hứng vì Cap'n Web không bị trói vào sản phẩm Cloudflare mà tồn tại độc lập
Khi đọc phần này trong tài liệu, tôi có một thắc mắc
Thậm chí tôi nghĩ Cap'n Web có thể đi trước worker RPC ở một số điểm (thực tế tính năng pipeline đã đi trước rồi)
Cấu trúc của Cap'n Web đơn giản hơn nhiều, nên các thử nghiệm tính năng mới có lẽ cũng sẽ được làm trước ở Cap'n Web