STOMP 协议规范

简介

STOMP (Simple (or Streaming) Text Oriented Message Protocol ) 是一种在客户端与中转服务端之间进行异步消息传输的简单通用协议; 它定义了服务端与客户端之间的格式化文本传输方式;

STOMP 已经被使用好多年, 现在被许多消息中间件与客户端工具所支持。最新版的协议已经从1.1更新为1.2;如果您有任何问题想反馈, 请发送内容到邮件列表 [email protected]


概述

背景

STOMP的衍生环境是, 有许多脚本语言比如Ruby, Python and Perl 需要连接访问企业消息服务器, 在这个环境下就要求, 发送一个单独的消息, 断开连接与消费所有接受到的消息等这些操作都是简单且可靠的;

STOMP是其他开放消息传递协议的替代方案,比如AMQP和JMS代理(如OpenWire)中使用的实现专用线协议。它通过覆盖常用消息传递操作的一小部分而不是提供全面的消息传递API来与其他消息传递方案区分。

STOMP已经发展为一个成熟的网络传输协议, 它以较简单的方式提供与专用线路级协议同样的传输功能, 同时保持自身协议核心的简单性与互操作性;

协议内容

STOMP是基于帧的协议,其中帧是基于HTTP协议建模的。 一个帧由一个命令,一组可选的标题和一个可选的主体组成。 STOMP是基于文本的,但也允许二进制消息的传输。 STOMP的默认编码为UTF-8,但它支持为消息体指定替代编码。

STOMP服务器被定义为可以向其发送消息的一组目的地。

STOMP协议将目标视为不透明字符串,它们的语法是服务器实现特定的。 此外,STOMP没有定义目的地的传递语义应该是什么。 目的地的递送或“消息交换”语义可以随服务器,甚至从目的地到目的地而变化。 这允许服务器具有创新性,并且具有STOMP可以支持的语义。

STOMP客户端是可以以两种(可能同时)模式操作的用户代理:

  • 作为生产者,经由SEND帧将消息发送到服务器上的目的地
  • 作为消费者,发送用于给定目的地的SUBSCRIBE帧, 并且从服务器接收作为MESSAGE帧的消息。

协议兼容性

STOMP 1.2主要向后兼容STOMP 1.1。 只有两个不兼容的更改:

  • 现在可以用回车加上换行而不是换行来结束框线
  • 消息确认已经被简化并且现在使用专用报头

除了这些,STOMP 1.2没有介绍新的功能,但重点阐明规范的一些领域,如:

  • 重复帧头条目
  • 使用内容长度和内容类型头
  • 服务器需要支持STOMP帧
  • 连接延迟
  • 范围和订阅和事务标识符的唯一性
  • RECEIPT帧相对于先前帧的含义

协议设计理念

驱动STOMP设计的主要理念是简单性和互操作性。

STOMP被设计为轻量级协议,易于在客户端和服务器端以各种语言实现。 这意味着,特别地,对服务器的体系结构没有许多约束,并且诸如目的地命名和可靠性语义的许多特征是实现特定的。

在本规范中,我们将注意到STOMP 1.2没有明确定义的服务器的特性。 您应参阅STOMP服务器的文档,了解这些功能的具体实施细节。

相同点

本文档中的关键词“MUST”,“MUST NOT”,“REQUIRED”,“SHALL”,“SHALL NOT”,“SHOULD”,“SHOULD NOT”,“RECOMMENDED”,“MAY”和“OPTIONAL” 以如RFC 2119中所描述的来解释。

实现可以对无约束输入施加实现特定的限制。 以防止拒绝服务攻击,防止内存不足或解决平台特定的限制。

本规范定义的一致性类别是STOMP客户端和STOMP服务器。

STOMP 结构

STOMP是基于帧的协议,其承担下面的可靠的双向流网络协议(例如TCP)。 客户端和服务器将使用通过流发送的STOMP帧进行通信。 框架的结构如下:

COMMAND
header1:value1
header2:value2

Body^@

该框架以一个以行结束(EOL)结束的命令串开头,它由一个可选的回车符(八位字节13)和一个必须的换行符(八位字节10)组成。 命令之后是:格式中的零个或多个标题条目。 每个头条目由EOL终止。 空白行(即额外的EOL)指示头部的结束和主体的开始。 然后主体后面跟随NULL字节。 本文档中的示例将使用^ @,ASCII中的control- @来表示NULL字节。 NULL字节可以可选地后跟多个EOL。 有关如何解析STOMP帧的更多详细信息,请参阅本文档的增强BNF部分。

本文档中引用的所有命令和标题名都区分大小写。

报文结构

命令和标头以UTF-8编码。 除了CONNECT和CONNECTED帧之外的所有帧也将转义在生成的UTF-8编码头中找到的任何回车符,换行符或冒号。

需要转义以允许标题键和值将那些帧标题分隔的八位字节作为值。 CONNECT和CONNECTED帧不会转义回车,换行或冒号字节,以保持与STOMP 1.0的向后兼容性。

C样式字符串文字转义用于编码在UTF-8编码标头中找到的任何回车符,换行符或冒号。 当解码帧头时,必须应用以下变换:

  • \ r(八位字节92和114)转换为回车(八位字节13)
  • \ n(八位字节92和110)转换为换行(八位字节10)
  • \c(八位字节92和99)转换为:(八位字节58)
  • \\(八位字节92和92)转换为\(八位字节92)

未定义的转义序列,如\ t(八位字节92和116)必须被视为致命的协议错误。 相反,当编码帧报头时,必须应用逆变换。

报文体

只有SEND,MESSAGE和ERROR帧可能有一个主体。 所有其他帧不能有一个body。

标准报文头

一些头部可以使用,并且对于大多数帧具有特殊的意义。

报文头内容长度

所有帧可以包括内容长度报头。 此头是消息体长度的八位字节计数。 如果包括内容长度报头,则必须读取该八位字节的数量,而不管主体中是否存在NULL八位字节。 帧仍然需要以NULL字节终止。

如果存在帧体,则SEND,MESSAGE和ERROR帧应当包括内容长度头部以便于帧解析。 如果帧主体包含NULL字节,则帧必须包含内容长度头。

报文头内容类型

如果存在帧体,则SEND,MESSAGE和ERROR帧应该包括内容类型头部以帮助帧的接收器解释其主体。 如果设置了content-type头,它的值必须是描述body的格式的MIME类型。 否则,接收器应该将主体视为二进制blob。

以text /开头的MIME类型的隐含文本编码是UTF-8。 如果你使用一个基于文本的MIME类型与不同的编码,那么你应该附加; charset = 到MIME类型。 例如,如果您以UTF-16编码发送HTML正文,则应使用text / html; charset = utf-16。 ; charset = 也应该附加到可以解释为文本的任何非文本/ MIME类型。 一个很好的例子是UTF-8编码的XML。 它的内容类型应该设置为application / xml; charset = utf-8

所有STOMP客户端和服务器必须支持UTF-8编码和解码。 因此,为了在异构计算环境中实现最大的互操作性,建议使用UTF-8编码基于文本的内容。

报文接收标头

除CONNECT外的任何客户端框架可以指定具有任意值的接收标头。 这将使服务器用RECEIPT帧确认客户端帧的处理(更多细节参见RECEIPT帧)

SEND
destination:/queue/a
receipt:message-12345

hello queue a^@

重复头实体

由于消息传递系统可以被组织在存储和转发拓扑中,类似于SMTP,消息可以在到达消费者之前遍历多个消息传递服务器。 STOMP服务器可以通过将报头预先添加到消息或在消息中修改报头来更新报头值。

如果客户端或服务器接收到重复的帧头条目,则只有第一个头条目应该用作头条目的值。 后续值仅用于维护头的状态更改的历史记录,并且可以被忽略。

比如, 客户端收到这样的报文

MESSAGE
foo:World
foo:Hello

^@

上面foo的值就只是个单词;

大小限制

为了防止恶意客户端利用服务器中的内存分配,服务器可以设置最大限制:

  • 在单个帧中允许的帧报头的数量
  • 标题行的最大长度
  • 帧体的最大大小

如果超过这些限制,服务器应该向客户端发送ERROR帧,然后关闭连接。

连接延续

STOMP服务器必须能够支持快速连接和断开的客户端。

这意味着服务器可能只允许关闭的连接在连接被重置之前在短时间内停留。

因此,在套接字复位之前,客户端可能不会接收到服务器发送的最后一帧(例如,ERROR帧或RECEIPT帧对DISCONNECT帧的答复)。

连接

STOMP客户端通过发送CONNECT帧来发起到服务器的流或TCP连接:

CONNECT
accept-version:1.2
host:stomp.github.org

^@

如果服务器接受连接尝试,它将使用CONNECTED帧响应:

CONNECTED
version:1.2

^@

服务器可以拒绝任何连接尝试。 服务器应该回应一个ERROR帧,解释为什么连接被拒绝,然后关闭连接。

连接者或者STOMP结构

STOMP服务器必须以与CONNECT帧相同的方式处理STOMP帧。 STOMP 1.2客户端应该继续使用CONNECT命令保持与STOMP 1.0服务器的向后兼容性。

使用STOMP帧而不是CONNECT帧的客户端将只能连接到STOMP 1.2服务器(以及一些STOMP 1.1服务器),但优点是协议嗅探器/鉴别器能够区分STOMP连接和 HTTP连接。

STOMP 1.2客户端必须设置以下头:

  • accept-version:客户端支持的STOMP协议的版本。 有关详细信息,请参阅协议协商。
  • host:客户端希望连接的虚拟主机的名称。 建议客户端将此设置为建立套接字的主机名,或者选择任何名称。 如果此标头与已知虚拟主机不匹配,则支持虚拟主机的服务器可以选择默认虚拟主机或拒绝连接。

STOMP 1.2客户端可能需要设置以下头:

  • login:用于对受保护的STOMP服务器进行认证的用户标识符。
  • 密码:用于对受保护的STOMP服务器进行认证的密码。
  • 心跳:心跳设置。

    被连接者结构

    STOMP 1.2服务器必须设置以下头:

  • version:会话将使用的STOMP协议的版本。 有关详细信息,请参阅协议协商。

STOMP 1.2服务器可能需要设置以下头:

  • 心跳:心跳设置。
  • session:唯一标识会话的会话标识符。
  • server:包含有关STOMP服务器的信息的字段。 该字段必须包含一个服务器名称字段,并且可以后跟由空格字符分隔的可选注释字段。
    server-name字段由一个名称令牌和一个可选的版本号令牌组成。
    server = name [“/” version] *(comment)
    Example:
    server:Apache/1.3.9

    协议协商

    从STOMP 1.1开始,CONNECT帧必须包含accept-version头。 它应该设置为客户端支持的增量STOMP协议版本的逗号分隔列表。 如果accept-version头缺失,这意味着客户端只支持协议版本1.0。

将用于会话的其余部分的协议将是客户端和服务器都具有共同的最高协议版本。

例如, 如果客户端发送

CONNECT
accept-version:1.0,1.1,2.0
host:stomp.github.org

^@

服务器将回复与客户端共有的最高版本的协议:

CONNECTED
version:1.1

^@

如果客户端和服务器不共享任何公共协议版本,则服务器必须用类似于以下内容的ERROR帧响应,然后关闭连接:

ERROR
version:1.2,2.1
content-type:text/plain

Supported protocol versions are 1.2 2.1^@

心跳

心跳可以可选地用于测试底层TCP连接的健康性,并确保远程端存活和踢。

为了实现心跳,每个方都必须声明它能做什么以及对方想要做什么。 这发生在STOMP会话的最开始,通过向CONNECT和CONNECTED帧添加一个心跳头。

使用时,心跳头必须包含两个用逗号分隔的正整数。

第一个数字表示帧的发送者可以做什么(输出心跳):

  • 0意味着它不能发送心跳
  • 否则它是它能保证的心跳之间的最小毫秒数第二个数字表示帧的发送方想要获得(传入心跳):
  • 0表示不想接收心跳
  • 否则它是心跳之间所需的毫秒数

    心跳标题是可选的。 丢失的心跳头必须以与“心跳:0,0”头相同的方式对待,即:聚会不能发送并且不想接收心跳。

心跳头部提供足够的信息,使得每一方可以找出是否可以使用心跳,在哪个方向上以及使用哪个频率。

更正式地说,初始帧如下:

CONNECT
heart-beat:<cx>,<cy>

CONNECTED:
heart-beat:<sx>,<sy>

对于从客户端到服务器的心跳:

  • 如果是0(客户端不能发送心跳)或者是0(服务器不想接收心跳),那么将没有
  • 否则,将有每MAX(,)毫秒的心跳

在另一个方向,和以相同的方式使用。

关于心跳本身,通过网络连接接收的任何新数据是远程端存活的指示。 在给定的方向上,如果每n毫秒预期心跳 -

  • 发送者必须至少每隔几毫秒通过网络连接发送新的数据
  • 如果发送方没有要发送的实际STOMP帧,它必须发送行尾(EOL)
  • 如果在至少毫秒的时间窗口内,接收器没有接收到任何新数据,则其可以认为连接为死
  • 由于定时不准确,接收机应该容忍并考虑误差容限

Client Frames 客户端架构

客户端可以发送不在此列表中的帧,但是对于这样的帧,STOMP 1.2服务器可以响应ERROR帧,然后关闭连接。

  • SEND
  • SUBSCRIBE
  • UNSUBSCRIBE
  • BEGIN
  • COMMIT
  • ABORT
  • ACK
  • NACK
  • DISCONNECT

SEND 发送

SEND帧将消息发送到消息系统中的目标。 它有一个REQUIRED头,目的地,它指示在哪里发送消息。 SEND帧的主体是要发送的消息。 例如:

SEND
destination:/queue/a
content-type:text/plain

hello queue a
^@

这会将消息发送到名为/ queue / a的目标。 请注意,STOMP将此目标视为不透明字符串,并且目标的名称不承担递送语义。 您应该咨询您的STOMP服务器的文档,以了解如何构造一个目标名称,为您提供您的应用程序所需的交付语义。

消息的可靠性语义也是服务器特定的,并且将取决于正在使用的目的地值和其他消息报头,例如事务报头或其他服务器特定的消息报头。

SEND支持允许事务发送的事务头。

SEND帧应包含一个内容长度头和一个内容类型头(如果有一个主体)。

应用程序可以将任意任意用户定义的标头添加到SEND框架。 用户定义的头通常用于允许消费者使用SUBSCRIBE帧上的选择器基于应用定义的头过滤消息。 用户定义的头必须在MESSAGE帧中传递。

如果服务器由于任何原因无法成功处理SEND帧,则服务器必须向客户端发送一个ERROR帧,然后关闭连接。

SUBSCRIBE 订阅

SUBSCRIBE帧用于注册监听给定的目的地。 与SEND帧一样,SUBSCRIBE帧需要一个目的地首部,表示客户想要订阅的目的地。 在订阅的目的地上接收的任何消息此后将作为MESSAGE帧从服务器传送到客户端。 ack头控制消息确认模式。

Example:

SUBSCRIBE
id:0
destination:/queue/foo
ack:client

^@

如果服务器无法成功创建订阅,服务器必须向客户端发送一个ERROR帧,然后关闭连接。

STOMP服务器可以支持其他服务器特定的头来自定义订阅的交付语义。 有关详细信息,请参阅服务器的文档。

SUBSCRIBE id Header

由于单个连接可以具有与服务器的多个打开订阅,因此必须在帧中包括id标头以唯一地标识订阅。 id头允许客户端和服务器将后续MESSAGE或UNSUBSCRIBE帧与原始预订相关联。

在同一连接中,不同的订阅必须使用不同的订阅标识符。

SUBSCRIBE ack Header

ack头的有效值为auto,client或client-individual。 如果未设置标题,则默认为auto。

当ack模式是自动时,则客户端不需要为它接收的消息发送服务器ACK帧。 服务器将假设客户端一旦将其发送到客户端就已经接收到该消息。 此确认模式可能导致传输到客户端的消息被丢弃。

当ack模式是客户端时,客户端必须为它处理的消息发送服务器ACK帧。 如果在客户端发送消息的ACK帧之前连接失败,服务器将假定消息未被处理,并可能将消息重新传递给另一个客户端。 由客户端发送的ACK帧将被视为累积确认。 这意味着确认对ACK帧中指定的消息和在ACK确认消息之前发送到预订的所有消息进行操作。

如果客户端没有处理一些消息,它应该发送BACK帧,告诉服务器它没有消费这些消息。

当确认模式是客户端独立的时,确认操作就像客户端确认模式,除了由客户端发送的ACK或NACK帧不是累积的。 这意味着用于后续消息的ACK或NACK帧不得使得先前消息得到确认。

UNSUBSCRIBE 取消订阅

UNSUBSCRIBE帧用于删除现有订阅。 一旦删除订阅,STOMP连接将不再从该订阅接收消息。

由于单个连接可以具有对服务器的多个打开订阅,因此必须在帧中包括id头以唯一地标识要移除的订阅。 该头必须匹配现有订阅的订阅标识符。

Example:

UNSUBSCRIBE
id:0

^@

ACK

ACK用于使用客户端或客户端个体确认来确认来自订阅的消息的消费。 从这样的订阅接收的任何消息将不被视为已被消费,直到消息已经通过ACK被确认。

ACK帧必须包括与被确认的MESSAGE的ack头匹配的id头。 可选地,可以指定事务头,指示消息确认应该是命名事务的一部分。

ACK
id:12345
transaction:tx1

^@

NACK

NACK与ACK相反。 它用于告诉服务器客户端没有消费消息。 然后,服务器可以将消息发送到不同的客户端,将其丢弃,或将其放在死信队列中。 确切的行为是服务器特定的。

NACK采用与ACK:id(REQUIRED)和事务(可选)相同的报头。

NACK应用于一个单个消息(如果订阅的确认模式是客户端独立的),或者应用于之前发送的并且尚未确认或NACK应答(如果订阅的确认模式是客户端)的所有消息。

BEGIN

BEGIN用于启动事务。 在这种情况下,事务适用于发送和确认 - 在事务期间发送或确认的任何消息将基于事务进行原子处理。

BEGIN
transaction:tx1

^@

事务头是必需的,并且事务标识符将用于SEND,COMMIT,ABORT,ACK和NACK帧,以将它们绑定到命名事务。 在同一连接中,不同的事务必须使用不同的事务标识符。

如果客户端发送DISCONNECT帧或TCP连接由于任何原因失败,那么尚未提交的任何已启动事务将被隐式中止。

COMMIT

COMMIT用于提交正在进行的事务。

COMMIT
transaction:tx1

^@

事务头是必需的,必须指定要提交的事务的标识符。

ABORT

ABORT用于回滚正在进行的事务。

ABORT
transaction:tx1

^@

事务头是必需的,必须指定要中止的事务的标识符。

DISCONNECT

客户端可以随时通过关闭套接字与服务器断开连接,但不能保证服务器已经接收到先前发送的帧。 要做一个正常关机,客户端保证所有以前的帧已被服务器接收,客户端应该:

(1). 发送一个带有接收头设置的DISCONNECT帧。 例:

DISCONNECT
receipt:77
^@

(2). 等待RECEIPT帧对DISCONNECT的响应。 例:

RECEIPT
receipt-id:77
^@

(3). close the socket.

请注意,如果服务器太快关闭其端口,客户端可能永远不会收到预期的RECEIPT帧。 有关详细信息,请参阅连接配置部分。

在发送DISCONNECT帧后,客户端不得再发送任何帧。


Server Frames 服务端架构

服务器有时会向客户端(除了初始CONNECTED帧之外)发送帧。 这些帧可能是以下之一:

  • MESSAGE
  • RECEIPT
  • ERROR

MESSAGE

MESSAGE帧用于将消息从订阅传达到客户端。

MESSAGE帧必须包括指示消息被发送到的目的地的目的地头部。 如果消息已使用STOMP发送,则此目的地标头应与在相应的SEND帧中使用的相同。

MESSAGE帧必须还包含具有该消息的唯一标识符的message-id报头和与接收该消息的订阅的标识符匹配的订阅报头。

如果从需要显式确认的订阅接收到消息(客户机或客户机独立模式),则MESSAGE帧必须还包含具有任意值的ack头。 该报头将用于将消息与后续的ACK或NACK帧相关联。

帧主体包含消息的内容:

MESSAGE
subscription:0
message-id:007
destination:/queue/a
content-type:text/plain

hello queue a^@

MESSAGE帧应该包括一个内容长度头和一个内容类型头如果一个主体存在。

MESSAGE帧还将包括除了可以添加到帧的服务器特定头之外,当消息发送到目的地时存在的所有用户定义的头。 请参阅您的服务器文档以找出它添加到邮件的服务器特定头。

RECEIPT

一旦服务器已成功处理请求接收的客户端帧,则RECEIPT帧从服务器发送到客户端。 RECEIPT帧必须包含标头receipt-id,其中值是帧的收据头的值,这是其的收据。

RECEIPT
receipt-id:message-12345

^@

RECEIPT帧是对应的客户端帧已经被服务器处理的确认。 由于STOMP是基于流的,所以接收也是服务器已经接收到所有先前帧的累积确认。 然而,这些先前帧可能尚未被完全处理。 如果客户端断开连接,以前接收的帧应该继续由服务器处理。

ERROR

如果出现错误,服务器可能会发送ERROR帧。 在这种情况下,它必须在发送ERROR帧后立即关闭连接。 请参阅下一节有关连接延迟。

ERROR帧应该包含一个具有错误的简短描述的消息头,并且主体可以包含更多的详细信息(或者可以是空的)。

ERROR
receipt-id:message-12345
content-type:text/plain
content-length:171
message: malformed frame received

The message:
-----
MESSAGE
destined:/queue/a
receipt:message-12345

Hello queue a!
-----
Did not contain a destination header, which is REQUIRED
for message propagation.
^@

如果错误与从客户端发送的特定帧相关,则服务器应添加其他标头,以帮助识别导致错误的原始帧。 例如,如果帧包括接收报头,则ERROR帧应该设置receive-id报头以匹配与错误相关的帧的接收报头的值。

错误帧应包括内容长度头和内容类型头如果主体存在。


Frames and Headers

除了上述的标准报头(内容长度,内容类型和接收),这里是在本规范中定义的每个帧必须或可能使用的所有报头:

  • CONNECT or STOMP
    • REQUIRED: accept-version, host
    • OPTIONAL: login, passcode, heart-beat
  • CONNECTED
    • REQUIRED: version
    • OPTIONAL: session, server, heart-beat
  • SEND
    • REQUIRED: destination
    • OPTIONAL: transaction
  • SUBSCRIBE
    • REQUIRED: destination, id
    • OPTIONAL: ack
  • UNSUBSCRIBE
    • REQUIRED: id
    • OPTIONAL: none
  • ACK or NACK
    • REQUIRED: id
    • OPTIONAL: transaction
  • BEGIN or COMMIT or ABORT
    • REQUIRED: transaction
    • OPTIONAL: none
  • DISCONNECT
    • REQUIRED: none
    • OPTIONAL: receipt
  • MESSAGE
    • REQUIRED: destination, message-id, subscription
    • OPTIONAL: ack
  • RECEIPT
    • REQUIRED: receipt-id
    • OPTIONAL: none
  • ERROR
    • REQUIRED: none
    • OPTIONAL: message

此外,SEND和MESSAGE帧可以包括应该被认为是携带消息的一部分的任意用户定义的报头。 此外,错误帧应包括额外的标头,以帮助识别导致错误的原始帧。

最后,STOMP服务器可以使用附加头部来访问功能,如持久性或过期。 有关详细信息,请参阅服务器的文档。


Augmented BNF

STOMP会话可以使用在HTTP / 1.1 RFC 2616中使用的Backus-Naur表单(BNF)语法更正式地描述。

NULL                = <US-ASCII null (octet 0)>
LF                  = <US-ASCII line feed (aka newline) (octet 10)>
CR                  = <US-ASCII carriage return (octet 13)>
EOL                 = [CR] LF 
OCTET               = <any 8-bit sequence of data>

frame-stream        = 1*frame

frame               = command EOL
                      *( header EOL )
                      EOL
                      *OCTET
                      NULL
                      *( EOL )

command             = client-command | server-command

client-command      = "SEND"
                      | "SUBSCRIBE"
                      | "UNSUBSCRIBE"
                      | "BEGIN"
                      | "COMMIT"
                      | "ABORT"
                      | "ACK"
                      | "NACK"
                      | "DISCONNECT"
                      | "CONNECT"
                      | "STOMP"

server-command      = "CONNECTED"
                      | "MESSAGE"
                      | "RECEIPT"
                      | "ERROR"

header              = header-name ":" header-value
header-name         = 1*<any OCTET except CR or LF or ":">
header-value        = *<any OCTET except CR or LF or ":">

License

本规范根据Creative Commons Attribution v3.0许可证授权。

原文: http://stomp.github.io/stomp-specification-1.2.html#Abstract

猜你喜欢

转载自blog.csdn.net/hinstenyhisoka/article/details/54311814
今日推荐