再见,Rust
Farewell, Rust for web

原始链接: https://yieldcode.blog/post/farewell-rust/

## 从 Pascal 到 Rust 再回到 Rust:一个 Web 开发之旅 我的编程之旅始于九年级时的 Pascal,随后很快深入学习 C,被其低级控制能力所吸引。在探索 PHP 和 C++ 之后,我攻读了软件工程学位,并最终在 PHP 中找到了一份 Web 开发工作——这与我热爱的 C 语言家族有所不同。 尽管我接受了 PHP、Python 和 Ruby 等动态语言用于 Web 开发,但我始终渴望 C 的精确性。Rust 出现后,提供了一种潜在的解决方案,它既具有低级控制能力,又具有现代工具。我成功地使用 Rust 构建并发布了一个产生收入的 Web 应用程序,但最终将其迁移回 Node.js。 虽然 Rust 在某些领域表现出色,但 Web 的动态特性带来了挑战。编译时间、模板安全性、本地化以及生态系统的成熟度对于独立开发者来说都存在问题。Node.js 尽管有一些缺点,但它提供了更快的迭代速度、更好的动态任务工具(如模板和 i18n),以及更成熟的 Web 聚焦生态系统。 最终,快速开发和维护的需求超过了 Rust 对于*这个*项目的优势。虽然我享受了 Rust 的体验并学到了宝贵的经验,但我已经回到了 Node.js,认识到它是我当前需求的更务实的选择。

一篇由名为“告别 Rust”的帖子引发的 Hacker News 讨论,突出了 Rust 的 Web 开发体验与 Node.js 和 TypeScript 等替代方案相比的挫败感。虽然 Rust 提供了强类型,但评论员指出使用像 `sqlx` 这样的 crate 进行动态 SQL 查询时存在挑战,需要数据库连接来进行编译时检查——`kysely` 在 Node.js 中无需此开销即可实现此功能。 一些用户提倡像 `sea-orm` 这样的 ORM 作为更好的 Rust 解决方案,赞扬其符合人体工程学的查询构建器以及活动记录和数据库请求之间的清晰区分。另一些人强调了 TypeScript/JavaScript 生态系统的工具优势(热重载、调试),以及 WASM 引入的摩擦,影响了 Blazor 等技术。 有趣的是,一位评论员指出使用 AI(Claude Code)来*生成* Rust 代码,然后将其转换为 WebAssembly 的成功工作流程,暗示了 Rust 在这种情况下可能扮演的未来角色。
相关文章

原文

Prelude

When I was in 9th grade — last year before high-school — my best friend persuaded me to join, together with him, the schools programming club. At first, I hesitated, but later agreed. I am immensely thankful to him for this.

There, step by step, we learned Pascal using Turbo Pascal. Little by little, we grasped the basics of the language: variables, operators, string manipulations, data structures — until eventually, remaking Conway’s Game of Life. And then, summer break came. Other than HTML, which is not a programming language, Pascal was the first real programming language I have learned and used.

But I did not stay with Pascal for very long time. After the summer break, in high-school, I have chosen the “Software Engineering” branch of studying, and there we learned C. Just like Pascal, we started step by step: basic operators, string manipulations, memory allocation, data structures, and the final boss, the void *.

I fell in love with C. The precise control over memory; passing variables by reference or pointer; the need to allocate memory for every data structure that you wanted to create.

After 3 more years of studying, I have graduated high-school. In that time, I have improved my C skills, learned some PHP, picked up C++, and tried to build a variety of programs (don’t judge me too hard, these are ~20 years old): a clone of BattleCity, 3D software renderer, IRC bot, unfinished operating system kernel, unfinished game engine, TTF renderer for OpenGL.

After high-school, I have enrolled in a two-year college program that would earn me a Practical Software Engineering Degree. During the summer break between the first and the seconds years, I had a choice to make: go work in McDonald’s (I had experience working as a waiter during high school summer breaks), or find a job in software engineering. After 2 seconds of hesitation, I have crafted a CV and started to send it to every position I saw online. And despite the fact that I really wanted to get a software development position in C or C++, nobody would hire me. Eventually, I have secured a web-development position in PHP (thank you very much my first employer for giving me a chance).

And this would be the last time I’d touch C or C++. Dynamic, high-level languages such as PHP, Python, and Ruby — are more suited to the dynamic nature of web development. You rarely need to squeeze the maximum performance from your hardware, since for every second you gain by optimizing data structures allocations in C, you lose 10x more waiting for network or disk requests to resolve. And so, collectively, we all agreed that the web is better to be written in dynamic languages.

But just like your first true-love, C would hunt me. I would obsess about micro-optimizations, and would get mad that I can’t control when variables are allocated, and how to pass things by reference or pointer without making a redundant copy. And then, Rust became a thing.

Web development in Rust

Just like every software engineer, I started learning Rust by building my own game engine. Do you even call yourself a software engineer if you never tried to build your own game engine? And I fell in-love with Rust. C was aging, and failed to catch up with the moving world of hipster development: modern tooling, linters, formatters, package management. Rust offered the best of both worlds: low-level control of memory allocation, and variable life-cycle, while having modern development practices: a simple-to-use compiler, built-in linter and formatter, and of course a modern package management tool and repository.

The community was loving Rust, and the language was growing very fast. And for the first time you could write a web application using a low-level systems-programming language. 1 After picking up the basics of the language, and abandoning my game engine, I have decided to build a web application in Rust. And, in the end of 2023, I have succeeded. I have built, and shipped a fully operational web application that was making me money, in Rust.

Throughout the years, I learned from my mistakes, and shared my progress here, here, and here. Eventually, the application became a Frankensteins’ monster were the backend would be pure Rust, while the frontend was a statically generated Astro website. Until, in the end, I have reached a road-block and could no longer maintain or extend the application.

I took a painful decision to migrate everything to Node.js, and conclude this experiment with Rust. It was hard for me to make this decision. I really loved working with Rust, and I wanted to make it work. My work with Rust earned me valuable human connections, and opportunities to present my knowledge at two conferences, and one meetup. I am happy that I have took the risk with Rust, but…

Farewell Rust, at least for now…

I’m not going to go over the positive sides of Rust, since I covered them in other articles such as Building a web app in Rust and One year of Rust in production. But I do want to talk about some of the struggles I had, both as a reminder to my future self (you can take C from me, but you can’t take my love to low-level programming), and as a cautionary tale to everyone else.

Keep in mind that everything I share is based on my perspective: I’m a solo founder, building and running a web application. There is no heavy workload like video processing, nor a need to have low latency — two things at which Rust excels. From my experience, and my product, the bottleneck is always the database, disk, or network. I’m also, somewhat, experienced in Rust, so I’m not the “I tried to build a web app in Rust, but I don’t understand borrow checker, so Rust bad” kind of guy. Now, with that out of the way, let’s begin.

Templating

As I mentioned earlier, from pure Rust + server side generated templates with tera, I have switched to a Rust API server + Astro static website that calls the API. The reason I moved away from rendering HTML in Rust, is the fact that I am too spoiled with type-safe templates. With Astro, I can use the .astro components that are type-safe. Since I use, and advocate for, Typescript, all my templates are type-safe, and there is very little chance to mistype a variable, or miss it. If you have a component like this, it’s very hard to make it compile with the wrong type (unless you do stupid stuff like props as any):

interface Props {
    username: string;
    email: string;
}

export function UserCard(props: Props) {
    return (
        <div>
            Hello {props.username} ({props.email})
        </div>
    );
}

With libraries like tera, handlebars, or mrml — your view is essentially separated from the props, and if you rename a field in the template, you must remember to rename it in the model as well. It’s possible to solve it with tests at the cost of longer CI/CD pipeline, but I will talk about compilation time a bit later. Functions inside templates are basically wild-west of strings. And you need functions. It’s hard to make rich templates without functions.

But there are libraries like maud or askama — I hear you say. And I agree, there are. These are essentially type-safe HTML templates that use the macro mechanism from Rust. By building, basically, HTML DSL using Rust macro, you can create compile time, type-safe templates. But then again, compilation is expensive, but more on that later.

Localization and Internationalization

I have been talking about localization back in 2015. Node.js ships with full icu support, and a set of Intl.* APIs to format numbers, lists, currencies, country names, you name it. On top of that, with libraries like i18next, one can get type-safe auto-complete for translations, something that I was not able to achieve in Rust. Yes, Rust has support for fluent translation files developed by Mozilla, and a minimal support for number formatting. But Node.js has everything you need in order to build and ship fully localized and translated web-applications. It’s a know fact that i18n is lacking in Rust (see: AWWY: Internationalization), and bindings for icu4c are in progress, but they are nowhere near what Node.js can offer it that regard.

The web is dynamic by nature

Take it or leave it, but the web is dynamic by nature. Most of the work is serializing and deserializing data between different systems, be it a database, Redis, external APIs, or template engines. Rust has one of the best (de)serialization libraries in my opinion: serde. And yet, due to the nature of safety in Rust, I’d find myself writing boilerplate code just to avoid calling .unwrap(). I’d get long chain calls of .ok_or followed by .map_err. I defined a dozen of custom error enums, some taking other enums, because you want to be able to handle errors properly, and your functions can’t just return any error.

Similar thing can be said about writing SQL. I was really happy with using sqlx, which is a crate for compile-time checked SQL queries. By relying on macros in Rust, sqlx would execute the query against a real database instance in order to make sure that your query is valid, and the mappings are correct. However, writing dynamic queries with sqlx is a PITA, as you can’t build a dynamic string and make sure it’s checked during compilation, so you have to resort to using non-checked SQL queries. And honestly, with kysely in Node.js, I can get a similar result, without the need to have a connection to the DB, while having ergonomic query builder to build dynamic queries, without the overhead of compilation time.

Okay, I hear you, let’s talk about compilation time.

Compilation time

Rust achieves its safety at the expense of, somewhat long, compilation time. On modern hardware, the compilation time is not that bad. It’s not good enough to have uninterrupted change-f5-preview cycle, but it’s good enough especially with incremental compilation.

However, the more crates you have, the more macros you use, the slower the compilation time becomes. So the problem becomes is how to achieve fast compilation during CI/CD. Due to the dynamic nature of the web, you often times find yourself in a loop of: there is an error in production → fix it → deploy. And you want this loop to be as fast as possible, because you have customers who can’t do things with your app.

I have my own hardware to run CI/CD workers. A dedicated VM on an Intel Core i5-7500 with 32GB of RAM. The VM has access to all 4 CPU cores, and would take about 14 minutes from push to master until the docker container was deployed on the server (about 12 of it is the docker stage with compilation). That’s with multistaged docker file, and a cached builder layer; with no cache, it would probably take ~20-25 minutes. And it does not include running tests or clippy as part of the CI/CD. I simply gave-up trying to set-up proper caching so tests, clippy, and compilation would be able to use the same cache.

Node.js, on the other hand, takes on average 5 minutes, including linting, and tests. And since I moved to Node.js, I added another back-office service, so I deploy more code, 3 times faster, while actually relying on running tests and lint as part of the CI/CD pipeline.

Ecosystem maturity

Rust has a very mature ecosystem in most aspects, but it is lacking in the web aspect. Need an obscure third party API? It’s probably not there. So you end up implementing APIs instead of doing core business logic. Each API is additional code that you need to test and maintain.

It’s not hard to implement REST APIs, but the lack of maturity does slow you down. I had to implement, and test, third party APIs, queue mechanism on top of PostgreSQL, code to validate webhook signatures. It was fun, but it comes out of the box in other, “standard”, languages.

Node.js is good enough

And honestly, Node.js is good enough. People like to criticize Node.js or the npm ecosystem, implying that there is something fundamentally wrong with JavaScript. And sure, JavaScript is far from being a good language. There are many quirks in the language, which you can learn only with experience, pain, and tears. And I miss stuff from Rust like the Result and Option types; I miss the match statement; and I miss the enum as well. But in all honesty, Node.js ecosystem has matured and is stable to write web applications. You have libraries like zod to validate request/response JSONs, libraries like kysely, @kitajs/html, and my own @mjmx/core to write type-safe SQL, HTML, and MJML. And async/await is still better in Node.js than in Rust. It’s always weird to need to bring thirds party async-trait crate because you want to have an async method in a trait.

There are still issues with Node.js. The never-ending deprecation of cjs in favor of esm; it’s mostly good, until you encounter a cjs package, and then it’s hell. The cumbersome eslint and prettier setup where you need to juggle between 2 files and dozen of plugins to get basic functionality you get with clippy out-of-the-box; I wait for the day biome will close the gaps, and becomes the standard tool. Workspaces are still not solved, although pnpm is better in this regard, but it’s nowhere near the workspaces in cargo. And the occasional struggles with typescript where the runtime seems to be changing too often; is it ts-node? tsx? tsm? The built-in typescript runtime in node? deno? bun?

Oh, and I really wish we all would just agree that snake_case is simply the best, most aesthetically pleasing, and easy to read case for programming. I really hate the fact that JavaScript has adopted the camelCase.

Closing notes

Building solo is hard. You wear 10 hats as software engineer, in addition to the dozen hats as an entrepreneur. I really wanted Rust to succeed, but I also need to move forward. I found myself ignoring bugs in Sentry because it meant going back to long compile times. I was postponing feature development because it meant slow iteration speed, and trying to synchronize backend REST API with frontend, or the need to visually re-test every page after a variable rename in a template. Call me spoiled, but I don’t have the luxury of development team, and someone checking my code.

Rust excels at non-visual things, i.e. things you can write and wrap with tests. But when you need to develop UI, it becomes painful. You wait for the code the recompile; worried about passing the wrong variable to a view.

From pure Rust, I had to go to Rust with a statically generated HTML + Alpine on top of it. To make sure my email templates are safe, I was considering writing a dedicated mailing service in NodeJS, just so I could use react.email (or my own @mjmx/core), and get some type-safety. It’s as if I was moving backwards, breaking my Rust monolith and rewriting parts in a dynamic language that is better suited for the web.

No, Node.js is not perfect. But at least I have one stack, and funny enough, I get more type-safety than I had in Rust. Sure, it’s a fake type-safety, JavaScript is a dynamic language, but I no longer have to jump between my view files and my model and try to keep them in sync. I no longer misspell translation keys, resulting in blank words; emails do not come with Hello {{dearCustomer}}.

It seems like most of my issues boil down to dynamic things: templates, i18n, SQL. If I were to write an API service today, I would probably choose Rust again, since API services do not have to deal with views or translations. I still don’t know how I would handle SQL though. ORMs are not my cup of tea, and other than sqlx, Rust seems to lack a good type-safe query builder.

I also miss the small footprint of the application. With Rust, the containers would use between 60 and 80 MB of RAM; but with Node.js, the lowest I have is 117 MB of RAM for the back-office, without any load. And it’s true what they say: use the right tool for the job. Rust shines in CPU-heavy tasks, and for sure I will be using it when I will have such tasks.

But until then, farewell Rust.

联系我们 contact @ memedata.com