阅读主题
日期如何变成截止边界
会员管理页面上有一个“最后有效日期”字段。运营填入 2027-03-31,希望会员在这一天仍能使用权益。程序处理访问请求时,却需要回答一个更具体的问题:这次访问是否已经超过有效期?从表单中的日期到请求中的判断,需要先把“这一天仍然有效”解释清楚。
1.1 从业务日期到截止瞬间
同样是 2027-03-31,按北京时间和按东京时间计算,这一天结束的瞬间并不相同。因此,“最后有效日全天有效”还需要一个时区约定。本例统一按北京时间计算:3 月 31 日全天有效,进入 4 月 1 日时失效。我们把这项业务规则采用的时区称为业务时区,在程序中用 Asia/Shanghai 表达。
这项约定决定了截止怎样计算。用户去外地旅行,或者服务器部署到其他地区,都不应改变这份会员的有效期。实现时需要显式使用约定的业务时区,不能让设备或服务器的默认设置替代它。
1.1.1 日期与时间点回答不同的问题
“最后有效日期是哪一天”和“从哪一刻起失效”需要两种不同的值。日期表示日历中的某一天,例如 2027-03-31;时间点,也称瞬间,表示时间轴上的一个位置,例如一次访问被检查的时刻。在 Java 中,可以分别用 LocalDate 和 Instant 表达它们。
日期能否满足需求,取决于我们要用它回答什么问题。出生日期 1995-06-12 足以记录生日,无需再补出一个出生时刻;但要判断某次访问是否已经超过会员有效期,仅有最后有效日期还不够,需要结合业务规则确定截止。转换所需的条件来自这个值的用途,不能因为程序需要时间点,就随意给日期补上时分秒。
例如,把 2027-03-31 补成北京时间 2027-03-31 00:00:00,得到的是最后有效日的开始。若把它当作失效时刻,3 月 31 日还没过去,会员就已经失效了。转换得到了一个合法的时间点,却没有表达“当天全天有效”的承诺。
反过来,出生日期通常没有转换成瞬间的必要。若给它补上午夜和时区,再按另一个时区显示,日期甚至可能变成前一天。这里原本只需保留用户填写的日期,额外的转换却改变了要记录的信息。
对本例而言,需要确定的是最后有效日结束后的边界:先找到下一日期,再按业务时区求那一天的开始。这样得到的截止瞬间,才适合与访问时刻比较。
1.2 用半开区间表达有效期
1.2.1 从最后有效日期推导截止
最后有效日期为 3 月 31 日,下一日期就是 4 月 1 日。按本例的北京时间规则,4 月 1 日从零点开始,截止因此可以逐步算出:
text
最后有效日期:2027-03-31
↓ 日历上加一天
下一日期: 2027-04-01
↓ 按 Asia/Shanghai 求这一天的开始
截止瞬间: 2027-04-01T00:00:00+08:00
= 2027-03-31T16:00:00Z最后两行表示同一个瞬间。T 分隔日期与时刻,+08:00 表示当地读数比协调世界时(UTC)快八小时,Z 表示 UTC 的零偏移。北京时间 4 月 1 日零点减去八小时,就是 UTC 的 3 月 31 日 16 点。这些写法符合 RFC 3339 时间戳格式。
换成 UTC 表示以后,日期部分变成了 3 月 31 日,但截止并没有提前。业务时区决定截止如何计算,同一个截止可以有不同的日期时间表示。 判断是否过期时,比较的是它们表示的瞬间,不能只看字符串中的日期。
对应的 Java 计算保留了这几个步骤:
java
LocalDate lastValidDate = LocalDate.of(2027, 3, 31);
ZoneId businessZone = ZoneId.of("Asia/Shanghai");
Instant expiresAt = lastValidDate.plusDays(1)
.atStartOfDay(businessZone)
.toInstant();
// expiresAt: 2027-03-31T16:00:00ZplusDays(1) 在日历上取下一日期;atStartOfDay(businessZone) 按指定时区的规则确定那一天的开始;toInstant() 得到用于比较的瞬间。这里采用带 ZoneId 参数的 atStartOfDay,使业务时区明确参与计算。
这个顺序对应的是“下一日期开始时失效”。如果改成从最后有效日开始时加上 24 小时,就额外假定了这一天恰好长 24 小时。本例中两种计算结果相同,遇到时区偏移变化时却未必相同;1.4 节会解释这个差异。一天的开始也不总是当地 00:00:时钟调整可能跳过零点。上述 API 会按时区规则求最早有效时间,特殊日界的处理在 1.5 节展开。
也可以保留日期,在判断时使用业务时区
推导截止瞬间是一种实现选择,不意味着最后有效日期必须存成时间点。对于本例的日期和规则,也可以保留 2027-03-31,检查时先把当前瞬间转换为北京时间的日期,再判断是否晚于最后有效日期。两种做法都需要遵守同一个时区与“当天包含在内”的约定;这里只比较截止条件,生效条件仍需单独判断。
本节采用截止瞬间,是为了把它与访问时刻放在时间轴上比较。如果同时保存原始日期和计算出的截止,就要明确后者由前者及规则推导,修改配置时一并更新。日期与派生瞬间怎样保存,在 1.7 节讨论。
1.2.2 端点约定与相邻区间
截止瞬间确定以后,比较符号也能从它的含义中推导出来。我们将截止记为 expires_at,定义为“从这一瞬间起失效”。因此,检查时刻 now 必须早于它,即 now < expires_at;恰好到达截止时,已经不在有效期内。
再设这份会员从北京时间 3 月 1 日零点生效,记为 starts_at。恰好到达生效时刻时已经有效,因此开始一侧使用 starts_at <= now。合在一起,时间范围条件就是:
text
starts_at <= now && now < expires_at这个范围包含起点、排除终点,称为半开区间,记作 [starts_at, expires_at)。方括号表示包含,圆括号表示排除。最后有效日期包含在有效期内,截止瞬间则是第一个不再有效的瞬间。 它们表达不同的边界含义,因此前者“包含当天”与后者“排除终点”并不矛盾。
这也解释了为什么不必寻找“当天最后一刻”。如果把 23:59:59 作为包含在内的上限,就会遗漏 23:59:59.500 等仍属于当天的时刻;补成 23:59:59.999,在能表达更细精度的系统中仍有同样的问题。使用下一天的开始作为不包含的上限,就不必让业务规则依赖系统的最小时间单位。
半开区间还让相邻有效期自然衔接。假设续期从原有效期结束时立即生效,共同边界就是北京时间 4 月 1 日零点:前一段不包含它,后一段包含它,既没有空隙,也没有重叠。
图 1-1:北京时间 4 月 1 日零点属于续期后的第二段,不属于第一段。实心点表示包含,空心点表示不包含,横向不按比例。
用一般形式表示,设 a < b < c,则 [a, b) 和 [b, c) 没有交集,合起来恰好是 [a, c)。连续会员、相邻账期和每日统计都能利用这个性质,使交界点只属于其中一段。端点是否包含仍要由业务规则决定;这里选择半开区间,是因为它符合本例的生效与失效约定,也便于表示连续衔接的时间范围。
1.2.3 把定义变成可检查的结果
这项设计包含两步需要验证的工作:从日期和业务规则算出正确的截止,以及用起止瞬间正确判断有效期。两者应分别检查。即使比较函数完全正确,传入一个提前一天的截止,也仍然会让会员提前失效。
转换的检查直接使用需求中的例子:输入最后有效日期 2027-03-31,按 Asia/Shanghai 和“最后有效日全天有效”计算,结果应为 2027-03-31T16:00:00Z。这项检查也应确认计算显式采用业务时区,不依赖运行环境的默认时区。
判断函数接收生效瞬间、截止瞬间和检查瞬间。创建有效期时,本例要求 expires_at > starts_at:两者相等会得到空区间,截止早于开始则是反向区间,都应拒绝。对合法的区间,判断只需表达已经推导出的条件:
text
isWithinValidityWindow(now, starts_at, expires_at):
return starts_at <= now && now < expires_at处理请求时,服务端读取一次当前时间,作为 now 传入函数,使两侧比较使用同一个检查时刻。测试时则直接传入固定的瞬间,无需等待月底或修改电脑时间。端点约定决定预期结果,测试要覆盖端点两侧以及恰好到达端点的情况。 取系统能保留的正时间步长 δ,并确保它小于有效期长度,就得到表 1-1。
表 1-1:本例有效期的边界检查。
| 检查时刻 | 满足时间范围 | 所检验的边界 |
|---|---|---|
starts_at - δ | 否 | 尚未达到起点 |
starts_at | 是 | 起点包含 |
starts_at + δ | 是 | 已进入有效期 |
expires_at - δ | 是 | 尚未达到终点 |
expires_at | 否 | 终点排除 |
expires_at + δ | 否 | 已越过终点 |
例如,取 δ 为 1 秒,截止前的测试时刻就是北京时间 2027-03-31 23:59:59。它只是边界附近的取样,不表示“当天最后一刻”。此外,创建区间的检查应覆盖相等和反向的起止值;若边界需要经过接口或数据库,还应确认读回后没有因精度变化而发生移动。
这个函数只回答检查时刻是否落在有效区间内。会员暂停或撤销等状态还需另外判断,当前时间是否可信也取决于时钟来源。这些问题分别在 1.8 节和 1.9 节讨论。
本例由运营直接给出最后有效日期,程序只需按约定把它转换成截止。如果需求改成“购买后七天有效”或“每月续费”,截止本身也需要计算,而“七天”和“一个月”并未说明同一种运算。下一节将从这些差别出发,整理时间点、日期和时间长度之间的关系。