跳转到内容

Rust 日期与时间(chrono)

程序总是要和时间打交道。“这段代码跑了多久?”“这张账单哪天到期?”“离出发旅行还有几天?”Rust 把这些问题分成两类。

标准库的 std::time 模块是一块秒表:它只负责测量过去了多少时间,不认识日历、月份,也不知道今天星期几。处理日历相关的事情,几乎所有 Rust 项目都会用 chrono 这个 crate。这一页先讲秒表,再讲日历。

Instant::now() 从计算机的时钟读取一个时刻。之后调用 .elapsed(),就能知道从那一刻起过去了多少时间,结果是一个 Duration:

use std::time::{Duration, Instant};
fn main() {
let start = Instant::now();
let mut total: u64 = 0;
for i in 0..1_000_000 {
total += i;
}
let elapsed = start.elapsed();
println!("合计 = {total}");
println!("耗时:{:?}", elapsed);
println!("耗时:{} ms", elapsed.as_millis());
println!("少于 5 秒?{}", elapsed < Duration::from_secs(5));
}
▶ 动手试一试

这里故意不展示输出:每台计算机、每次运行,耗时都不一样。你会看到类似 耗时:6.862793ms 的结果。试着点两次按钮,比较一下。

Instant 只能用来测量。它不能当作日期打印出来,而且只会往前走。这正是给代码计时需要的:就算有人修改了系统时钟,它也不受影响。

Duration 表示一段时间的长度,比如“90 秒”。你可以用秒或毫秒创建它,把几个 Duration 加起来,也可以比较大小:

use std::time::Duration;
fn main() {
let short = Duration::from_millis(1500);
let long = Duration::from_secs(90);
println!("{:?}", short);
println!("{:?}", long);
println!("{} ms", short.as_millis());
println!("{:.1} s", short.as_secs_f64());
let total = short + long;
println!("合计:{:?}", total);
println!("{} 分 {} 秒", long.as_secs() / 60, long.as_secs() % 60);
println!("{}", short < long);
}

输出:

1.5s
90s
1500 ms
1.5 s
合计:91.5s
1 分 30 秒
true
▶ 动手试一试

Duration 在很多地方都会出现:std::thread::sleep(Duration::from_millis(100)) 让线程暂停一会儿,网络库的超时设置也接收 Duration。

SystemTime 是计算机时钟上的时间,也就是屏幕角落里显示的那个。最常见的用法,是问它距离 Unix 纪元(Unix epoch,1970 年 1 月 1 日 UTC)过去了多久。这个数字叫作 Unix 时间戳:

use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
let now = SystemTime::now();
let since_1970 = now
.duration_since(UNIX_EPOCH)
.expect("系统时钟早于 1970 年");
println!("距 1970 年 1 月 1 日的秒数:{}", since_1970.as_secs());
}
▶ 动手试一试

这个数字取决于运行的时刻,所以这里同样不展示输出。为什么 duration_since 返回的是 Result?因为系统时钟可能不准,也可能被往回调。这也是给代码计时要用 Instant 而不是 SystemTime 的原因。

std::time 能做的差不多就这些了。它根本不知道“三月”或“星期五”是什么意思。要处理这些,就需要 chrono。

把这个 crate 加进项目(如果你还不熟悉,先看看 Cargo 与 crate):

Terminal window
cargo add chrono
[dependencies]
chrono = "0.4"

Rust Playground 也内置了 chrono,所以下面每个按钮都能直接运行。chrono 的类型分成两大类:

  • Naive 类型(NaiveDate、NaiveTime、NaiveDateTime)是不带时区的日期和时间,就像日历上写的“2025 年 3 月 14 日”或“09:30”。日常代码大多用的是这一类。
  • DateTime<Tz> 是带时区的某个时刻,比如 DateTime<Utc> 或 DateTime<Local>。

NaiveDate::from_ymd_opt(year, month, day) 用来创建日期。它返回一个 Option,因为并不是每种组合都是真实存在的日期:

use chrono::{Datelike, NaiveDate, NaiveTime};
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap();
println!("{date}");
println!("{} 年 {} 月 {} 日", date.year(), date.month(), date.day());
let time = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let meeting = date.and_time(time);
println!("{meeting}");
let other = date.and_hms_opt(17, 45, 10).unwrap();
println!("{other}");
let impossible = NaiveDate::from_ymd_opt(2025, 2, 30);
println!("{:?}", impossible);
}

输出:

2025-03-14
2025 年 3 月 14 日
2025-03-14 09:30:00
2025-03-14 17:45:10
None
▶ 动手试一试

2 月 30 日并不存在,所以你得到的是 None,而不是一个错误的日期。如果是你自己写死的固定值,用 .unwrap() 没问题;如果值来自用户输入,就要处理 None 的情况。

use chrono::Datelike 这一行引入了 .year()、.month() 和 .day() 这些方法。(Timelike 对 .hour() 和 .minute() 起同样的作用。)

Utc::now() 返回当前的 UTC 时刻,Local::now() 返回计算机所在时区的当前时刻:

use chrono::{Local, Utc};
fn main() {
let utc_now = Utc::now();
let local_now = Local::now();
println!("UTC:{}", utc_now);
println!("本地:{}", local_now);
println!("今天:{}", local_now.format("%Y-%m-%d"));
let today = local_now.date_naive();
println!("转成 NaiveDate:{today}");
}
▶ 动手试一试

输出是今天的日期,所以每天都不一样。Playground 的服务器使用 UTC,因此在那里前两行显示的时间是一样的。.date_naive() 会去掉时区和时间,只留下一个普通的 NaiveDate。一个好习惯:只在程序的入口处获取一次“现在”,然后把日期作为参数传给各个函数。这样函数就很容易测试,后面的截止日期示例会让你看到这一点。

.format() 接收一个格式模板,模板里的每个 % 代码都会被替换成日期的一部分。%Y 是年份,%m 是月份数字,%d 是日:

use chrono::NaiveDate;
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap();
let dt = date.and_hms_opt(9, 5, 0).unwrap();
println!("{}", date.format("%Y-%m-%d"));
println!("{}", date.format("%d/%m/%Y"));
println!("{}", date.format("%A, %B %-d, %Y"));
println!("{}", date.format("%a %d %b"));
println!("{}", dt.format("%H:%M"));
println!("{}", dt.format("%-I:%M %p"));
let file_name = date.format("report-%Y%m%d.txt").to_string();
println!("{file_name}");
}

输出:

2025-03-14
14/03/2025
Friday, March 14, 2025
Fri 14 Mar
09:05
9:05 AM
report-20250314.txt
▶ 动手试一试

最常用的代码:%Y 年,%m 月(01–12),%d 日(01–31),%H 小时(00–23),%M 分钟,%S 秒,%A 星期名,%B 月份名。在 % 后面加一个 - 可以去掉前导零,所以 %-d 打印的是 5 而不是 05。星期名和月份名始终是英文,chrono 默认不做本地化。.format() 返回的东西可以直接打印;需要 String 的时候,调用 .to_string()。

NaiveDate::parse_from_str 做的是反方向的事:用同样的 % 模板读取文本。它返回一个 Result,因为文本可能和模板对不上:

use chrono::{NaiveDate, NaiveDateTime};
fn main() {
let a = NaiveDate::parse_from_str("2025-03-14", "%Y-%m-%d");
println!("{:?}", a);
let b = NaiveDate::parse_from_str("14/03/2025", "%d/%m/%Y");
println!("{:?}", b);
let c: Result<NaiveDate, _> = "2025-03-14".parse();
println!("{:?}", c);
let d = NaiveDateTime::parse_from_str("2025-03-14 09:30", "%Y-%m-%d %H:%M");
println!("{:?}", d);
for text in ["2025-13-01", "March 14", "2025-03"] {
match NaiveDate::parse_from_str(text, "%Y-%m-%d") {
Ok(date) => println!("解析成功:{date}"),
Err(e) => println!("无法解析 {text:?}:{e}"),
}
}
}

输出:

Ok(2025-03-14)
Ok(2025-03-14)
Ok(2025-03-14)
Ok(2025-03-14T09:30:00)
无法解析 "2025-13-01":input is out of range
无法解析 "March 14":input contains invalid characters
无法解析 "2025-03":premature end of input
▶ 动手试一试

对于标准的 YYYY-MM-DD 格式,也可以直接调用 .parse()。13 月不存在,所以第一个错误例子报的是“out of range”(超出范围)。第二个是“包含无效字符”,第三个是“输入提前结束”。这些错误信息是 chrono 自带的英文,虽然简短,但足够清楚。

手动给日期加天数其实挺难:每个月天数不同,还有闰年。chrono 会帮你处理好。可以用 checked_add_days 配合 Days,用 checked_add_months 配合 Months,或者直接加一个 TimeDelta:

use chrono::{Days, Months, NaiveDate, TimeDelta};
fn main() {
let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
let plus_30 = start.checked_add_days(Days::new(30)).unwrap();
println!("30 天后:{plus_30}");
let minus_7 = start.checked_sub_days(Days::new(7)).unwrap();
println!("7 天前:{minus_7}");
let next_month = start.checked_add_months(Months::new(1)).unwrap();
println!("1 个月后:{next_month}");
let week_later = start + TimeDelta::weeks(1);
println!("1 周后:{week_later}");
let dt = start.and_hms_opt(23, 30, 0).unwrap();
let later = dt + TimeDelta::hours(2);
println!("2 小时后:{later}");
}

输出:

30 天后:2025-03-02
7 天前:2025-01-24
1 个月后:2025-02-28
1 周后:2025-02-07
2 小时后:2025-02-01 01:30:00
▶ 动手试一试

看看“1 个月后”这一行:1 月 31 日加一个月是 2 月 28 日,因为没有 2 月 31 日,chrono 会选择那个月最后一个有效的日子。checked_ 系列方法返回 Option,只有结果超出 chrono 支持的(非常大的)年份范围时才会是 None。给 23:30 加 2 小时,也正确地跨到了第二天。

.weekday()(来自 Datelike)告诉你某天是星期几。用一个日期减去另一个日期,得到的是 TimeDelta,再用 .num_days() 把它变成数字:

use chrono::{Datelike, NaiveDate, Weekday};
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap();
let day = date.weekday();
println!("{date} 是 {day}({})", date.format("%A"));
let weekend = matches!(day, Weekday::Sat | Weekday::Sun);
println!("周末?{weekend}");
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2025, 12, 25).unwrap();
let gap = end - start;
println!("{} 天,也就是 {} 个整周", gap.num_days(), gap.num_weeks());
println!("反过来减:{} 天", (start - end).num_days());
let check_in = start.and_hms_opt(14, 0, 0).unwrap();
let check_out = NaiveDate::from_ymd_opt(2025, 1, 4).unwrap().and_hms_opt(11, 0, 0).unwrap();
println!("酒店入住:{} 小时", (check_out - check_in).num_hours());
}

输出:

2025-03-15 是 Sat(Saturday)
周末?true
358 天,也就是 51 个整周
反过来减:-358 天
酒店入住:69 小时
▶ 动手试一试

Weekday 是一个枚举,变体从 Mon 到 Sun,所以可以对它使用 match。注意,如果被减的日期更晚,差值可能是负数。这其实很有用:剩余天数为负,就表示“已逾期”。

把前面的内容组合起来。下面的函数把“今天”作为参数传进来,而不是在函数内部调用 Local::now()。这样下面的输出永远不变,函数也很容易测试。在真实程序里,你会传入 Local::now().date_naive()。

use chrono::NaiveDate;
fn days_left(today: NaiveDate, deadline: NaiveDate) -> String {
let days = (deadline - today).num_days();
match days {
0 => String::from("due today"),
1 => String::from("due tomorrow"),
d if d > 1 => format!("{d} days left"),
-1 => String::from("1 day overdue"),
d => format!("{} days overdue", -d),
}
}
fn main() {
let today = NaiveDate::from_ymd_opt(2025, 6, 10).unwrap();
let tasks = [
("Pay rent", "2025-06-10"),
("Send report", "2025-06-11"),
("Renew passport", "2025-07-01"),
("Return library book", "2025-06-09"),
("Dentist bill", "2025-06-03"),
];
println!("Today is {}", today.format("%A, %-d %B %Y"));
for (name, date_text) in tasks {
let deadline = NaiveDate::parse_from_str(date_text, "%Y-%m-%d").unwrap();
println!("{:<20} {} {}", name, deadline.format("%d %b"), days_left(today, deadline));
}
}

输出:

Today is Tuesday, 10 June 2025
Pay rent 10 Jun due today
Send report 11 Jun due tomorrow
Renew passport 01 Jul 21 days left
Return library book 09 Jun 1 day overdue
Dentist bill 03 Jun 7 days overdue
▶ 动手试一试

试着把 today 改成别的日期,再运行一次。{:<20} 会把名字补齐到 20 个字符,让各列对齐(见输出)。这个例子里的字符串保留了英文,原因就在这里:汉字在屏幕上占两个字符宽,在 UTF-8 里每个占 3 个字节,用 {:<20} 补齐的话,各列就对不齐了。

chrono 不是唯一的选择。time crate 更小巧,也很流行;较新的 jiff crate 在时区和夏令时方面下了更多功夫。这里用 chrono,是因为它用得最广泛,而且 Playground 里就有。

忘了写 use chrono::Datelike。没有它,.year()、.month() 和 .weekday() 都用不了。错误信息说这个方法是“private”(私有的),挺让人困惑,但 help 那一行告诉了你真正的解决办法:

error[E0624]: method `year` is private
--> src/main.rs:5:25
|
5 | println!("{}", date.year());
| ^^^^ private method
|
= help: items from traits can only be used if the trait is in scope
help: trait `Datelike` which provides `year` is implemented but not in scope; perhaps you want to import it
|
1 + use chrono::Datelike;
|

意思是:trait 里的方法,只有在这个 trait 被引入作用域之后才能用。加上 use chrono::Datelike; 就好了。

把 %m 和 %M 搞混。%m 是月份,%M 是分钟。对于带时间的日期,写错了也不会报错,它只会悄悄地打印出分钟数:

use chrono::NaiveDate;
fn main() {
let meeting = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap().and_hms_opt(9, 5, 0).unwrap();
println!("{}", meeting.format("%Y-%M-%d")); // 错误:这是分钟
println!("{}", meeting.format("%Y-%m-%d")); // 正确:这是月份
}

输出:

2025-05-14
2025-03-14
▶ 动手试一试

用 SystemTime 给代码计时。系统时钟可能会跳变(比如和网络时间同步的时候)。测量耗时用 Instant,只有需要真实日期时才用 SystemTime 或 chrono。

在业务逻辑深处调用 Local::now()。这会让结果每天都变,测试也很难写。像 days_left 那样,把日期作为参数传进去。

  • std::time::Instant 测量耗时;Duration 表示一段时间;SystemTime 是现实中的时钟(适合获取 Unix 时间戳)。
  • 处理日历要加上 chrono。不带时区的日期和时间用 NaiveDate / NaiveDateTime,当前时刻用 Utc::now() / Local::now()。
  • from_ymd_opt 返回 Option,parse_from_str 返回 Result,因为不是每个输入都是真实的日期。
  • .format("%Y-%m-%d") 用 % 代码把日期变成文本。记住:%m 是月份,%M 是分钟。
  • 用 checked_add_days、checked_add_months 或 + TimeDelta 增加时间。两个日期相减得到 TimeDelta,再调用 .num_days()。
  • 把“今天”作为参数传给函数,而不是在函数内部读取时钟。

小测验

1. 要测量一段代码运行了多久,应该用哪个类型?

2. NaiveDate::from_ymd_opt(2025, 2, 30) 返回什么?

3. 在 chrono 的格式字符串里,%M 表示什么?

4. 你把两个 NaiveDate 相减:deadline - today。怎样得到相差的天数?

5. 在 chrono 里,2025 年 1 月 31 日加上 Months::new(1) 是哪一天?

得分: 0 / 5