发布于 2026-01-06 5 阅读
0

Stripe 如何设计 API 中的日期和时间信息

Stripe 如何设计 API 中的日期和时间信息

介绍

API 是为开发者设计的——成功的API 都以开发者体验为核心。良好的开发者体验离不开一致性,无论是路径名称的设计(全部为 `/`snake_case或 `/ kebab-case`),还是每个实体都拥有一个id带有唯一标识符的属性,都体现了这一点。在向 API 添加新功能时,参考现有模式至关重要,这样才能提供可预测的接口。今天,你将学习 Stripe 工程师在向 API添加日期和时间字段时使用的设计模式。

假设您想为订阅 API 添加一项新功能。用户反馈说,他们面临的挑战之一是无法在稍后取消订阅。他们目前使用 cron 作业构建复杂的变通方案,但他们更希望能够直接告诉 Stripe 何时应该取消订阅。以下是产品的基本规范:

在使用 API 创建或更新订阅时,用户可以指定一个未来的日期和时间,以便在该日期和时间取消订阅;如果用户从 API 检索订阅,他们就可以知道订阅是否已被取消。

让我们来详细分析一下:

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

这项任务至少涉及三个功能的实现,它将使我们能够讨论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

颜色 订单样式 结尾
青色 DMY L
黄色的 年月 B
品红 MDY M
绿色的 日月年月
蓝色的 DMY,MDY L,M
红色的 MDY,YMD M,B
灰色的 MDY、YMD、DMY M、B、L

存储本地时区数据无法扩展。

如果数据库中存储的数据使用本地时区datetime,那么尝试跨多个区域扩展就会引入新的问题。

12 小时制与 24 小时制

24小时制是标准时间,但在曾经是英国殖民地的国家中,12小时制更为普遍[ 2 ]。在一些地方,口头上使用12小时制,但书面材料中使用24小时制。

闰年

我们绕太阳公转一周的时间是365天5小时59分16秒,而不是标准的365天。公历中的闰年是通过在每个4的整数倍年份中增加天数来计算的(除了能被100整除但不能被400整除的年份)[ 3 ]。罗马时代的儒略历通过每四年一次的闰日来解决这个问题,闰是2月24日,持续48小时,参见“bissextile ”!

本文草稿中的列表更长,希望这个精简版能让您信服。接下来,我们将讨论可扩展的 API 设计,这些设计可以避免时区陷阱、闰秒问题,并且能够被世界各地的人使用。

API中处理日期和时间的选项

目前大多数 Web API 都使用 JSON 进行序列化和反序列化。JSON 本身没有原生的 `int` datetime`int` 或datetime标量数据类型,因此我们需要使用 `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 或晚上 8: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 ]。该规范非常全面,包括对持续时间和时间间隔的支持。

以下是一些datetime采用 ISO 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 时间(也称为 Epoch 时间、Posix 时间、自 Epoch 以来的秒数、Unix 时间戳或 UNIX Epoch 时间)是描述时间点的系统。它是自 Unix Epoch 以来经过的秒数,不包括闰秒。Unix Epoch 为 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 中以时间戳字段和参数的形式表示。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` 和 `or`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 APIdatetime字段命名设计模式

时间戳字段已命名 **<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)。
  • 这只是一个通用准则,有些情况下可以打破它。例如,`\n` dispute_evidence_details.due_bycoupon.redeem_by`\n` 和 ` issuing_card_shipping.eta\n` 不使用_at后缀,但使用 `\n` 更清晰。

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

🟢好: file.expires_at

🔴不好: file.expires_afterfile.expiration_date

  • 如果过期时间由定时任务处理,那么定时任务稍微延迟是可以接受的。例如,如果某个status字段的值从 变为pendingexpired而用户在 之后过一段时间才获取该对象expires_at,那么他们仍然看到 是可以接受的pending
  • 务必仔细检查expires_at操作执行时间。例如,如果某个 URL 指向托管页面,我们应该检查expires_at该托管页面何时加载。
  • 为什么不行呢expires_after?它可能会让人误解为“N天后过期”,而不是“在给定时间或之后不久过期datetime”。此外,它也不鼓励上面提到的更严格的运行时检查;用户喜欢我们尽可能提供的更严格的合约。

开发者体验改进

语法糖快捷方式

Stripe 还为处理日期添加了一项非常实用的开发者体验增强功能。某些端点接受“魔法字符串”代替整数时间戳,从而简化了操作。例如,向 API 传递日期时间时,一个常见的用例是传递当前日期和时间。如果您知道始终要传递“现在”,则无需计算,只需使用“魔法字符串”即可,**now**例如用于设置计费周期锚点,该锚点决定了每月订阅费用的收取日期 [ 8 ]。

您可以想象一下,还可以通过其他方式扩展此功能,使其接受诸如 `\ subscription_endn` 或 `\n` 之类的参数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 框架。闲暇时,他喜欢陪伴家人或骑自行车出行🚲。

PS

你认为有一天墓碑上会刻有 Unix 时间戳吗?

珍妮·罗森,生活在554805154—— 3067403554,当心三月十五日。

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

文章来源:https://dev.to/4thzoa/how-stripe-designs-for-dates-and-times-in-the-api-3eoh