API 设计
客户端 API 接口必须充分考虑,因为它是用户和您的服务之间的最主要交互。
命名空间
有些语言用命名空间的概念来将相关类型分组。在云基础设施中对服务分组是常见的,因为它有助于发现,并为参考文档提供结构。
☑️ 您应该 如果使用命名空间在语言生态系统中很常见,则支持命名空间。
✅ 务必 使用 .. 形式的根命名空间。所有普遍使用的面向使用者的 API 都应该在此命名空间。 命名空间由三部分组成:
表示所有 Azure 服务的通用前缀。它可能是 Azure 或 com.azure 或类似的,这取决于语言的常见形式。
是服务的组。请参阅下面的列表。
是缩写服务名。
✅ 务必 选择缩写服务名称,允许使用者把仓库和正在使用的服务联系起来。默认情况下,使用压缩的服务名称。命名空间不会在产品品牌更改时更改,因此避免使用可能更改的营销名称。
压缩的服务名称指没有空格的服务名称。如果缩短的版本在社区中更广为人知,它还可以进一步缩短。例如,“Azure Media Analytis” 有一个压缩的服务名称 MediaAnalytics,而“Azure Service Bus”则变成 ServiceBus。
✅ 务必 使用下述列表作为服务的组(如果目标语言支持命名空间):
ai
人工智能,包括机器学习
analytics
为度量或使用分析数据
containers
与容器相关的服务
communication
通信服务
data
处理结构化数据存储,如数据库
diagnostics
数字双胞胎,物理空间和 IoT 设备的数字表示
identity
认证与授权
iot
物联网
management
控制平面(Azure 资源管理器)
media
音视频技术
messaging
信息服务,比如推送通知或发布订阅
mixedreality
混合现实技术
monitor
Azure 监视器提供的服务
quantum
量子计算技术
search
搜索技术
security
安全和加密
storage
非结构化数据存储
如果客户端库看起来不适用于组列表,请联系架构委员会来讨论命名空间的需求。
✅ 务必 将管理器(Azure 资源管理器)API 放在管理组。对命名空间使用分组 .management.. 。由于需要控制平面 API 的服务多于需要数据平面 API 的服务,因此其他命名空间可能只显式地用于控制平面。数据平面的使用只是出于例外。其余能用于控制平面 SDK 的命名空间包括:
appmodel
应用模块,例如函数或应用框架
compute
虚拟机、容器和其他计算服务
integration
集成服务,(例如逻辑应用程序)
management
管理服务(例如成本分析)
networking
服务如VPN、WAN和网络
许多管理 API 没有数据平面,因为它们处理 Azure 账户的管理。将管理库放在 .management 命名空间。例如,使用 azure.management.costanalysis 而不是 azure.management.management.costanalysis 。
⛔️ 请勿 为做不同事情的客户端选择相同的名称。
✅ 务必 向架构委员会注册选定的命名空间。新建一个问题来请求命名空间。有关当前已注册命名空间列表,请参见当前已注册命令空间列表。
示例命名空间
下面是一些符合这些准则的示例命名空间:
Azure.Data.CosmosAzure.Identity.ActiveDirectoryAzure.IoT.DeviceProvisioningAzure.Storage.BlobsAzure.Messaging.NotificationHubs(通知中心的客户端库)Azure.Management.Messaging.NotificationHubs(通知中心的管理库)
下面是一些不符合准则的命名空间:
Microsoft.Azure.CosmosDB(不在Azure命名空间,且没有使用分组)Azure.MixedReality.Kinect(分组不在批准列表中)Azure.IoT.IoTHub.DeviceProvisioning(组里有太多层级)
客户端接口
API 表面将包含一个以上的服务客户端,用户将实例化这些客户端以连接到您的服务,另外还包含一组支持类型。
✅ 务必 使用 client 后缀来命名服务客户端类型。
有时候,有些操作需要添加可选数据,这些数据俗称“选项包”。客户端库需要努力做到命名一致。
☑️ 您应该 使用 client_options 后缀命名服务客户端选项包的类型。
☑️ 您应该 使用 options 后缀命名操作选项包类型。例如,如果操作是 get_secret,那么选项包类型将被称为 get_secret_options。
✅ 务必 将使用者最有可能与之交互的服务客户端类型放在根命名空间(假设目标语言支持命名空间)。专门的服务客户端可以放在子命名空间。
✅ DO allow the consumer to construct a service client with the minimal information needed to connect and authenticate to the service.
✅ 务必 允许使用者使用最少的用于连接和认证服务的信息来构建服务客户端。
✅ 务必 标准化服务的一整套客户端库的动词前缀。服务能够在对外材料(例如文档、博客和公共演讲)中以跨语言的方式谈论一个特定操作。如果不同语言中的不同动词引用了相同操作,它们就不能这样做。
下面是标准动词前缀。你应该有一个很好的(清晰的)理由为这些操作之一使用替换动词。例如,.NET 使用 Get<noun>s 替代 List<noun>s 因为它是该语言的惯用词。
create_<noun>
key, item
Created item
创建一个新项目。若该项目已经存在,则失败。
upsert_<noun>
key, item
item
Create new item, or update existing item. Verb is primarily used in database-like services. 创建新项目,或更新现有项目。动词主要用于类似数据库的服务。
set_<noun>
key, item
item
Create new item, or update existing item. Verb is primarily used for dictionary-like properties of a service. 创建新项目,或更新现有项目。动词主要用于服务的类似字典的属性。
update_<noun>
key, partial item
item
该项目不存在,则失败。
replace_<noun>
ey, item
item
替换现有项目。若该项目不存在,则失败。
append_<noun>
item
item
向集合中添加新项目。项目将添加到最后。
add_<noun>
index, item
item
向集合中添加新项目。项目将添加到给定索引处。
get_<noun>
key, item
item
若项目不存在,则引发错误。
list_<noun>
Pageable[Item]
返回该项目的可迭代对象。若项目不存在,返回空项目的可迭代对象。
<noun>_exists
key
boolean`
若该项目存在,返回 Ture。若该方法无法判断项目是否存在,则必须引发错误(例如,服务返回 HTTP 503 响应)。
delete_<noun>
key
item
删除一个已存在项目。即使项目不存在也必须成功。
remove_<noun>
key
removed item
从集合删除一个项目的引用。该方法不删除实际项目,只删除引用。
✅ 务必 100%支持客户端表示的由 Azure 服务提供的所有特性。功能缺失会导致开发者感到困惑和沮丧。
服务 API 版本
客户端库的目的是和 Azure 服务通信。Azure 服务支持多个 API 版本。要理解服务的功能,客户端库必须能够支持多个服务 API 版本。
✅ 务必 在发布稳定版本的客户端库时,只针对普遍可用的服务 API 版本。
✅ 务必 默认情况下,稳定版本的客户端库只针对最新的普遍可用的服务 API 版本。
✅ 务必 记录默认使用的服务 API 版本。
✅ 务必 在发布客户端库的公共测试版时,默认以最新的公共预览 API 版本为目标。
✅ 务必 在 ServiceVersion 枚举值上包含客户端库支持的所有服务 API 版本。
✅ 务必 确保 ServiceVersion 中的枚举值匹配服务 Swagger 定义中的版本字符串。
✅ 务必 在服务返回的任意 URI(例如 Operation-Location、下一页链接等)中增加或替换 api-version 查询参数,并在客户端上配置传递的服务版本。
为了满足该需求,允许语义的更改。例如,许多版本字符串基于 SemVer,它允许点和破折号。然而,标志符中不允许使用这些字符。当服务版本被设置为 ServiceVersion 枚举中的每个值时,开发者必须能够清楚地知道将使用哪个服务 API 版本。
模型类型
客户端库将传入和传出 Azure 服务的实体表示为模型类型。某些类型用于往返服务。它们可以发送到服务(作为增加或更新操作)和从服务中检索(作为获取操作)。这些应该根据类型命名。例如,应用程序配置的 ConfigurationSetting 或事件 网格的 Event。
在模型类型中的数据通常可以分为两部分 - 用于支持服务的主要场景之一的数据,以及不太重要的数据。给定一个 Foo 类型,不太重要的细节可以收集到名为 FooDetails的类型, 并作为 details 附加到 Foo 上。
例如:
操作的可选参数和设置应该收集到名为 <operation>Options 的选项包。例如,GetConfigurationSetting 方法可能会采用 GetConfigurationSettingOptions 来指定可选参数。
Results should use the model type (e.g. ConfigurationSetting) where the return value is a complete set of data for the model. However, in cases where a partial schema is returned, use the following types: 结果应该使用模型类型(例如 ConfigurationSetting),当返回值是模型的一系列完整数据。但是,如果返回部分模型,请使用下述类型:
如果枚举返回模型的部分模型,则为枚举中的每个项返回
<model>item。例如,GetBlobs()返回BlobItem的枚举,该枚举包括 blob 名称和元数据,但不包含 blob 的内容。为每个操作结果返回
<operation>Result。<operation>绑定到特定的服务操作。如果同一结果可用于多个操作,使用适合的名词-动词短语代替。例如,UploadBlob的结果使用UploadBlobResult,而改变一个 blob 容器的各种方法的结果使用ContainerChangeResult。
下表列举了可能创建的各种类型:
Secret
资源的完整数据
Details
SecretDetails
资源的不太重要的详细信息。附加到 .details
Item
SecretItem
为枚举返回的部分数据集
Options
AddSecretOptions
单个操作的可选参数
Result
AddSecretResult
单个操作的部分或不同数据集
Result
SecretChangeResult
用于模型里的多个操作的部分或不同数据集
网络请求
由于客户端库通常包装一个或多个 HTTP 请求,因此支持标准的网络能力非常重要。虽然异步编程技术在开发可伸缩的网络服务非常必要,并在某些环境中是必需的(例如手机或 Node 环境),但是它们没有得到广泛理解。当学习如何使用一项技术时,许多开发者更喜欢同步方法调用,因为它们简单的语义。此外,使用者已经开始期望网络栈中的某些功能 - 例如取消调用、自动重试、日志等功能。
✅ 务必 同时支持同步和异步方法调用,除非语言或者默认运行时不支持一种。
✅ 务必 确保使用者能够识别出哪个方法是异步的,哪个方法是同步的。
当应用程序发起一个网络请求,网络基础设施(如路由器)和被调服务可能需要很长时间来响应,实际上,可能永远不会响应。编写良好的应用程序应该永远不要放弃对于网络基础设施或服务的控制。这里有一些例子来说明为什么这一点如此重要:
当协调器需要终止一个服务(由于缩容、重新配置或更新到新版本),协调器常常会通知通过发送 Posix SIGINT 来通知正在运行的服务实例。当服务接受到此信号,它应该通过设置一个取消机制来尽可能快和优雅地终止,该机制由当前正在进行的所有网络操作执行。
当使用者的网络服务接受到请求,它可能会设置一个时间限制表示它给用户发出响应前允许的最长时间。
当使用者的 GUI 应用程序通过我们的 SDK 向 Azure 服务发出请求,GUI 可能会提供一个取消按钮,以便终端用户可以表示他们不再等待操作或者操作完成。
使用者处理取消的最佳方法是将取消对象想象成一棵树。例如:
取消父节点时自动取消子节点。
子节点可以比父节点更早超时,但不能延长总时间。
取消可以因为超时或者手动/显式操作而发生。
下面是一个说明应用程序如何使用取消树的例子:
当应用程序启动,它应该代表整个应用程序创建一个取消对象;此对象在接收到 SIGINT 通知时显式终止。
当网络服务器接收传入请求,它将创建一个新的取消对象,该对象是应用程序节点的子节点。新的取消对象将指定网络服务器允许对请求操作的最长时间。
作为操作传入请求的一部分,网络服务器可能需要向其他服务(例如数据库)发出多个请求。如果这些请求可以串行或并行发出,那么它们可能共享之前创建的取消对象。然而,如果网络服务器想限制花在一个或者多个请求上的时间,它可以创建新的取消对象(带有所需的超时值)并让该对象成为传入节点的子节点;这样,当整个请求超时或是超过该操作的最大时间时,单个请求会过期 - 以先发生的为准。
注意,当多个请求以并行方式发出,使用者通常希望当其中一个请求失败时取消所有请求。这应该是一种被支持的场景。
✅ 务必 在所有异步调用中接受所有平台原生的取消令牌(实现超时)。
☑️ 您应该 只在 I/O 调用(例如网络请求和文件传输)中检查取消令牌。不要在客户端库进行 I/O 调用时(例如,正在处理 I/O 调用的数据)检查取消令牌。
⛔️ 请勿 向使用者泄漏底层协议传输实现细节。必须对所有协议传输实现类型恰当地抽象。
身份验证
Azure 服务使用各种不同的身份验证方案来允许客户端访问服务。在概念上,有两个实体负责此过程:凭据和验证策略。凭据提供机密的验证数据。验证策略使用凭据提供的数据来验证对服务的请求。
首先,所以 Azure 服务都应该支持 Azure Active Directory OAuth 令牌身份验证,并且所有客户端必须支持以这种方式方式验证请求。
✅ 务必 提供一个服务客户端构造函数或者工厂,接受来自 Azure 核心的 TokenCredential 抽象实例。
⛔️ 请勿 持久化、缓存或重复使用令牌凭据返回的令牌。这是 至关重要的,因为凭据的有效期通常很短,而令牌凭据负责刷新这些凭据。
✅ 务必 使用 Azure 核心库中可用的身份验证策略实现。
✅ 务必 保留 TokenCredential 验证需要的 API 接口,在少数情况下服务可能没有支持 Azure Active Directory 身份验证。
除了 Azure Active Directory OAuth,服务还可以提供自定义身份验证模版。这种情况下,以下准则适用。
✅ 务必 支持服务支持的所有身份验证方案。
✅ 务必 定义一个公共的自定义身份验证类型,让客户端可以使用自定义方案验证请求。
⚠️ 您不应该 定义自定义凭据类型,继承或者实现来自 Azure 核心的 TokenCredential 抽象。在类型安全语言中尤其如此,继承或实现这个抽象会打破其他服务客户端的类型安全,允许使用者用错误服务的自定义凭据来实例化它们。
✅ 务必 在和客户端相同的命名空间和包中定义自定义的凭据类型,或在服务组命名空间和共享包中,而不是在 Azure Core 或 Azure Indentity。
✅ 务必 在自定义凭据类型名称前面加上服务名称或服务组名称,以便为预期的范围和用法提供清晰的上下文。
✅ 务必 将 Credential 附加到自定义凭据类型名称的末尾。注意必须要是单数而不是复数。
✅ 务必 为自定义凭据类型定义一个构造函数或工厂,该类型接收自定义身份验证协议所需的所有数据。
✅ 务必 定义一个接收所有可变凭据数据的更新方法,并以原子的、线程安全的方式来更新凭据。
⛔️ 请勿 定义允许使用者以非原子的操作直接更新身份验证数据的公共可设置属性或字段。
⚠️ 您不应该 定义允许使用者直接访问身份验证数据的公共属性或字段。它们很多时候不被终端使用者需要,并且难以线程安全的方式使用。在需要公开身份验证数据的情况下,所有身份验证请求需要的数据应该从单个 API 中返回,以保证返回数据处于一致状态。
✅ 务必 提供接受所有支持的凭据类型的服务客户端构造函数或工厂。
客户端库仅当服务通过门户网站或者其他工具提供连接字符串,才支持通过连接字符串提供凭据数据。连接字符串通常有利于入门,因为它们可以通过从门户网站复制/粘贴轻松地集成到应用程序。但是,连接字符串被认为是一种较低形式的身份验证,因为凭据不能在正在运行的程序中轮换。
⛔️ 请勿 支持构造带有连接字符串的服务客户端,除非这种连接字符串在工具中可用(用于复制/拷贝操作)。
响应格式
对服务的请求分为两个基础组 - 发出单个逻辑请求的方法,或是发出确定性请求序列的方法。单个逻辑请求的一个示例是可以在操作中重试的请求。确定性请求序列的一个示例是分页操作。
逻辑实体是响应的中立表示协议。对于 HTTP,逻辑实体可以包括从头部、正文和状态行的数据。一个常见的示例是将 ETag 头部公开为逻辑实体中的属性以及正文中的任何反序列化内容。
✅ 务必 优化用于返回给定请求的逻辑实体。逻辑实体必须表示99%+的情况下需要的信息。
✅ 务必 允许使用者获取完整的响应,包括状态行、头部和正文。客户端库必须遵循语言特定的准则来实现。
✅ 务必 对如何访问给定请求的原生的响应和流响应提供示例,在客户端库中公开。我们并不期望所有方法都公开流响应。
✅ 务必 提供一种语言惯用的方式来为分页操作枚举所有逻辑实体,需要时自动获取新页。
例如,在 Python 中:
对于将多个请求组合成单个调用的方法:
✅ 务必 返回头部和其他每个请求的元数据,除非方法返回值对应哪个特定的 HTTP 请求是显而易见的。
✅ 务必 在失败案例中提供充足的信息,以便应用程序能采用适当的纠正措施。
⚠️ 您不应该 在逻辑实体返回的模型中,使用常见的保留字作为属性名称。例如:
objectvalue
这种用法将引起混淆,并且不可避免地必须在每种语言的基础更改,这可能会导致一致性问题。
条件请求
条件请求一般使用 HTTP 头部表示。主要用法是提供头部来匹配 ETag 到一些已经值。ETag 是一个不透明的标志符,表示资源的单个版本。例如,添加下述头部将转换成“如果 Etag 指定的记录版本不同”。
对于头部,可以对以下内容测试:
无条件地(无附加头部)
如果(未)修改自某版本(
If-Match和If-Not-Match)如果(未)修改自某个日期(
if-Modified-Since和If-unmodified-Since)如果(不)存在(
If-Match和If-Not-Match带有Etag=*值)
并非所有服务都支持所有的语义,并且可能不支持其中任何一个。开发者对 Etag 和条件请求的理解程度不同,因此最好从 API 表面抽象这些概念。我们需要关注两种类型的条件请求:
安全的条件请求(例如 GET)
这通常用于在“更新缓存”的场景中节省带宽,例如,我有一个缓存值,仅当服务比我的副本更新的情况下才向我发送数据。它们返回200或304的状态码,表示值未被修改,来告诉调用方它们的缓存值是最新的。
不安全的条件请求(例如 POST、PUT 或 DELETE)
它们往往用于防止在乐观并发场景下丢失更新,例如,我已经修改了我持有的缓存值,但不更新服务版本,除非它与我拥有的副本相同。它们返回成功或412错误状态码,表示值已被修改,来告诉调用方如果他们希望更新成功,需要重试更新。
这两种情况在客户端库的处理方式不同。但是,两种情况的调用形式是相同的。该方法的签名应该是:
requestOptions 字段为 HTTP 请求提供前提条件。如果可能,将从传入方法的项中检索 Etag 值,否则从方法参数中检索。方法的形式将基于选中语言的习惯使用模式来修改。当 Etag 未知,操作不能是条件的。如果库开发者不需要支持前置条件头的高级用法,他们可以增加一个设置为 true 的布尔参数来建立条件。例如,使用下述布尔名称之一替代条件运算符:
onlyIfChangedonlyIfUnchangedonlyIfMissingonlyIfPresent
在所有情况下,条件表达式是 “opt-in”,默认情况是无条件执行。
必须仔细考虑条件操作的返回值。对于安全操作(例如 GET),如果值被读取将返回响应(或遵循用于 204 No Content 响应的相同约定),因为在正文中没有要引用的值。对于不安全操作(例如 PUT、DELETE 或 POST),当接收到 Precondition Failed 或 Conflict 结果时,抛出特定错误。这允许使用者在结果冲突的情况下做一些不同的事情。
☑️ 您应该 根据需要在服务方法上接受条件布尔值或枚举参数,以启用使用 ETag 进行条件检查。
☑️ 您应该 当支持条件操作时,将 ETag 字段作为对象模型的一部分。
⚠️ 您不应该 当从服务接收到 304 Not Modified 响应时抛出错误,除非这样的错误是语言惯用的。
☑️ 您应该 因为条件检查而从服务接收到 412 Precondition Failed 或 409 Conflict response 响应时,抛出明显的错误。
分页
Azure 客户端避免低层次的分页 API ,而使用高层次抽象的迭代器。高层次的 API 对于开发者使用大多数用例是很轻松的,但是在需要细粒度控制(例如,过度配额/节流)和在出现问题时进行调试,可能会让人更加迷惑。文档的其他准则有助于减轻这种限制,例如通过提供健壮的日志记录、跟踪和管道自定义选项。
✅ 务必 使用语言标准迭代器在页内的项上公开分页集合。用于公开异步迭代器的 API 依赖于语言,但也应该和在你的系统中的任意现有公共实践保持一致。
✅ 务必 如果异步迭代器不是您的语言内置特性,请使用迭代器或游标模式来公开分页集合。
✅ 务必 以非分页列表端点相同的方式公开分页列表端点。用户不应该需要理解其中的差异。
✅ 务必 如果列表端点的实体和从 get 端点的返回实体是不同的类型,则为这些实体使用不同的类型,否则在这些情况下您必须使用相同的类型。
重要:服务应该避免在列表中的特定实体有不同类型与对该单个项目的 GET 请求的结果之间存在差异,因为这使得客户端库的 API 更简单。
⛔️ 请勿 公开在每个单独项上的迭代器,如果获取每个项需要一个发送给服务的对应的 GET 请求。每个项一个 GET 请求通常太昂贵,因此我们不想代表用户采取这种行动。
⛔️ 请勿 公开一个 API 以获取在列表中的分页集合。对于可能返回许多页的服务来说,这是一种危险的能力。
✅ 务必 当在集合遍历时,公开分页 API。分页 API 必须接受一个延续令牌(从之前的运行中)和项可以返回的最大数字,并且必须返回一个延续令牌作为响应的一部分,这样迭代器可以继续,可能在不同的机器上。
长时间运行的操作
长时间运行的操作包含一个初始化请求,该请求启动操作然后通过轮询来确认操作是否已经完成或失败。长时间操作操作往往遵循 对于耗时操作的 REST API 准则,但也有例外。
✅ 务必 使用一些对象来表示长时间运行的操作。这些对象封装了轮训和操作状态。这种对象,称为轮询器,必须为以下方面提供 API:
查询当前的操作状态(可以异步查询服务,也可以同步不查询服务)
当操作完成时请求异步通知
如果服务支持取消,则取消操作
对操作不感兴趣,因此轮询停止
手动触发轮询操作(必须禁用自动轮询)
进程报告(如果服务支持)
✅ 务必 支持下述轮询配置选项:
pollInterval
轮询配置只能在缺少来自服务的相关重试头部的情况下使用,否则应被忽略。
✅ 务必 在返回一个轮询器的方法名称使用 begin 或 start 前缀。语言特定的准则将规定使用哪个动词。
✅ 务必 提供一种方法来实例化具有另一个轮询器的序列化状态的轮询器,从它停止的地方开始,例如,通过将该状态作为参数传递给启动操作的同一方法,或者直接实例化具有该状态的轮询器。
⛔️ 请勿 通过取消令牌来请求取消时,取消长时间运行的操作。并且,取消令牌正在取消长时间运行的操作时,不应该对服务产生任何其他影响。
✅ 务必 使用 Info 级别的日志打印轮询状态 (包括下次重试的时间)。
✅ 务必 如果服务进度报告作为轮询操作的一部分,对使用者公开进度报告机制。在这种情况下,语言相关指南将提供有关如何公开进度报告的额外指南。
对非 HTTP 协议的支持
大部分 Azure 服务通过 HTTPS 公开 RESTful API。但是,一些服务使用其他协议,例如 AMQP、MQTT 或 WebRTC。在这些情况下,协议的操作可以分为两个阶段:
每个连接(围绕连接启动和终止的时间)
每个操作(围绕通过打开连接发送操作的时间)
添加到 HTTP 请求/响应的策略(身份验证、唯一请求 ID、遥测、分布式追踪、日志打印)在每个连接和每个操作的基础上仍然有效。然而,实现这些策略的方法取决于协议。
✅ 务必 在每个连接和每个操作的基础上实现尽可能多的策略。
例如,在 WebSockets 上的 MQTT 提供了在初始化 WebSockets 连接的时候增加头部的能力,因此这是一个增加身份验证、遥测和分布式追踪策略的好地方。然而,MQTT 没有元数据(相同于 HTTP 头部),因此不可能执行每个操作的策略。根据契约,AMQP 确实具有每个操作的元数据。可以使用 AMQP 在每个操作的基础上提供唯一请求 ID 和分布式追踪头部。
✅ 务必 有关非 HTTP 协议的策略决策,请向架构委员会咨询。预计所有策略都会实现。如果协议不支持某个策略,请从架构委员会获取例外。
✅ 务必 使用在 Azure 核心库中建立的全局配置来为非 HTTP 协议配置策略。使用者不一定知道客户端库使用什么协议。他们希望客户端库遵循他们为整个 Azure SDK 建立的全局配置。
Last updated
Was this helpful?