S

Stripe 如何在 API 中设计日期和时间

2025-06-10

Stripe 如何在 API 中设计日期和时间

介绍

API 是为开发者构建的——成功的API 在设计时充分考虑了开发者的体验。良好的开发者体验的一部分在于一致性,无论是在路径名的设计上(所有路径名均为snake_casekebab-case),还是在每个实体上都拥有一个带有唯一标识符的属性。在向 API 添加新功能时,参考现有模式非常重要,这样我们才能提供可预测的界面。今天,您将学习 Stripe 工程师在向 API添加日期和时间id字段时使用的设计模式。

假设您想为订阅 API 添加一项新功能。用户反馈,他们面临的挑战之一是日后如何取消订阅。他们正在使用 cron 作业构建复杂的变通方案,并希望能够告知 Stripe 何时应该取消订阅。以下是基本的产品规格:

在使用 API 创建或更新订阅时,用户可以传递他们希望取消订阅的未来日期和时间,并且如果用户从 API 检索订阅,他们就会知道订阅何时被取消。

让我们来分析一下:

  • “使用 API 创建或更新订阅时”→此功能需要同时适用于创建和更新端点。
  • “用户可以传递未来的日期和时间”→我们需要从用户那里接收日期和时间作为 API 的参数。
  • “当他们想要取消订阅时”→新的日期和时间应该存储在某个地方,以便我们可以在取消订阅的新后台作业中使用它。
  • “如果用户从 API 检索订阅”→这是检索端点的不同功能。
  • “他们知道订阅何时被取消”→我们需要呈现订阅被取消的日期和时间。
  • “如果有的话。” → Subscription 上表示取消时间的属性可能为空(如果没有取消)。

这项任务至少涉及三个功能,我们将讨论datetimeAPI 的参数以及datetimeAPI 返回对象的属性。在列举可能的解决方案和权衡利弊之前,我们先来谈谈处理日期和时间的一些挑战。

为什么处理日期和时间很有挑战性

如果您尝试构建日历界面,或将界面本地化datetime到用户特定的时区,那么您会遇到以下一些挑战:

时区出奇地复杂

编写能够完美适配时区的代码极其困难 [ 0 ], [ 1 ]。时区还会增加运营开销。也许你位于旧金山,却忘记在悉尼的客户下个月开始缴费之前部署账单变更——糟糕!还有个更诡异的例子:在印第安纳州,不同县的时区各不相同。

印第安纳州各县的地图,根据各县的时区以红色和黄色着色 https://en.wikipedia.org/wiki/Time_in_Indiana。

月份的天数不同

由于月份的最后一天可能是 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


Enter fullscreen mode Exit fullscreen mode

如果你还不信,可以看看这个 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"


Enter fullscreen mode Exit fullscreen mode

日期格式使用图来自 https://en.wikipedia.org/wiki/Date_format_by_country

颜色 订购样式 结尾
青色 迪米
黄色的 年月日 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 没有原生的datetimedatetime标量数据类型,因此我们需要使用 String 或 Number 来表示值。

任意字符串

作为一名幼稚的早期开发者,我可能会设计内部 API 来处理字符串值,并选择一个人类易于阅读的任意字符串。例如:

m/dd/yyyy hh:mm:ss

优点:

  • 易于阅读(如果您知道格式!)

缺点:

  • 未被广泛接受的格式
  • 没有时区概念
  • 无法很好地适应其他地区
  • 需要知道中间和最后一个日期部分是月份还是日期。例如,是8/9/20228 月 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"
  }
}


Enter fullscreen mode Exit fullscreen mode

优点:

  • 我们避免跨语言环境的字符串格式不一致

缺点:

  • 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,
  }
}


Enter fullscreen mode Exit fullscreen mode

这种结构非常适合没有时间的日期!

优点:

  • 避免对日期的本地格式产生任何混淆,例如9-1-1901vs.1-9-1901
  • 避免字符串操作
  • 简单的

缺点:

  • 实现代码必须单独处理每个部分而不是处理一个值

ISO 8601

国际标准化组织 (ISO) [ 4 ] 定义了一项“涵盖全球范围日期和时间相关数据交换与通信的国际标准”——ISO 8601 [ 5 ]。这项规范非常全面,支持持续时间和时间间隔。

以下是datetimeISO 8601 格式的时区的一些示例:



2022-09-12T14:42:04+00:00
2022-09-12T14:42:04Z


Enter fullscreen mode Exit fullscreen mode

优点:

  • 被广泛接受的标准

缺点:

  • 一些开发人员可能会采用字符串操作
  • 字符串在数据库中的权重较大
  • 用户仍然需要考虑时区
  • 同一个时刻可以有多个值表示。例如1994-11-05T08:15:30-05:001994-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,
}


Enter fullscreen mode Exit fullscreen mode

优点:

  • Unix 时间不需要用户考虑时区
  • Unix 时间是一个单符号数字
    • 由于整数比字符串更易于查询、索引且更节省空间,因此日期通常以 64 位整数的形式存储在数据库中
    • 1970 年 1 月 1 日之前的时刻使用负数
  • Unix 时间具有单一格式
  • Unix 时间不受夏令时影响
  • Unix 时间是广泛支持的标准
  • 使用date-fnsday.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


Enter fullscreen mode Exit fullscreen mode


{
  "id": "sub_1LhfXp2Tb35ankTnpxovpHQs",
  "cancel_at": 1662738023
}


Enter fullscreen mode Exit fullscreen mode

🟢好: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


Enter fullscreen mode Exit fullscreen mode


{
  "id": "person_1LhfZ7",
  "first_name": "Jenny",
  "last_name": "Rosen",
  "dob": {
    "year": 1901,
    "month": 2,
    "day": 12
  }
}


Enter fullscreen mode Exit fullscreen mode

🔴不好:使用任何其他表示日期或时间的格式。

Stripe 的 API 命名datetime字段设计模式

时间戳字段被命名 **<verb>ed_at**

_at模式表明整数始终是 Unix 时间戳。_at非时间戳字段不允许使用后缀。某些字段(例如created所有资源上的时间戳)不符合该_at格式,并且可能早于此设计模式。

🟢好: subscription.canceled_atinvoice.finalized_at

🔴不好 subscription.canceled,,subscription.canceled_onsubscription.cancellation_date

  • 对于未来的时间戳,最好使用<verb>s_at;例如file_link.expires_at。但请注意,未来的时间戳可能暗示着 SLA。
  • 这只是一般准则,有一些充分的理由可以打破它。例如,dispute_evidence_details.due_bycoupon.redeem_byissuing_card_shipping.eta不使用_at后缀,但更清晰。

到期时间表示为 **expires_at: <unix timestamp>**

🟢好: file.expires_at

🔴不好: file.expires_afterfile.expiration_date

  • 如果过期时间由 cronjob 处理,那么 cronjob 稍微延迟是可以接受的。例如,如果某个status字段从 变为pendingexpired而用户在 之后不久获取该对象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}
);


Enter fullscreen mode Exit fullscreen mode

代码示例

如何将 unix 时间戳转换为通用语言中的本机datetime或对象。datetime

红宝石



Time.at(1662759630)


Enter fullscreen mode Exit fullscreen mode

Python



from datetime import datetime
datetime.utcfromtimestamp(1662759630)


Enter fullscreen mode Exit fullscreen mode

PHP



DateTime::createFromFormat( 'U', 1662759630 )


Enter fullscreen mode Exit fullscreen mode

JavaScript



new Date(1662759630 * 1000)


Enter fullscreen mode Exit fullscreen mode



time.Unix(1662759630, 0)


Enter fullscreen mode Exit fullscreen mode

Java



new java.util.Date((long)1662759630 * 1000);


Enter fullscreen mode Exit fullscreen mode

。网



DateTimeOffset.FromUnixTimeSeconds(1662759630);

Enter fullscreen mode Exit fullscreen mode




结论

Unix 时间戳和结构化的出生日期对象为 API 使用者带来了诸多益处。我们始终保持灵活,并乐于调整以寻求更好的服务方式。请告知我们您在 API 中处理日期和时间数据的首选方法。

关于作者

CJ阿维拉

CJ Avilla ( @cjav_dev ) 是 Stripe 的开发倡导者、Ruby on Rails 开发者和YouTuber。他热爱学习和教授新的编程语言和 Web 框架。不在电脑前时,他会陪伴家人或骑自行车🚲。

聚苯乙烯

您是否认为有一天墓碑上会有 unix 时间戳?

珍妮·罗森 (Jenny Rosen),生活在554805154- 3067403554,要警惕三月十五日。

墓碑图,带有出生和死亡日期的 unix 时间戳

鏂囩珷鏉yu簮锛�https://dev.to/stripe/how-stripe-designs-for-dates-and-times-in-the-api-3eoh
PREV
如何使 ESLint 与 Prettier 协同工作以避免冲突和问题
NEXT
.env 环境变量不起作用的 5 个原因