Những gì chúng tôi đã học được khi xây dựng Rust Runtime cho TypeScript
Frontend·Hacker News·0 lượt xem

Những gì chúng tôi đã học được khi xây dựng Rust Runtime cho TypeScript

What We Learned Building a Rust Runtime for TypeScript

Encore đã xây dựng thời gian chạy Rust tùy chỉnh để hỗ trợ các ứng dụng phụ trợ TypeScript. 67K dòng Rust xử lý toàn bộ vòng đời yêu cầu HTTP, truy vấn cơ sở dữ liệu, pub/sub, theo dõi và kết nối với Node.js thông qua NAPI. Đây là những gì chúng tôi đã học được.

Encore bắt đầu dưới dạng khung Go với thời gian chạy Go, Go CLI, trình phân tích cú pháp Go và trình biên dịch Go. Khi chúng tôi quyết định hỗ trợ TypeScript, lựa chọn đơn giản nhất có thể là viết cả thời gian chạy trong TypeScript hoặc kéo dài thời gian chạy Go bằng một loại cầu nối nào đó, nhưng cuối cùng chúng tôi lại viết một thời gian chạy mới từ đầu trong Rust.

Có hai lý do cho điều này ngoài những gì nguyên mẫu xe sidecar Go đã cho chúng tôi thấy (thêm về điều đó dưới). Đầu tiên, chúng tôi biết mình muốn mở rộng Encore sang nhiều ngôn ngữ hơn theo thời gian và chúng tôi đã thấy các dự án như PrismaPydantic sử dụng thành công lõi Rust với các liên kết tương ứng với Node.js và Python. Viết logic cốt lõi một lần trong Rust và liên kết nó với từng thời gian chạy ngôn ngữ có nghĩa là chúng tôi sẽ không triển khai lại việc xử lý cơ sở hạ tầng cho mọi ngôn ngữ mà chúng tôi thêm vào. Thứ hai, Node.js về cơ bản là đơn luồng. Bằng cách chuyển mọi thứ không phải logic nghiệp vụ vào Rust, vòng đời yêu cầu HTTP, quản lý kết nối cơ sở dữ liệu, pub/sub, theo dõi, tất cả đều chạy hoàn toàn đa luồng trên tokio. Đó là mức tăng hiệu suất mà bản thân Node.js không thể đạt được.

Hai năm và 67.000 dòng sau, thời gian chạy xử lý toàn bộ vòng đời yêu cầu HTTP (định tuyến, phân tích cú pháp và xác thực yêu cầu, tuần tự hóa phản hồi), tổng hợp và truy vấn kết nối cơ sở dữ liệu, pub/sub trên ba nhà cung cấp đám mây, theo dõi phân tán, thu thập số liệu, lưu trữ đối tượng, bộ nhớ đệm và cổng API được cung cấp bởi Pingora. Mã TypeScript mà ứng dụng của bạn chạy là logic nghiệp vụ và mọi thứ bên dưới nó là Rust. Bài đăng này trình bày những quyết định đã đưa chúng tôi đến đây, những vấn đề không rõ ràng đang xảy ra và những điều chúng tôi sẽ làm khác đi.

Tại sao không kéo dài thời gian chạy Go

Thời gian chạy Go hoạt động tốt cho các ứng dụng Go và vẫn như vậy. Nó biên dịch thành ứng dụng nhị phân và xử lý các mối quan tâm về cơ sở hạ tầng ở cấp khung. Cách tiếp cận rõ ràng để hỗ trợ TypeScript là chạy thời gian chạy Go dưới dạng một quy trình phụ cùng với Node.js, trong đó cả hai giao tiếp qua IPC.

Chúng tôi đã tạo nguyên mẫu cho tính năng này và chi phí độ trễ của việc tuần tự hóa mọi truy vấn cơ sở dữ liệu, thông báo pub/sub và sự kiện theo dõi trên một ranh giới quy trình tăng lên nhanh chóng. Một yêu cầu API duy nhất chạm vào cơ sở dữ liệu và xuất bản một sự kiện sẽ vượt qua ranh giới IPC sáu hoặc bảy lần và trong các điểm chuẩn, phương pháp sidecar đã thêm 2-4 mili giây chi phí cho mỗi yêu cầu chỉ từ việc tuần tự hóa và chuyển đổi ngữ cảnh, trước khi bất kỳ công việc thực tế nào xảy ra.

Vấn đề còn lại là do hoạt động. Hai quy trình có nghĩa là hai thứ cần giám sát, hai thứ có thể gặp sự cố độc lập, hai bộ nhật ký để tương quan. Đối với hoạt động phát triển cục bộ, điều này có thể quản lý được nhưng trong quá trình sản xuất trên hàng chục dịch vụ, các chế độ lỗi sẽ nhân lên.

Vì vậy, thời gian chạy cần phải hoạt động trong cùng một quy trình với vòng lặp sự kiện Node.js, nghĩa là viết nó bằng C/C++ với liên kết N-API hoặc trong Rust với napi-rs. Rust đã mang lại cho chúng tôi những đảm bảo an toàn tương tự (an toàn bộ nhớ, an toàn luồng, không chạy đua dữ liệu) mà thời gian chạy Go có, cùng với quyền truy cập vào hệ sinh thái không đồng bộ (tokio) để xử lý hàng nghìn kết nối đồng thời mà không chặn vòng lặp sự kiện Node.js.

10.000 dòng đầu tiên

Hỗ trợ TypeScript đã được phát triển tăng dần trong vài tháng công việc nội bộ qua nhiều yêu cầu kéo. Bản phát hành công khai (#1073) đã vận chuyển ba thùng Rust cùng nhau: thời gian chạy lõi, liên kết NAPI JavaScript và trình phân tích cú pháp TypeScript. Khung này chỉ hoạt động khi tất cả các phần này đã sẵn sàng, vì vậy bản phát hành phải ở dạng nguyên tử ngay cả khi quá trình phát triển không diễn ra.

Thời gian chạy cốt lõi (runtimes/core) được cấu trúc như một tập hợp các trình quản lý, mỗi trình quản lý chịu trách nhiệm về một cơ sở hạ tầng mối quan ngại:

pub struct Runtime { API: api::Manager, // Vòng đời HTTP, định tuyến, xác thực sqldb: sqldb::Manager, // Kết nối cơ sở dữ liệu, gộp, truy vấn pubsub: pubsub::Manager, // Đăng ký và xuất bản chủ đề đối tượng: đối tượng::Trình quản lý, // Lưu trữ đối tượng (S3, GCS) số liệu: số liệu::Người quản lý, // Thu thập và xuất bí mật: bí mật::Người quản lý, // Truy xuất bí mật // ... }

Mỗi người quản lý khởi chạy một cách lười biếng từ hai cấu hình protobuf riêng biệt. Đầu tiên là siêu dữ liệu ứng dụng, siêu dữ liệu này mô tả chính hệ thống. Dữ liệu này được tạo tại thời điểm biên dịch bởi trình phân tích cú pháp TypeScript, trình này đọc mã ứng dụng của bạn và trích xuất các khai báo cơ sở hạ tầng:

// Siêu dữ liệu ứng dụng — mô tả đầy đủ về hệ thống. // Được tạo tại thời điểm biên dịch bởi trình phân tích cú pháp TypeScript/Go. tin nhắn Dữ liệu { chuỗi module_path = 1; lặp lại Dịch vụ svcs = 5; tùy chọn AuthHandler auth_handler = 6; lặp lại CronJob cron_jobs = 7; lặp lại PubSubTopic pubsub_topics = 9; lặp lại CacheCluster cache_clusters = 11; lặp lại SQLDatabase sql_database = 14; lặp lại Cổng cổng = 15; lặp lại Nhóm nhóm = 17; // ...

Thứ hai là cấu hình thời gian chạy, cho biết thời gian chạy thực tế như thế nào: triển khai nhà cung cấp đám mây nào sẽ sử dụng cho từng chủ đề pub/sub, cách xác thực, vị trí tồn tại của mỗi cụm cơ sở dữ liệu, khám phá dịch vụ cho các cuộc gọi giữa các dịch vụ và cài đặt khả năng quan sát. Điều này được tạo tại thời điểm triển khai dựa trên môi trường đích:

// Cấu hình thời gian chạy — cách định cấu hình thời gian chạy cho mỗi môi trường. // Được tạo tại thời điểm triển khai. thông báo RuntimeConfig { Môi trường môi trường = 1; // nhà cung cấp đám mây, loại env (dev/prod/test) Cơ sở hạ tầng hạ tầng = 2; // Cụm SQL, pub/sub, Redis, bí mật, nhóm Triển khai triển khai = 3; // dịch vụ khám phá, phương pháp xác thực, khả năng quan sát

Sự tách biệt quan trọng vì cùng một siêu dữ liệu ứng dụng có thể được triển khai tới các môi trường hoàn toàn khác nhau (phát triển cục bộ với NSQ và Docker Postgres, sản xuất với AWS SNS/SQS và RDS) chỉ bằng cách hoán đổi cấu hình thời gian chạy. Mã ứng dụng không thay đổi và siêu dữ liệu mô tả nó cũng vậy.

Làm cho Rust nói chuyện với JavaScript (và nhận lại câu trả lời)

NAPI (N-API) của Node.js được thiết kế để gọi mã gốc từ JavaScript: bạn đăng ký một hàm, JavaScript gọi nó, bạn làm việc, bạn trả về một mã gốc giá trị. Hướng khó hơn là gọi từ mã gốc sang JavaScript, đây là điều chúng ta cần khi có tin nhắn pub/sub đến và cần được gửi đến trình xử lý TypeScript hoặc khi yêu cầu HTTP cần gọi hàm điểm cuối TypeScript.

NAPI cung cấp các hàm an toàn luồng cho việc này và napi-rs bao bọc chúng một cách khéo léo. Vấn đề là tính trừu tượng tiêu chuẩn chỉ hỗ trợ gửi đối số tới JavaScript. Nó không hỗ trợ nắm bắt giá trị trả về. Khi trình xử lý tin nhắn pub/sub kết thúc quá trình xử lý, chúng tôi cần biết liệu nó thành công hay thất bại để chúng tôi có thể xác nhận hoặc ghi lại tin nhắn. Khi trình xử lý điểm cuối API trả về một phản hồi, chúng tôi cần phản hồi đó trở lại Rust để tuần tự hóa nó và gửi nó qua mạng.

Chúng tôi phân nhánh của napi-rs ThreadSafeFunction để cho phép gọi hàm JavaScript theo cách thủ công và ghi lại kết quả trả về của nó giá trị:

// Ngã ba của threadsafe_function từ napi-rs cho phép đang gọi // Hàm JS theo cách thủ công thay vì chỉ trả về các đối số. // Điều này cho phép chúng tôi sử dụng giá trị trả về của hàm. pub struct ThreadSafeCallContext<T: 'static> { pub env: Env, Giá trị pub: T, quán rượu gọi lại: Option<JsFunction>, }

Sự tinh tế còn lại là những lời hứa hẹn. Trình xử lý điểm cuối TypeScript là các hàm không đồng bộ trả về lời hứa. Khi chúng tôi gọi JavaScript và nhận lại một giá trị, chúng tôi cần phát hiện xem đó có phải là một lời hứa hay không và nếu vậy, hãy xâu chuỗi một lệnh gọi lại .then() để phân giải trở lại Rust thông qua kênh tokio. Điều này kết nối mô hình không đồng bộ của JavaScript với mô hình của Rust:

// Phát hiện xem giá trị trả về của JS có phải là Lời hứa hay không và nếu có, // chờ đợi bằng cách xâu chuỗi .then() phân giải thành kênh Rust fn await_promise(env: &Env, giá trị: JsUnknown) -> Kết quả<()> { Giá trị if.is_promise()? { // Chuỗi .then() với lệnh gọi lại gửi kết quả // qua kênh oneshot tokio quay lại phe Rust } }

Mối quan hệ giữa Node.js và Rust trong thiết lập này là cộng sinh chứ không phải là mối quan hệ sắp xếp chủ nhà/khách truyền thống. Node.js (hoặc Bun mà chúng tôi cũng hỗ trợ) bắt đầu quá trình và nhập thư viện Encore dưới dạng mô-đun gốc. Sau đó, thư viện sẽ đảm nhận các mối quan tâm về cơ sở hạ tầng: định tuyến, kết nối cơ sở dữ liệu, theo dõi, pub/sub. Node.js sở hữu vòng đời của quy trình trong khi Rust sở hữu lớp cơ sở hạ tầng.

Điều này tạo ra một trường hợp đặc biệt thú vị mà Fredrik gần đây đã giải quyết: Hợp đồng tương lai của Rust có thể bị hủy bất cứ lúc nào (ví dụ: khi hết thời gian chờ yêu cầu của Cloud Run đóng kết nối), nhưng trình xử lý JavaScript vẫn đang chạy trên vòng lặp sự kiện Node.js và không thể hủy được. Phía Rust sẽ không bao giờ đạt tới request_span_end, để lại dấu vết mà không có khoảng gốc. Bản sửa lỗi là CancellationGuard phát hiện thời điểm tương lai của nó bị loại bỏ và tạo ra một tác vụ tokio tách rời để chờ trình xử lý JavaScript hoàn thành:

/// Bộ phận bảo vệ đưa trình xử lý trở thành tác vụ nền trên hủy, /// đảm bảo `request_span_end` luôn được phát ra. Trên đường đi bình thường /// (trình xử lý hoàn tất trước khi hủy), đây là trường hợp không hoạt động. struct CancellationGuard<'a> { gọi: &'a mut HandlerCall, thông tin: Tùy chọn<CancellationGuardInfo>, }

Trên đường dẫn thông thường nơi trình xử lý hoàn thành trước khi hủy, trình bảo vệ không làm gì cả. Khi tương lai bị hủy bỏ giữa chuyến bay, quá trình triển khai Drop của người bảo vệ sẽ sở hữu HandlerCall trong chuyến bay và sinh nó làm tác vụ nền để phía JavaScript có thể hoàn thành một cách rõ ràng và khoảng theo dõi được đóng lại.

Nhúng cổng API với Pingora

Các ứng dụng Encore bao gồm nhiều dịch vụ giao tiếp qua mạng. Trong sản xuất, cổng định tuyến các yêu cầu bên ngoài đến dịch vụ phù hợp, xử lý xác thực, CORS và xác thực yêu cầu. Cách tiếp cận điển hình là chạy quy trình này như một quy trình riêng biệt, như nginx hoặc đặc phái viên ngồi trước các dịch vụ của bạn. Thay vào đó, chúng tôi đã nhúng trực tiếp vào thời gian chạy.

Chúng tôi sử dụng Pingora, thư viện proxy HTTP mã nguồn mở của Cloudflare, làm lớp cổng. Pingora được thiết kế để sử dụng như một thư viện thay vì một hệ nhị phân độc lập, đó chính xác là những gì chúng tôi cần. gateway triển khai đặc điểm ProxyHttp của Pingora và cắm vào quy trình tương tự như phần còn lại của thời gian chạy:

pub struct Gateway { bên trong: Cung<Bên trong>, } cấu trúc Bên trong { service_registry: Arc<ServiceRegistry>, bộ định tuyến: bộ định tuyến::Bộ định tuyến, cors_config: CorsHeadersConfig, // Đăng ký đẩy xuất bản/sub được ủy quyền qua cổng proxyed_push_subs: HashMap<Chuỗi, ProxiedPushSub>, // ... }

Cổng chia sẻ bộ nhớ với hệ thống xác thực, sổ đăng ký dịch vụ và trình thu thập dấu vết. Không có ranh giới tuần tự hóa giữa "proxy quyết định yêu cầu này được xác thực" và "trình xử lý điểm cuối chạy". Kết quả xác thực là cấu trúc Arc'd được chuyển trực tiếp đến trình xử lý.

Một ưu điểm quan trọng của phương pháp trong quá trình: trình xử lý xác thực do người dùng xác định được viết bằng TypeScript có thể thực thi trực tiếp bên trong cổng. Pingora gọi tới Node.js để chạy trình xử lý xác thực của bạn, nhận lại kết quả và yêu cầu tiếp tục đi qua cổng có ngữ cảnh xác thực được đính kèm. Điều này sẽ yêu cầu tuần tự hóa và IPC với một quy trình proxy riêng.

Pingora cũng cung cấp cho chúng tôi tính năng tổng hợp kết nối với các dịch vụ ngược dòng, hỗ trợ HTTP/2 và tiêu hao kết nối một cách duyên dáng trong quá trình triển khai, tất cả những thứ mà nếu không chúng tôi sẽ phải tự xây dựng hoặc bắt đầu từ bên ngoài.

Fun thực tế

Pingora không hỗ trợ Windows khi chúng tôi bắt đầu sử dụng nó và kể từ Encore cần chạy trên Windows để phát triển cục bộ, chúng tôi đã thêm hỗ trợ Windows cho Pingora và đóng góp nó ngược dòng. Vậy là hôm nay Pingora chạy được trên Windows nhờ Encore. 🙌

Tóm tắt ba nhà cung cấp đám mây không có tên chung

Hệ thống pub/sub cần hoạt động giống hệt nhau trên NSQ (phát triển địa phương), GCP Pub/SubAWS SNS+SQS. Cách tiếp cận đơn giản của Rust sẽ sử dụng nhiều generics, nhưng điều đó sẽ làm rò rỉ lựa chọn nhà cung cấp vào mọi chữ ký loại trong cơ sở mã. Thay vào đó, chúng tôi sử dụng các đối tượng đặc điểm:

trait Cluster: Debug + Gửi + Đồng bộ hóa { fn topic(&self, cfg: &PubSubTopic, nhà xuất bản_id: xid::Id) -> Arc<dyn Chủ đề + 'static>; fn subscription(&self, cfg: &PubSubSubscription, meta: &Subscription) -> Arc<dyn Đăng ký + 'static>; } đặc điểm Chủ đề: Gỡ lỗi + Gửi + Đồng bộ hóa { fn publish(&self, msg: MessageData, order_key: Tùy chọn<Chuỗi>) -> Ghim<Box<dyn Tương lai<Đầu ra = Kết quả<MessageId>> + Gửi + '_>>; } đặc điểm Đăng ký: Gỡ lỗi + Gửi + Đồng bộ hóa { fn subscribe(&self, người xử lý: Arc<SubHandler>) -> Ghim<Box<dyn Tương lai<Đầu ra = APIResult<()>> + Gửi + 'static>>; }

Ba đặc điểm, mỗi đặc điểm có ba cách triển khai. người quản lý chọn cách triển khai cụm phù hợp khi khởi động dựa trên cấu hình thời gian chạy và bao bọc mọi thứ trong Arc<dyn Trait> và phần còn lại của cơ sở mã không bao giờ biết hoặc quan tâm đến nhà cung cấp đám mây nào là bên dưới.

Mỗi nhà cung cấp đều có những đặc điểm riêng. NSQ sử dụng mô hình giống diễn viên với các kênh thông báo và vòng lặp nhà sản xuất do tokio sinh ra. GCP sử dụng tokio::sync::OnceCell để khởi tạo ứng dụng khách lười biếng, vì phương thức đặc điểm Cluster::topic() cần phải trả về một cách đồng bộ (người gọi không cần phải await để nhận tham chiếu chủ đề), nhưng việc tạo ứng dụng khách GCP là một thao tác không đồng bộ liên quan đến các cuộc gọi mạng để xác thực. OnceCell ẩn quá trình khởi tạo không đồng bộ đó đằng sau một giao diện đồng bộ, trì hoãn việc khởi tạo đó cho lần sử dụng đầu tiên. AWS SQS/SNS cần ID nhà xuất bản để sắp xếp thông báo FIFO mà các nhà cung cấp khác không quan tâm.

Tất cả những khác biệt này đều nằm trong các mô-đun tương ứng. Người quản lý nhìn thấy Arc<dyn Chủ đề> và gọi .publish(). Thực tế là một bên triển khai đang nói chuyện với daemon NSQ cục bộ qua TCP và một bên khác đang thực hiện các lệnh gọi HTTPS được xác thực tới AWS là vô hình. Bộ nhớ đối tượng tuân theo cùng một mẫu, với cách triển khai cho bộ nhớ tương thích với S3Google Cloud Storage.

Một giao thức theo dõi nhị phân tùy chỉnh

Mọi thao tác trong Encore ứng dụng được theo dõi tự động: lệnh gọi API, truy vấn cơ sở dữ liệu, xuất bản pub/sub, lệnh gọi HTTP tới các dịch vụ bên ngoài, hoạt động bộ đệm. Các dấu vết bao gồm thời gian, lồng ghép (truy vấn cơ sở dữ liệu nào xảy ra bên trong lệnh gọi API nào), nội dung yêu cầu/phản hồi và chi tiết lỗi. Có rất nhiều dữ liệu được tạo ra theo mỗi yêu cầu, vì vậy chúng tôi đã triển khai giao thức theo dõi nhị phân tùy chỉnh thay vì xây dựng thông báo protobuf và mã hóa chúng. EventBuffer là một trình tuần tự hóa được xây dựng có mục đích ghi các sự kiện theo dõi vào một bộ đệm byte liền kề với mã hóa số nguyên có độ dài thay đổi:

pub struct EventBuffer { vết xước: [u8; 10], bạn ơi: ByteMut, }

Bộ đệm đầu tránh phân bổ cho mã hóa varint. ID theo dõi và ID nhịp được ghi dưới dạng byte thô trên dây (lần lượt là 16 byte và 8 byte) thay vì chuỗi được mã hóa. Đây là cách tiếp cận tương tự mà OpenTelemetry sử dụng cho giao thức nhị phân của nó. Tổng hợp tiết kiệm trên hàng triệu dấu vết khi mỗi yêu cầu tạo ra hàng chục sự kiện nhịp.

Chúng tôi cũng cần phải tương quan thời gian đơn điệu (để đo thời lượng chính xác) với thời gian trên đồng hồ treo tường (để hiển thị). TimeAnchor ghi lại cả tokio::time::Instantchrono::DateTime tại cùng một thời điểm, sau đó mọi sự kiện tiếp theo chỉ ghi lại phần bù đơn điệu. Điều này tránh các vấn đề về độ lệch đồng hồ gây khó khăn cho các hệ thống theo dõi phân tán trong đó các sự kiện dường như xảy ra trước sự kiện gốc.

Việc lấy mẫu theo dõi có thể định cấu hình cho mỗi điểm cuối, mỗi dịch vụ và trên toàn cầu. Quyết định lấy mẫu xảy ra khi bắt đầu yêu cầu và lan truyền đến tất cả các nhịp con, do đó bạn không bao giờ nhận được một phần dấu vết. Một dấu vết mà bạn có thể thấy lệnh gọi API nhưng không thấy truy vấn cơ sở dữ liệu mà nó thực hiện còn tệ hơn là không có dấu vết nào cả, vì vậy đây là một yêu cầu khó khăn.

Phân tích cú pháp TypeScript từ Rust

Trình phân tích cú pháp TypeScript (tsparser) là một phần riêng biệt Thùng rỉ sét đọc mã nguồn TypeScript của ứng dụng của bạn và trích xuất các khai báo khung Encore: dịch vụ nào tồn tại, điểm cuối nào chúng hiển thị, cơ sở dữ liệu và chủ đề pub/sub nào chúng khai báo, các loại yêu cầu và phản hồi trông như thế nào.

Chúng tôi đã xây dựng phần mềm này dựa trên trình phân tích cú pháp TypeScript của SWC cho AST, sau đó viết các phân tích của riêng chúng tôi lên trên. Trình phân tích cú pháp cần giải quyết quá trình nhập, theo dõi tái xuất và hiểu rõ hệ thống kiểu của TypeScript để trích xuất hình dạng của các loại yêu cầu và phản hồi, bao gồm các loại chung, kết hợp và các loại được ánh xạ. Chúng tôi chưa hỗ trợ toàn bộ độ phức tạp của hệ thống loại của TypeScript nhưng chúng tôi đang liên tục mở rộng phạm vi (các bổ sung gần đây bao gồm ánh xạ lại khóa, chữ ký phương thức, chữ ký cuộc gọigiao lộ type).

Trình phân tích cú pháp là thứ giúp cơ sở hạ tầng từ mã hoạt động. Khi bạn viết new SQLDatabase("orders", { Migrations: "./migrations" }), trình phân tích cú pháp sẽ thấy phần khai báo đó, trích xuất tên cơ sở dữ liệu và đường dẫn di chuyển, đồng thời đưa nó vào protobuf siêu dữ liệu ứng dụng mà thời gian chạy đọc khi khởi động. Thời gian chạy không bao giờ tự phân tích cú pháp TypeScript mà chỉ nhận mô tả có cấu trúc của ứng dụng và tự định cấu hình tương ứng.

Trình phân tích cú pháp cũng là thứ cho phép máy chủ MCP, sơ đồ kiến ​​trúc và tạo tài liệu API vì tất cả những thứ đó đều sử dụng cùng một siêu dữ liệu mà trình phân tích cú pháp tạo ra.

Những gì chúng tôi sẽ làm khác biệt

Đầu tư vào bối cảnh lỗi sớm hơn. Khả năng xử lý lỗi của Rust rất xuất sắc ở cấp độ ngôn ngữ, nhưng ban đầu chúng tôi dựa quá nhiều vào dù sao đi nữa::Context một cách tổng quát thay vì xác định các loại lỗi cụ thể. Khi có lỗi xảy ra ở sâu ba lớp trong ngăn xếp pub/sub, "thông báo không xuất bản được" sẽ ít hữu ích hơn lỗi có cấu trúc bao gồm tên chủ đề, kích thước thông báo, nhà cung cấp và chế độ lỗi cụ thể. Chúng tôi đã dần dần trang bị thêm các loại lỗi tốt hơn.

Gửi bộ điều hợp OpenTelemetry sớm hơn. Định dạng theo dõi tùy chỉnh của chúng tôi thu thập được nhiều thông tin hơn mức được biểu thị đơn giản trong OpenTelemetry (tải trọng yêu cầu/phản hồi, biên tập tự động) và điều đó rất có giá trị. Nhưng khách hàng muốn xuất dấu vết sang ngăn xếp khả năng quan sát hiện có của họ ngay từ ngày đầu tiên và phản hồi "bạn không sử dụng tiêu chuẩn mở" là công bằng. Hiện tại, chúng tôi đang xây dựng bộ chuyển đổi OTel nhưng lẽ ra nên ưu tiên nó sớm hơn.

Tăng gấp đôi việc kiểm tra ảnh chụp nhanh. Chúng tôi sử dụng thử nghiệm ảnh chụp nhanh, đặc biệt là về phân tích cú pháp hệ thống loại TypeScript và đây là một trong những chiến lược thử nghiệm hiệu quả nhất trong cơ sở mã. Mỗi khi chúng tôi thêm hỗ trợ cho cấu trúc TypeScript mới, các thử nghiệm chụp nhanh sẽ phát hiện các hồi quy trên toàn bộ diện tích bề mặt. Lẽ ra chúng ta nên đầu tư nhiều hơn vào việc này ngay từ đầu.

Điều tra bằng cách sử dụng thời gian chạy Rust cho Go. Việc có một thời gian chạy duy nhất cho cả hai ngôn ngữ sẽ giảm đáng kể gánh nặng bảo trì. Nhưng FFI giữa Go và Rust khó hơn trường hợp Node.js vì cách thức hoạt động của trình thu gom rác của Go. Quyền sở hữu bộ nhớ ở ranh giới Go-Rust rất khó khăn khi Go có thể di chuyển các đối tượng trong GC và cgo có chi phí hoạt động riêng. Chúng tôi đã bắt đầu khám phá điều này nhưng nó không hề đơn giản.

Những con số

Thời gian chạy Rust xử lý hàng tỷ yêu cầu hàng ngày trong quá trình triển khai Encore Cloud, cùng với nhiều yêu cầu khác từ các công ty tự lưu trữ có lưu lượng truy cập mà chúng tôi không thấy.

Hiệu suất tác động của việc chuyển lớp cơ sở hạ tầng sang Rust là có thể đo lường được. Trong điểm chuẩn sử dụng 150 nhân viên đồng thời trong 10 giây (tốt nhất trong 5 lần chạy, tải được tạo bằng oha):

[[TAG_6 89]]107.018[[TA G_721]]—[[TAG_ 733]]—
FrameworkYêu cầu/giâyYêu cầu/giây (với xác thực)Độ trễ P99Độ trễ P99 (với xác thực)
Encore.ts121.0052,3 mili giây[[TAG_6 96]]3,6 mili giây
Bánh mì + Zod101.61133.772 3,7 mili giây14,9 mili giây
Elysia + TypeBox82.61735.124
Xin chào + TypeBox71.20233.150
Fastify + Ajv62.20748.3974 ,1 mili giây5,4 mili giây
Express + Zod15.70711.87811,9 mili giây[[ TAG_758]]18,2 mili giây

Encore.ts xử lý thông lượng gấp 9 lần Express.js với độ trễ thấp hơn 80%. Khoảng cách sẽ mở rộng khi kích hoạt xác thực vì Encore xác thực các yêu cầu ở lớp Rust bằng cách sử dụng thông tin loại mà trình phân tích cú pháp đã trích xuất, trong khi các khung khác chạy xác thực bằng JavaScript. Để biết thông tin chi tiết hơn về nguồn gốc của hiệu suất, hãy xem Encore.ts — nhanh hơn 9 lần so với Express.js.

Cơ sở mã là 67.077 dòng Rust trong thời gian chạy lõi, liên kết JavaScript, trình phân tích cú pháp TypeScript và trình giám sát quy trình. Thời gian chạy Go mà nó có là 42.629 dòng và vẫn được duy trì tích cực cho các ứng dụng Go. Chúng có chung định dạng cấu hình dựa trên protobuf nhưng về mặt khác là các cơ sở mã độc lập.

Thời gian chạy xử lý mọi thứ bên dưới lớp ứng dụng: chấp nhận kết nối, yêu cầu định tuyến, phân tích cú pháp và xác thực dữ liệu đầu vào, quản lý nhóm cơ sở dữ liệu, xuất bản thông báo, thu thập dấu vết, xuất số liệu. Mã TypeScript trong ứng dụng của bạn là logic nghiệp vụ thuần túy, không nhập express, định cấu hình các kết nối cơ sở dữ liệu hoặc thiết lập các ứng dụng tiêu dùng pub/sub. Nó tuyên bố cơ sở hạ tầng nào nó cần và 67.000 dòng Rust đã biến điều đó thành hiện thực.

Encore là nguồn mở. Mã thời gian chạy tồn tại trong repo chính trong runtimes/core (thời gian chạy Rust được chia sẻ), runtimes/js (Node.js các ràng buộc) và tsparser (Phân tích TypeScript). Nếu bạn muốn biết cách thức hoạt động của bất kỳ tính năng nào trong số này, mã sẽ có ở đó.

Tác giả: vinhnx

#discussion