用Rust写WebAssembly,解析RRule快了5倍

2,426 阅读11分钟

手撕RRule是什么体验?一个字,难;四个字,我太难了~

前言

为什么会产生自己写一个rrule的想法呢?现成的库,它不香么?

这得从一个巧合说起:某一天,我听说WebAssembly很快,又一天,我听说RustWebAssembly很快,再一天,我发现我们日历项目里,RRule解析重复规则很慢。于是一拍手,要不用rust写一个rrule然后打包成WebASsembly吧~。然后苦逼的日子就开始了。

项目已经发布到Npm@suilang/rrule,基础的解析能力已经实现。后续会陆续补齐如生成字符串、设置排除时间等能力,有兴趣的同学可以看下。

标准场景下,执行效率会比rrule.js快4到5倍,如果是加上时区,会快100倍左右。(最新的rrule.js对于时区的计算有些性能问题,不知道什么时候会修)。

RRule是什么

rrule是一个用于解析日历循环的工具,它遵循了 iCalendar 规范(RFC 5545)。iCalendar 是一种通用的日历数据交换格式,用于描述日历事件和重复规则。

rrule的主要用途是根据指定的重复规则生成符合规则的日期序列。它可以处理各种复杂的重复规则,例如每天、每周、每月、每年的重复,以及每隔一定时间间隔重复的规则。它还支持排除特定日期、指定重复次数等功能。

举个例子,假设我们有一个重复规则,要求每周一、周三和周五重复一次。我们可以使用 rrule.js 来实现这个规则:

import { RRule } from 'rrule';

const rule = new RRule({
  freq: RRule.WEEKLY,
  byweekday: [RRule.MO, RRule.WE, RRule.FR],
});

const eventDates = rule.all();

// 打印日期序列
eventDates.forEach((date) => {
  console.log(date);
});

在上述示例中,我们使用rrule.js创建了一个每周重复的规则,并指定重复的日期为周一、周三和周五。然后,我们使用rule.all()生成满足该规则的日期序列,并遍历打印出每个日期。

rrule同样支持使用字符串来描述重复规则,例如,我们想获取每年最后一个周五,可以使用

RRULE:FREQ=YEARLY;BYDAY=-1FR

rrule.jsrrule的 JavaScript 版本,适用于在浏览器端或 Node.js 环境中使用。它提供了与 Python 版本类似的功能,可以轻松处理复杂的重复规则,适用于构建日历应用程序、定期任务调度等场景。

逻辑实现

首先是重复规则,rrule支持7种重复参数,分别是年、月、周、日、时、分、秒。枚举定义如下所示。

pub enum Frequency {
    /// The recurrence occurs on a yearly basis.
    Yearly = 0,
    /// The recurrence occurs on a monthly basis.
    Monthly = 1,
    /// The recurrence occurs on a weekly basis.
    Weekly = 2,
    /// The recurrence occurs on a daily basis.
    Daily = 3,
    /// The recurrence occurs on an hourly basis.
    Hourly = 4,
    /// The recurrence occurs on a minutely basis.
    Minutely = 5,
    /// The recurrence occurs on a second basis.
    Secondly = 6,
}

在此基础上,还有8种基础控制参数(还有点其他的,但我没实现)

- INTERVAL // 控制重复间隔,正整数
- COUNT // 生成日期的个数
- UNTIL // 生成日期的终止时间
- BYDAY // 控制星期几 如MO,FR,3TU,-2WE
- BYMONTH // 指定月份
- BYMONTHDAY // 指定每月几号,支持正负
- BYYEARDAY // 指定每年第几天,支持正负
- BYWEEKNO // 指定是第几周

然后有趣的就来了,即使在重复周期为DAILY的情况下,所有的参数都是可用的,哪怕用了BYYEARDAY=1。当我首次发现这个事情时,我的心情无比糟糕。。。

另外,例如BYDAY=-1FR,如果是按年重复,代表着一年最后一个周五;如果在此基础上补充一个BYMONTH,则代表着指定月份最后一个周五;如果改成按月重复,代表着每月最后一个周五;如果按周重复,则代表着每个周五。非常的无语。。但是很灵活

具体参数的解析规则就不具体介绍了,下面以按日和月解析来讲讲如何解析重复规则。虽然看着很简洁,但是这是我花了5个完整天总结出来的。另外,我没有实现时、分、秒的解析,毕竟前面4个已经很复杂了。

这里有一些通用的规则:

  1. 必须设置开始时间,否则会取当前时刻
  2. 必须设置Freq
  3. countuntil至少设置一个
  4. 截止时间不能小于开始时间

按日解析

  1. 初始化rrule里的参数,初始化当前时间索引curr,存储列表list
  2. 准备while循环,判断curr是否大于until,并且list长度小于count,不符合则到第9步
  3. 此重复规则下,BYDAY将忽略正负数;如果BYDAY存在并且curr不满足BYDAY的需求,前进interval天,回到第二步
  4. 如果BYMONTH存在并且curr不满足BYMONTH,前进,并回到第二步
  5. 如果BYMONTHDAY存在,计算该月所有符合BYMONTHDAY的时刻,并判断curr是否符合其一。如果不符合,前进,并回到第二步
  6. 如果BYYEARDAY存在,计算该月所有符合BYYEARDAY的时刻,并判断curr是否符合其一。如果不符合,前进,并回到第二步
  7. 如果BYWEEKNO存在,计算该curr是否符合某个weekno。如果不符合,前进,并回到第二步
  8. curr放入列表,返回第二步
  9. 结束循环,并返回列表

按月获取

按周和按日其实是差不多的,就不具体讲解了,现在说说按月。

  1. 初始化rrule里的参数,初始化当前时间索引curr,存储列表list
  2. 如果没有任何控制参数,则直接取每月中,开始时间所在的日,按月递增,并返回对应列表
  3. 构造按月解析的闭包
    1. 初始化临时存储数组list
    2. 如果有指定月份并且当天不属于指定月份之一,直接返回空数组
    3. 如果指定了BYYEARDAY,获取所有符合条件的时间,存储到vec_by_year_day
      1. 如果vec_by_year_day为空,直接返回空数组,结束本月循环,
      2. 否则将数据存储到list
    4. 如果指定了BYMONTHDAY,获取所有符合条件的时间,存储到vec_by_month_day
      1. 如果vec_by_month_day为空,直接返回空数组,结束本月循环,
      2. 如果list不为空,则将listvec_by_month_day取交集,结果为空则直接返回
      3. list为空,则将vec_by_month_day数据存储到list
    5. 如果指定了BYWEEKNO,获取所有符合条件的时间,存储到vec_by_weekno
      1. 如果vec_by_weekno为空,则直接返回空数组,结束本月循环
      2. 如果list不为空,则将listvec_by_weekno取交集,结果为空则直接返回
      3. list为空,则将vec_by_weekno数据存储到list
    6. 如果指定了BYDAY,获取所有符合条件的时间,存储到vec_by_day
      1. 如果vec_by_day为空,则直接返回空数组,结束本月循环
      2. 如果list不为空,则将listvec_by_day取交集,结果为空则直接返回
      3. list为空,则将vec_by_day数据存储到list
    7. 对临时数组list进行排序,并返回
  4. 准备while循环,判断curr是否大于until,并且list长度小于count
  5. 传入当前时间curr到第三步的闭包中,获取返回值
  6. 将返回值放入list中,curr步进interval控制的月份,重新执行第4步
  7. 解析完成

rrule的控制参数,本质上是获取+筛选,即先获取符合条件的值,再与其他的值做交集,最后剩下的就是需要的。在具体实现的过程中,增加了一些判断,在特定场景下,可以直接结束循环以节省性能。

关于时区

在实现解析规则的过程中,我们一直没有介绍关于时区的事情。并不是忘了,其实在解析过程中,我一直没用到时区。

解析本质上是对如20231023这种格式的时间进行重复,已知年月日,已经够解析了。当我们按照一定规则获取到时间数组后,以年月日为参数,初始化一个带有时区的时间戳就够了。

性能

标准的速度验证还没来得及做,就简单验证了一下。标准场景下,执行效率会比rrule.js快4到5倍,如果是加上时区,会快100倍左右。(最新的rrule.js对于时区的计算有些性能问题,不知道什么时候会修)。

WebAssembly

在现代Web开发中,WebAssembly(简称Wasm)已经成为一个备受关注的技术。WebAssembly是一种可移植、体积小、加载快速的二进制格式,旨在提供一种高效的执行环境。它是一种新的编译目标,可以将各种编程语言的代码编译成WebAssembly模块,这些模块可以在现代浏览器中直接运行。WebAssembly的设计目标是实现高性能、安全性和可移植性。

优势

  • 性能优异:相比传统的JavaScript代码,WebAssembly的执行速度更快,因为它是直接在底层虚拟机中运行的。这使得Web应用程序可以更高效地处理复杂的计算任务,例如图形渲染、物理模拟等。
  • 跨平台兼容:WebAssembly可以在几乎所有现代浏览器中运行,无论是桌面还是移动设备。这意味着开发者可以使用各种编程语言来编写Web应用程序,而不仅仅局限于JavaScript。
  • 安全性:WebAssembly运行在沙箱环境中,提供了良好的安全性。它使用了一系列安全措施,如内存隔离和沙箱限制,以防止恶意代码对系统的攻击。
  • 模块化:WebAssembly模块可以作为独立的组件进行开发和部署,这使得开发者可以更好地管理和维护代码库。此外,模块化的设计也为将来的性能优化和增量更新提供了便利。

使用场景

在Web开发中,可以使用WebAssembly来提高应用程序的性能和功能。以下是一些使用WebAssembly的常见场景:

  • 高性能计算:如果应用程序需要进行大量的数值计算、图像处理或者复杂的算法运算,可以将这部分代码编译成WebAssembly模块,以提高计算性能。
  • 游戏开发:WebAssembly可以用于创建高性能的HTML5游戏,通过将游戏逻辑编译成WebAssembly模块,可以实现更流畅的游戏体验。
  • 跨平台应用:使用WebAssembly可以实现跨平台的应用程序,无论是桌面还是移动设备,用户都可以通过浏览器来访问和使用。
  • 移植现有代码:如果你已经有用其他编程语言编写的代码,可以通过将其编译成WebAssembly模块,将其集成到现有的Web应用程序中,而无需重写整个应用程序。

用Rust编写WebAssembly

用Rust写WebAssembly还是很方便的,就好像新学语言时,在控制台打出Hello World! 那么简单。

  1. 准备运行环境
  • 安装Rust,这是一切的基础
  • 要构建包,我们需要一个额外的工具wasm-pack。这有助于将代码编译为WebAssembly,并生成在浏览器中使用的正确包。在终端输入以下命令:
cargo install wasm-pack
  1. 启动新项目
cargo new --lib hello-wasm

这将创建一个名为hello-wasm的新库,其中的目录结构为

├── Cargo.toml
└── src
    └── lib.rs

其中Cargo.toml与npm的package.json类似,都是用来配置构建的文件。最终打出的WebAssembly的包,其中的package文件就是依据toml文件生成的。

  1. 配置

先安装下辅助工具

cargo add wasm_bindgen

然后修改lib.rs的文件

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str)-> String {
    format!("Hello, {}!", name)
}

再修改下toml文件,主要是补充下lib的参数

[package]
name = "hello-wasm"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
description = "A sample project with wasm-pack"
license = "MIT/Apache-2.0"
repository = "https://github.com/yourgithubusername/hello-wasm"
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
  1. 打包

现在可以愉快的打包了

wasm-pack build --target web --release

使用我们最开始下载的打包工具,指定目标为web模式。就会在pkg文件夹下,生成一个简单但是齐全的npm包了。连d.ts文件都补齐了。

├── Cargo.lock
├── Cargo.toml
├── index.html
├── pkg
│   ├── hello_wasm.d.ts
│   ├── hello_wasm.js
│   ├── hello_wasm_bg.wasm
│   ├── hello_wasm_bg.wasm.d.ts
│   └── package.json
├── src
│   └── lib.rs
└── target
    ├── CACHEDIR.TAG
    ├── release
    └── wasm32-unknown-unknown
  1. 使用

简单测试下~。在根路径新建一个index.html文件,放入以下内容。

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/hello_wasm.js";
      init().then(() => {
        console.log(greet("WebAssembly"));
      });
    </script>
  </body>
</html>

script中当做正常的包来引入,然后执行。记得先调用下init。然后找个server,或者在vs里下载个Live Server,启动服务打开页面。就能在页面控制台上看到Hello, WebAssembly!了。

完整的示例可以参考WebAssembly官方页面,也可以在Rust WebAssembly教程中自己实现一个简单的小游戏。

创作不易,点个赞吧~