• 用Rust进行web开发是一个好的选择吗?
  • 发布于 1个月前
  • 50 热度
    0 评论

Rust 经常被视为仅仅是一种系统编程语言,但实际上它是一种多用途的通用语言。像 Tauri(用于桌面应用)、Leptos(用于前端开发)和 Axum(用于后端开发)这样的项目表明 Rust 的用途远不止于系统编程。


当我开始学习 Rust 时,我构建了一个网页应用来练习。因为我主要是一名后端工程师,这是我最为熟悉的领域。很快我就意识到 Rust 非常适合做网页开发。它的特性让我有信心构建出可靠的应用程序。让我来解释一下为什么 Rust 是网页编程的理想选择。

「错误处理」
一段时间以前,我开始学习 Python,那时我被机器学习的热潮所吸引,甚至是在大型语言模型(LLM)热潮之前。我需要让机器学习模型可以被使用,因此我选择了编写一个 Django REST API。在 Django 中获取请求体,你可能会这样写代码:
class User(APIView):
    def post(self, request):
        body = request.data
这段代码大多数时候都能正常工作。然而,当我意外地发送了一个格式不正确的请求体时,它就不再起作用了。访问数据时抛出了异常,导致返回了 500 状态码的响应。我没有意识到这种访问可能会抛出异常,并且也没有明确的提示。

Rust 通过不抛出异常而是以 Result 形式返回错误作为值来处理这种情况。Result 同时包含值和错误,你必须处理错误才能访问该值。
// 堆代码 duidaima.com
let body: RequestBody = serde_json::from_slice(&requestData)?;
问号 (?) 表示你想在调用函数中处理错误,将错误向上一级传播。我认为任何将错误作为值来处理的语言都是正确处理错误的方式。这种方法允许你编写代码时避免出现意外的情况,就像 Python 示例中的那样。

「默认不可变性」
最近,我的一位同事在我们的一个开源项目上工作,他需要替换一个客户端库为另一个。这是他使用的代码:
newClient(
 WithHTTPClient(httpClient), // &http.Client{}
 WithEndpoint(config.ApiBasePath),
)
突然间,集成测试开始抛出竞态条件错误,他搞不清楚为什么会这样。他向我求助,我们一起追踪问题回到了这行代码。我们在其他客户端之间共享了这个HTTP客户端,这导致了错误的发生。多个协程在读取客户端,而 WithHttpClient 函数修改了客户端的状态。在同一资源上同时有读线程和写线程会导致未定义的行为或在 Go 语言中引发恐慌。

这又是一个令人不悦的意外。而在 Rust 中,所有变量默认是不可变的。如果你想修改一个变量,你需要显式地声明它,使用 mut 关键字。这有助于 API 客户端理解发生了什么,并避免了意外的修改。
fn with_httpclient(client: &mut reqwest::Client) {}
「宏」
在像 Java 和 Python 这样的语言中,你们有注解;而在 Rust 中,我们使用宏。注解可以在某些环境下如 Spring 中带来优雅的代码,其中大量的幕后工作是通过反射完成的。虽然 Rust 的宏提供的“魔法”较少,但也同样能产生更清晰的代码。这里有一个 Rust 宏的例子:
sqlx::query_as!(Student, "DELETE FROM student WHERE id = ? RETURNING *", id)
Rust 中的宏会在后台生成代码,编译器在构建过程中会检查这些代码的正确性。通过宏,你甚至可以在编译时扩展编译器检查并验证 SQL 查询,方法是在编译期间生成运行查询的真实数据库上的代码。这种能够在编译时检查代码正确性的能力开辟了新的可能性,特别是在 web 开发中,我们经常编写原始的数据库语句或 HTML 和 CSS 代码。它帮助我们写出更少 bug 的代码。

这里提到的宏被称为声明式宏。Rust 还有过程式宏,它们更类似于其他语言中的注解。
#[instrument(name = "UserRepository::begin")]
pub async fn begin(&self) {}
核心思想保持不变:在后台生成代码,并在方法调用前后执行一些逻辑,从而确保代码更加健壮和易于维护。

「Chaining」
来看看这段在 Rust 中优雅的代码:
let key_value = request.into_inner()
                 .key_value
                 .ok_or_else(|| ServerError::InvalidArgument("key_value must be set".to_string()))?;
与这种更为冗长的方法相比:
Optional<KeyValue> keyValueOpt = request.getInner().getKeyValue();
if (!keyValueOpt.isPresent()) {
    throw new IllegalArgumentException("key_value must be set");
}
KeyValue keyValue = keyValueOpt.get();
在 Rust 中,我们可以将操作链接在一起,从而得到简洁且易读的代码。但是,为了实现这种流畅的语法,我们通常需要实现诸如 From 这样的特质。功能性技术大佬们可能会认识并欣赏这种方法,他们有这样的见解是有道理的。我认为任何允许混合函数式和过程式编程的语言都是走在正确的道路上。它为开发者提供了灵活性,让他们可以选择最适合其特定应用场景的方式。

「线程安全」
这里有没有人曾经因为竞态条件而在生产环境中触发过程序崩溃?我羞愧地承认,我有过这样的经历。是的,这是一个技能问题。当你在启动多个线程的同时对同一内存地址进行修改和读取时,很难不去注意到这个问题。但让我们考虑这样一个例子:
type repo struct {
 m map[int]int
}

func (r *repo) Create(i int) {
 r.m[i] = i
}

type Server struct {
 repo *repo
}

func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
 s.repo.Create(1)
}
没有显式启动任何线程,乍一看,一切似乎都很好。然而实际上,HTTP 服务器是在多个线程上运行的,这些线程被抽象隐藏了起来。在 web 开发中,这种抽象可能会掩盖与多线程相关的潜在问题。现在,让我们用 Rust 实现相同的功能:
struct repo {
    m: std::collections::HashMap<i8, i8>
}

#[post("/maps")]
async fn crate_entry(r: web::Data<repo>) -> HttpResponse {
    r.m.insert(1, 2);
    HttpResponse::Ok().json(MessageResponse {
        message: "good".to_string(),
    })
}
当我们尝试编译这个程序时,Rust 编译器将会抛出一个错误:
error[E0596]: cannot 
borrow data in an 
`Arc` as mutable                                                                                                                                             
   --> src\main.rs:117:5
    |
117 |     r.m.insert(1, 2);
    |     ^^^ cannot borrow as mutable
    |
    = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<repo>`
很多人说 Rust 的错误信息通常是很有帮助的,这通常是正确的。然而,在这种情况下,错误信息可能会让人感到困惑并且不是立即就能明白。幸运的是,如果知道如何解决,修复方法很简单:只需要添加一个小的互斥锁:
struct repo {
    m: HashMap<i8, i8>
}

#[post("/maps")]
async fn create_entry(r: web::Data<Mutex<repo>>) -> HttpResponse {
    let mut map = r.lock().await();
    map.m.insert(1, 2);
    HttpResponse::Ok().json(MessageResponse {
        message: "good".to_string(),
    })
}
确实非常美妙,编译器能够帮助我们避免这些问题,让我们的代码保持安全和可靠。

「空指针解引用」
大多数人认为这个问题只存在于 C 语言中,但你也会在像 Java 或 Go 这样的语言中遇到它。这里是一个典型问题的例子:
type valueObject struct {
 value *int
}

func getValue(vo *valueObject) int  {
 return *vo.value
}
你可能会说,“在使用值之前检查它是否为 nil 就好了。”这是 Go 语言中最大的陷阱之一 —— 它的指针机制。有时候我们会优化内存分配,有时候我们使用指针来表示可选值。
空指针解引用的风险在处理接口时尤其明显。
type Repository interface {
 Get() int
}

func getValue(r Repository) int {
 return r.Get()
}

func main() {
 getValue(nil)
}
在许多语言中,将空值作为接口的有效选项传递是可以的。虽然代码审查通常会发现这类问题,但我还是见过一些空接口进入开发阶段的情况。在 Rust 中,这类问题是不可能发生的,这是对我们错误的另一层保护:
trait Repository {
    fn get(&self) -> i32;
}

fn get_value(r: impl Repository) -> i32 {
    r.get()
}

fn main() {
    get_value(std::ptr::null());
}
「更不用说这段代码根本无法编译。」
我承认,我是端口和适配器模式的大粉丝,这些模式包括了一些抽象概念。根据复杂度的不同,这些抽象可能是必要的,以便在你的应用程序中创建清晰的边界,从长远来看提高单元测试性和可维护性。批评者的一个论点是性能会下降,因为通常需要动态调度,因为在编译时无法确定具体的接口实现。让我们来看一个 Java 的例子:
@Service
public class StudentServiceImpl implements StudentService {

    private final StudentRepository studentRepository;

    @Autowired
    public StudentServiceImpl(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }
}
Spring 为我们处理了很多幕后的事务。其中一个特性就是使用 @Autowired 注解来进行依赖注入。当应用程序启动时,Spring 会进行类路径扫描和反射。然而,这种便利性却伴随着性能成本。在 Rust 中,我们可以创建这些清晰的抽象而不付出性能代价,这得益于所谓的零成本抽象:
struct ServiceImpl<T: Repository> {
    repo: T,
}

trait Service{}

fn new_service<T: Repository>(repo: T) -> impl Service {
    ServiceImpl { repo: repo }
}
这些抽象在编译时就被处理好,确保在运行时不会有任何性能开销。这使我们能够在不牺牲性能的情况下保持代码的整洁和高效。

「数据转换」
在企业级应用中,我们经常使用端口和适配器模式来处理复杂的业务需求。这种模式涉及将数据转换成不同层次所需的表示形式。我们可能通过异步通信接收到用户数据作为事件,或者通过同步通信接收到用户数据作为请求。然后,这些数据被转换成领域模型并通过服务和适配器层进行传递。这就提出了一个问题:数据转换的逻辑应该放在哪里?应该放在领域包中吗?还是放在数据映射所在的包中?我们应该如何调用方法来转换数据?这些问题常常导致整个代码库中出现不一致性。

Rust 在提供一种清晰的方式来处理数据转换方面表现出色,使用 From 特质。如果我们需要将数据从适配器转换到领域,我们只需在适配器中实现 From 特质:
impl From<UserRequest> for domain::DomainUser {
    fn from(user: UserRequest) -> Self {
        domain::DomainUser {}
    }
}

impl From<domain::DomainUser> for UserResponse {
    fn from(user: domain::DomainUser) -> Self {
        UserResponse {}
    }
}

fn create_user(user: UserRequest) -> Result<()> {
    let domain_user = domain::upsert_user(user.into());
    send_response(domain_user.into())?;
    Ok(())
}
通过在需要的地方实现 From 特质,Rust 提供了一种一致且直接的方式来处理数据转换,减少了不一致性,并使代码库更加易于维护。

「性能」
当然,Rust 很快这一点毋庸置疑,但它实际上给我们带来了哪些好处呢?我记得第一次将我的 Django 应用部署到 Kubernetes 上,并使用 kubectl top pods 命令来检查 CPU 和内存使用情况的时候。我很震惊地发现,即使没有任何负载,这个应用也几乎占用了 1GB 的 RAM。Java 也没好到哪里去。后来我发现了像 Rust 和 Go 这样的新语言,意识到事情可以做得更高效。

我查找了一些性能和资源使用方面的基准测试,并发现使用能够高效利用资源的语言可以节省很多成本。这里有一个例子:

想象一下,有一个 Lambda 函数被创建用来列出 AWS 账户中的所有存储桶,并确定每个存储桶所在的区域。你可能会认为,进行一些 REST API 调用并使用 for 循环在性能上不会有太大的区别。任何语言都应该能够合理地处理这个任务,对吧?然而,测试显示 Rust 在执行这项任务时比 Python 快得多,并且使用更少的内存来达到这些执行时间。事实上,他们每百万次调用节省了 6 美元。

来自 web 和 Kubernetes 的背景,在那里我们根据用户负载进行扩缩容,我可以确认高效的资源使用能够节省成本并提高系统的可靠性。每个副本使用较少的资源意味着更多的容器可以装入一个节点。如果每个副本能够处理更多的请求,则总体上需要的副本数量就会减少。高性能和高效的资源利用对于构建成本效益高且可靠的系统至关重要。

我已经在 web 开发领域使用 Rust 三年了,对此我非常满意。那些具有挑战性的方面,比如编写异步代码或宏,都被我们使用的库很好地处理了。例如,如果你研究过 Tokio 库,你会知道它可能相当复杂。但在 web 开发中,我们专注于业务逻辑并与外部系统(如数据库或消息队列)交互,我们得以享受更简单的一面,同时受益于 Rust 出色的安全特性。

试试 Rust 吧;你可能会喜欢它,甚至成为一名更好的程序员。
用户评论