Stripe 如何在 API 中设计日期和时间
介绍
API 是为开发者构建的——成功的API 在设计时充分考虑了开发者的体验。良好的开发者体验的一部分在于一致性,无论是在路径名的设计上(所有路径名均为snake_case
或kebab-case
),还是在每个实体上都拥有一个带有唯一标识符的属性。在向 API 添加新功能时,参考现有模式非常重要,这样我们才能提供可预测的界面。今天,您将学习 Stripe 工程师在向 API添加日期和时间id
字段时使用的设计模式。
假设您想为订阅 API 添加一项新功能。用户反馈,他们面临的挑战之一是日后如何取消订阅。他们正在使用 cron 作业构建复杂的变通方案,并希望能够告知 Stripe 何时应该取消订阅。以下是基本的产品规格:
在使用 API 创建或更新订阅时,用户可以传递他们希望取消订阅的未来日期和时间,并且如果用户从 API 检索订阅,他们就会知道订阅何时被取消。
让我们来分析一下:
- “使用 API 创建或更新订阅时”→此功能需要同时适用于创建和更新端点。
- “用户可以传递未来的日期和时间”→我们需要从用户那里接收日期和时间作为 API 的参数。
- “当他们想要取消订阅时”→新的日期和时间应该存储在某个地方,以便我们可以在取消订阅的新后台作业中使用它。
- “如果用户从 API 检索订阅”→这是检索端点的不同功能。
- “他们知道订阅何时被取消”→我们需要呈现订阅被取消的日期和时间。
- “如果有的话。” → Subscription 上表示取消时间的属性可能为空(如果没有取消)。
这项任务至少涉及三个功能,我们将讨论datetime
API 的参数以及datetime
API 返回对象的属性。在列举可能的解决方案和权衡利弊之前,我们先来谈谈处理日期和时间的一些挑战。
为什么处理日期和时间很有挑战性
如果您尝试构建日历界面,或将界面本地化datetime
到用户特定的时区,那么您会遇到以下一些挑战:
时区出奇地复杂
编写能够完美适配时区的代码极其困难 [ 0 ], [ 1 ]。时区还会增加运营开销。也许你位于旧金山,却忘记在悉尼的客户下个月开始缴费之前部署账单变更——糟糕!还有个更诡异的例子:在印第安纳州,不同县的时区各不相同。
月份的天数不同
由于月份的最后一天可能是 28、29、30 或 31,因此将任务自动化到“月份的最后一天”可能颇具挑战性。肯定有一些糟糕的代码会根据月份来推断最后一天:
require 'time'
def last_day_of_month?
case Date.today.month
when 1, 3, 5, 7, 8, 10, 12
Date.today.day == 31
when 4, 6, 9, 11
Date.today.day == 30
else
Date.today >= 28 # 🐛
end
end
如果你还不信,可以看看这个 StackOverflow 问题,关于如何使用 JavaScript计算月份最后一天。它已经被浏览了 43 万多次,并有 25 个不同的答案!
顺便说一下,Stripe Billing 会帮您计算当月最后一天。如果客户的月度订阅日期固定为 8 月 31 日,则系统会在 9 月 30 日和 2 月 28 日扣款。
本地日期格式按日、月、年的顺序不同
ECMAScript 标准机构维护了一个专门用于本地化日期的 API。以下是一些来自MDN 文档的示例:
const date = new Date(Date.UTC(2012, 11, 20, 3, 0, 0));
// US English uses month-day-year order and 12-hour time with AM/PM
console.log(date.toLocaleString('en-US'));
// → "12/19/2012, 7:00:00 PM"
// British English uses day-month-year order and 24-hour time without AM/PM
console.log(date.toLocaleString('en-GB'));
// → "20/12/2012 03:00:00"
// Korean uses year-month-day order and 12-hour time with AM/PM
console.log(date.toLocaleString('ko-KR'));
// → "2012. 12. 20. 오후 12:00:00"
颜色 | 订购样式 | 结尾 |
---|---|---|
青色 | 迪米 | 左 |
黄色的 | 年月日 | B |
品红 | MDY | 米 |
绿色的 | 日月年 | 磅 |
蓝色的 | DMY,MDY | 左、中 |
红色的 | 月日年年月日 | M, B |
灰色的 | 月日年、年月日、日月年 | 中、中、大 |
存储本地时区无法扩展
如果您使用本地时区来datetime
存储在数据库中,那么尝试跨多个区域扩展会带来新的问题。
12 小时制与 24 小时制
24 小时制是标准计时法,但在曾属于大英帝国的国家中,12 小时制更为主流[ 2 ]。有些地方口头上使用 12 小时制,但书面材料中使用 24 小时制。
闰年
我们绕太阳公转一周的时间是365天5小时59分16秒,而不是完美的365天。公历中的闰年是通过在每年增加4的整数倍天数来计算的(能被100整除但不能被400整除的年份除外)[ 3 ]。罗马时代的儒略历解决了这个问题,它用四年一度的闰日来解决这个问题,即2月24日,即48小时制。参见闰年!
本文草稿中的列表比较长,希望这个精简版能让您信服。接下来,我们将讨论可扩展的 API 设计,避免时区陷阱和闰秒陷阱,并且可供世界上任何人使用。
在 API 中使用日期和时间的选项
如今,大多数 Web API 都使用 JSON 进行序列化和反序列化。JSON 没有原生的date
、time
或datetime
标量数据类型,因此我们需要使用 String 或 Number 来表示值。
任意字符串
作为一名幼稚的早期开发者,我可能会设计内部 API 来处理字符串值,并选择一个人类易于阅读的任意字符串。例如:
m/dd/yyyy hh:mm:ss
优点:
- 易于阅读(如果您知道格式!)
缺点:
- 未被广泛接受的格式
- 没有时区概念
- 无法很好地适应其他地区
- 需要知道中间和最后一个日期部分是月份还是日期。例如,是
8/9/2022
8 月 9 日还是 9 月 8 日? - 日期中的斜线表示它与 URL 路径不兼容
- 字符串在数据库中的权重较大
这只是为了说明,请不要在您的 API 中这样做。而是选择公认的标准机构认可的格式。
每个日期部分都有单独的键和值
datetime
我们可以将 视为结构化对象,而不是将其作为单个值接受。例如:
{
"id": "sub_abc123", # ID of the Subscription to cancel.
"cancel_at": {
"year": 2022,
"month": 8,
"day": 9,
"hour": 8,
"minute": 54,
"second": 0,
"timezone-offset": "+05:00"
}
}
优点:
- 我们避免跨语言环境的字符串格式不一致
缺点:
- API 用户仍然需要处理时区
- 虽然可能性不大,但用户可能会错误地将小时数设置为晚上 8:54 或 20:54
- 如果我们需要亚秒级的精度怎么办?你可能会看到
second: 0.00123
“等等”,或者milliseconds: 123
——这两个词都不符合我们对时间的口语理解。
在我们完全否定这种方法之前,让我们看一个没有时间成分的例子。这是一个创建具有姓名和出生日期的 Person 的示例:
{
"first_name": "Jenny",
"last_name": "Rosen",
"date_of_birth": {
"year": 1901,
"month": 1,
"day": 9,
}
}
这种结构非常适合没有时间的日期!
优点:
- 避免对日期的本地格式产生任何混淆,例如
9-1-1901
vs.1-9-1901
- 避免字符串操作
- 简单的
缺点:
- 实现代码必须单独处理每个部分而不是处理一个值
ISO 8601
国际标准化组织 (ISO) [ 4 ] 定义了一项“涵盖全球范围日期和时间相关数据交换与通信的国际标准”——ISO 8601 [ 5 ]。这项规范非常全面,支持持续时间和时间间隔。
以下是datetime
ISO 8601 格式的时区的一些示例:
2022-09-12T14:42:04+00:00
2022-09-12T14:42:04Z
优点:
- 被广泛接受的标准
缺点:
- 一些开发人员可能会采用字符串操作
- 字符串在数据库中的权重较大
- 用户仍然需要考虑时区
- 同一个时刻可以有多个值表示。例如
1994-11-05T08:15:30-05:00
和1994-11-05T13:15:30Z
是同一个![ 6 ]
如果您决定使用此选项,请考虑接受具有任何时区偏移的参数,然后仅使用基于 UTC 时间的日期和时间进行响应。
Unix 时间
在计算机领域,Unix 时间(也称为纪元时间、Posix 时间、纪元以来的秒数、Unix 时间戳或 UNIX 纪元时间)是一种描述时间点的系统。它是自 Unix 纪元以来经过的秒数,不包括闰秒。Unix 纪元是 1970 年 1 月 1 日 00:00:00 UTC。[ 7 ]
{
"id": "sub_abc123", # ID of the Subscription to cancel
"cancel_at": 1662738023,
}
优点:
- Unix 时间不需要用户考虑时区
- Unix 时间是一个单符号数字
- 由于整数比字符串更易于查询、索引且更节省空间,因此日期通常以 64 位整数的形式存储在数据库中
- 1970 年 1 月 1 日之前的时刻使用负数
- Unix 时间具有单一格式
- Unix 时间不受夏令时影响
- Unix 时间是广泛支持的标准
- 使用date-fns或day.js等库轻松转换为良好的本地化格式客户端
- 你可以得到一件带有当前时间的酷衬衫https://datetime.store/
缺点:
- 无法被人类理解。例如,这件事发生在多久以前
1662738023
?是将来发生的吗? - 2038 年 1 月 19 日 03:14:07 UTC 之后的 Unix 时间戳需要超过 32 位才能表示。这被称为“ 2038 年问题”
Stripe 用于表示日期和时间的 API 设计模式
🟢好:使用unix 时间戳来表示带有时间的日期
大多数字段和参数都是这种情况,在 API 中以时间戳 (Timestamp) 字段和参数的形式表示。API 中的时间戳以整数形式表示,表示 Unix 时间戳,该时间戳计算自 Unix 纪元以来的秒数。
curl https://api.stripe.com/v1/subscriptions/sub_1LhfXp2Tb35ankTnpxovpHQs \
-u sk_test_: \
-d "cancel_at"=1662738023
{
"id": "sub_1LhfXp2Tb35ankTnpxovpHQs",
"cancel_at": 1662738023
}
🟢好:day
使用具有、month
和属性的哈希year
来表示出生日期
集成会分别以日、月、年的形式收集生日信息。此哈希语法允许集成构建并读取生日值,而无需将其解析为字符串。请参阅发卡行或账户,了解此格式的示例。
curl https://api.stripe.com/v1/accounts/acct_1KtbGiBdyCgEshX8/persons \
-u sk_test_: \
-d first_name=Jenny \
-d last_name=Rosen \
-d "dob[year]"=1901 \
-d "dob[month]"=2 \
-d "dob[day]"=12
{
"id": "person_1LhfZ7",
"first_name": "Jenny",
"last_name": "Rosen",
"dob": {
"year": 1901,
"month": 2,
"day": 12
}
}
🔴不好:使用任何其他表示日期或时间的格式。
Stripe 的 API 命名datetime
字段设计模式
时间戳字段被命名 **<verb>ed_at**
该_at
模式表明整数始终是 Unix 时间戳。_at
非时间戳字段不允许使用后缀。某些字段(例如created
所有资源上的时间戳)不符合该_at
格式,并且可能早于此设计模式。
🟢好: subscription.canceled_at
,invoice.finalized_at
🔴不好的: subscription.canceled
,,subscription.canceled_on
subscription.cancellation_date
- 对于未来的时间戳,最好使用
<verb>s_at
;例如file_link.expires_at
。但请注意,未来的时间戳可能暗示着 SLA。 - 这只是一般准则,有一些充分的理由可以打破它。例如,
dispute_evidence_details.due_by
、coupon.redeem_by
和issuing_card_shipping.eta
不使用_at
后缀,但更清晰。
到期时间表示为 **expires_at: <unix timestamp>**
🟢好: file.expires_at
🔴不好: file.expires_after
,file.expiration_date
- 如果过期时间由 cronjob 处理,那么 cronjob 稍微延迟是可以接受的。例如,如果某个
status
字段从 变为pending
,expired
而用户在 之后不久获取该对象expires_at
,那么他们仍然看到 ,这也没关系pending
。 - 仔细检查
expires_at
操作的执行时间。例如,如果有一个指向托管页面的 URL,我们应该检查expires_at
该托管页面的加载时间。 - 为什么不呢
expires_after
?它可能被误认为是“N 天后过期”,而不是“在给定的 或之后很快过期datetime
”。它也不鼓励上面提到的更严格的运行时检查;如果我们可以提供更严格的约定,用户会更感激。
开发者体验改进
语法糖快捷方式
Stripe 还增强了开发者使用日期的体验。一些端点接受魔术字符串代替整数时间戳,使其更易于使用。例如,将日期时间传递给 API 时,一个常见的用例是传递当前日期和时间。如果您知道自己始终希望传递“现在”,那么无需计算日期和时间,就可以使用魔术字符串,例如,**now**
用于设置计费周期锚点,该锚点决定了每月的哪一天对订阅进行收费 [ 8 ]。
你可以想象其他扩展方法,使其能够接受类似subscription_end
或 的参数tomorrow
。如果你有其他想法,欢迎在下面的评论区留言!
测试时钟
测试时钟 [ 9 ] 是 API 的一项功能,可让您模拟时间的移动。您可以创建一个测试时钟对象,然后使用该测试时钟创建一个关联的客户,之后您可以将时钟推进到未来的某个时间,以测试您的 webhook 处理逻辑是否能够正确处理该时间段内触发的事件。
这是一个简洁的例子:
// Create a test clock
const testClock = await stripe.testHelpers.testClocks.create({
frozen_time: 1635750000,
name: 'its a date demo',
});
// Create a customer with the test clock
const customer = await stripe.customers.create({
email: 'jenny.rosen@example.com',
test_clock: testClock.id,
payment_method: 'pm_card_visa',
invoice_settings: {default_payment_method: 'pm_card_visa'},
});
// Create a subscription for the customer
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{
price: 'price_1LcssrCZ6qsJgndJ2W9eARnK'
}],
});
// Advance time
const testClock2 = await stripe.testHelpers.testClocks.advance(
testClock.id,
{frozen_time: 1641020400}
);
代码示例
如何将 unix 时间戳转换为通用语言中的本机date
、time
或对象。datetime
红宝石
Time.at(1662759630)
Python
from datetime import datetime
datetime.utcfromtimestamp(1662759630)
PHP
DateTime::createFromFormat( 'U', 1662759630 )
JavaScript
new Date(1662759630 * 1000)
去
time.Unix(1662759630, 0)
Java
new java.util.Date((long)1662759630 * 1000);
。网
DateTimeOffset.FromUnixTimeSeconds(1662759630);
结论
Unix 时间戳和结构化的出生日期对象为 API 使用者带来了诸多益处。我们始终保持灵活,并乐于调整以寻求更好的服务方式。请告知我们您在 API 中处理日期和时间数据的首选方法。
关于作者
CJ Avilla ( @cjav_dev ) 是 Stripe 的开发倡导者、Ruby on Rails 开发者和YouTuber。他热爱学习和教授新的编程语言和 Web 框架。不在电脑前时,他会陪伴家人或骑自行车🚲。
聚苯乙烯
您是否认为有一天墓碑上会有 unix 时间戳?
珍妮·罗森 (Jenny Rosen),生活在554805154
- 3067403554
,要警惕三月十五日。