为什么 Telegex 1.0 是正确无误的框架

本文章是早期 Telegex 作者在外部宣传时发表的内容,它还欠缺修改以更适应博客读者。

背景

大概三年前,我们创建了一个叫做 Telegex 的库。它那时候只是一个简单的 Telegram Bot API 客户端,虽然它的实现也是用数据来生成一个个调用 API 的函数,但是面对 Bot API 的频繁更新,它仍然显得适配乏力。

最近,我们完全重新的设计了这个库。它在适配新的 Bot API 的速度上是无与伦比的,因为只需要一条命令。秘密在于它基于官方文档“数据”来自动生成代码,只需要更新一下文档便可适配所有最新变化。包括 API 变动、注释变动,任何类型和字段的变动。

为什么叫文档数据?

因为文档真的解析成了数据。我们将官方文档页面中的几乎所有的有效内容转换为了 JSON 格式,并上传到独立的仓库中(telegex/api_doc.json)。包括所有类型、方法和注释。

从文档生成 JSON 文件是 Mix 任务,位于 telegex/lib/mix/tasks/gen.doc_json.ex 文件。

对于类型

  • 我将类型转换为 JSON,如:
{
  "name": "WebhookInfo",
  "description": "Describes the current status of a webhook.",
  "fields": [
    {
      "name": "url",
      "type": "String",
      "description": "Webhook URL, may be empty if webhook is not set up",
      "optional": false
    },
    {
      "name": "has_custom_certificate",
      "type": "Boolean",
      "description": "True, if a custom certificate was provided for webhook certificate checks",
      "optional": false
    },
    {
      "name": "pending_update_count",
      "type": "Integer",
      "description": "Number of updates awaiting delivery",
      "optional": false
    },
    {
      "name": "ip_address",
      "type": "String",
      "description": "Optional. Currently used webhook IP address",
      "optional": true
    },
    {
      "name": "last_error_date",
      "type": "Integer",
      "description": "Optional. Unix time for the most recent error that happened when trying to deliver an update via webhook",
      "optional": true
    },
    {
      "name": "last_error_message",
      "type": "String",
      "description": "Optional. Error message in human-readable format for the most recent error that happened when trying to deliver an update via webhook",
      "optional": true
    },
    {
      "name": "last_synchronization_error_date",
      "type": "Integer",
      "description": "Optional. Unix time of the most recent error that happened when trying to synchronize available updates with Telegram datacenters",
      "optional": true
    },
    {
      "name": "max_connections",
      "type": "Integer",
      "description": "Optional. The maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery",
      "optional": true
    },
    {
      "name": "allowed_updates",
      "type": "Array of String",
      "description": "Optional. A list of update types the bot is subscribed to. Defaults to all update types except chat_member",
      "optional": true
    }
  ]
}
  • 我将具有共同字段的类型们保存为联合类型,如
{
  "name": "ChatMember",
  "description": "This object contains information about one member of a chat. Currently, the following 6 types of chat members are supported:",
  "types": [
    "ChatMemberOwner",
    "ChatMemberAdministrator",
    "ChatMemberMember",
    "ChatMemberRestricted",
    "ChatMemberLeft",
    "ChatMemberBanned"
  ]
}

联合类型是某些 API 文档中的返回值,它们一组具体的类型,具有一些公共字段。

  • 通常联合类型的返回值中有一个字段的值是固定的,用这个固定值来指向具体类型。如
{
  "name": "ChatMemberLeft",
  "description": "Represents a chat member that isn't currently a member of the chat, but may join it themselves.",
  "fields": [
    {
      "name": "status",
      "type": "String",
      "description": "The member's status in the chat, always “left”",
      "optional": false
    },
    {
      "name": "user",
      "type": "User",
      "description": "Information about the user",
      "optional": false
    }
  ],
  "fixed": {
    "value": "left",
    "field": "status"
  }
}

上面的 fixed 字段描述了这种指向关系。当返回值为 ChatMember 的 API 返回数据中的 status 字段为 left,你就应该将其转换为 ChatMemberLeft

对于方法

  • 我将方法转换为 JSON,如
{
  "name": "getUpdates",
  "description": "Use this method to receive incoming updates using long polling (wiki). Returns an Array of Update objects.",
  "parameters": [
    {
      "name": "offset",
      "type": "Integer",
      "description": "Identifier of the first update to be returned. Must be greater by one than the highest among the identifiers of previously received updates. By default, updates starting with the earliest unconfirmed update are returned. An update is considered confirmed as soon as getUpdates is called with an offset higher than its update_id. The negative offset can be specified to retrieve updates starting from -offset update from the end of the updates queue. All previous updates will be forgotten.",
      "required": false
    },
    {
      "name": "limit",
      "type": "Integer",
      "description": "Limits the number of updates to be retrieved. Values between 1-100 are accepted. Defaults to 100.",
      "required": false
    },
    {
      "name": "timeout",
      "type": "Integer",
      "description": "Timeout in seconds for long polling. Defaults to 0, i.e. usual short polling. Should be positive, short polling should be used for testing purposes only.",
      "required": false
    },
    {
      "name": "allowed_updates",
      "type": "Array of String",
      "description": "A JSON-serialized list of the update types you want your bot to receive. For example, specify [“message”, “edited_channel_post”, “callback_query”] to only receive updates of these types. See Update for a complete list of available update types. Specify an empty list to receive all update types except chat_member (default). If not specified, the previous setting will be used.\n\nPlease note that this parameter doesn't affect updates created before the call to the getUpdates, so unwanted updates may be received for a short period of time.",
      "required": false
    }
  ],
  "result_type": "Array of Update"
}

方法的 result_type 各种各样,主要有结构类型、基本类型、联合类型和数组类型。其中联合类型有可能是 ChatMember 也有可能是 Message or True 。这些类型可能会互相嵌套。

这就是我将其称作文档数据的原因,因为我的确将文档转换为了数据,为代码生成创造条件。当然,此处的介绍并不是文档数据的全貌,它实际上还有更复杂的细节。

telegex/api_doc.json 中可以找到这些,您也许也可以利用它们,毕竟它们和实现语言无关。

从文档数据构建类型和 API 调用函数

如果直接从 JSON 数据生成调用函数,是非常困难的,因为定义函数并不能通过数据来完成。所以我首先创造了一些宏,这些宏可以通过输入的“数据”来生成函数和类型代码。

使用数据生成类型:

deftype(WebhookInfo, "Describes the current status of a webhook.", [
  %{
    name: :url,
    type: :string,
    description: "Webhook URL, may be empty if webhook is not set up",
    optional: false
  },
  %{
    name: :has_custom_certificate,
    type: :boolean,
    description: "True, if a custom certificate was provided for webhook certificate checks",
    optional: false
  },
  %{
    name: :pending_update_count,
    type: :integer,
    description: "Number of updates awaiting delivery",
    optional: false
  },
  %{
    name: :ip_address,
    type: :string,
    description: "Optional. Currently used webhook IP address",
    optional: true
  },
  %{
    name: :last_error_date,
    type: :integer,
    description:
      "Optional. Unix time for the most recent error that happened when trying to deliver an update via webhook",
    optional: true
  },
  %{
    name: :last_error_message,
    type: :string,
    description:
      "Optional. Error message in human-readable format for the most recent error that happened when trying to deliver an update via webhook",
    optional: true
  },
  %{
    name: :last_synchronization_error_date,
    type: :integer,
    description:
      "Optional. Unix time of the most recent error that happened when trying to synchronize available updates with Telegram datacenters",
    optional: true
  },
  %{
    name: :max_connections,
    type: :integer,
    description:
      "Optional. The maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery",
    optional: true
  },
  %{
    name: :allowed_updates,
    type: %{__struct__: Telegex.TypeDefiner.ArrayType, elem_type: :string},
    description:
      "Optional. A list of update types the bot is subscribed to. Defaults to all update types except chat_member",
    optional: true
  }
])

使用数据生成 API 调用函数:

defmethod(
  "getUpdates",
  "Use this method to receive incoming updates using long polling (wiki). Returns an Array of Update objects.",
  [
    %{
      name: :offset,
      type: :integer,
      description:
        "Identifier of the first update to be returned. Must be greater by one than the highest among the identifiers of previously received updates. By default, updates starting with the earliest unconfirmed update are returned. An update is considered confirmed as soon as getUpdates is called with an offset higher than its update_id. The negative offset can be specified to retrieve updates starting from -offset update from the end of the updates queue. All previous updates will be forgotten.",
      required: false
    },
    %{
      name: :limit,
      type: :integer,
      description:
        "Limits the number of updates to be retrieved. Values between 1-100 are accepted. Defaults to 100.",
      required: false
    },
    %{
      name: :timeout,
      type: :integer,
      description:
        "Timeout in seconds for long polling. Defaults to 0, i.e. usual short polling. Should be positive, short polling should be used for testing purposes only.",
      required: false
    },
    %{
      name: :allowed_updates,
      type: %{__struct__: Telegex.TypeDefiner.ArrayType, elem_type: :string},
      description:
        "A JSON-serialized list of the update types you want your bot to receive. For example, specify [“message”, “edited_channel_post”, “callback_query”] to only receive updates of these types. See Update for a complete list of available update types. Specify an empty list to receive all update types except chat_member (default). If not specified, the previous setting will be used.\n\nPlease note that this parameter doesn't affect updates created before the call to the getUpdates, so unwanted updates may be received for a short period of time.",
      required: false
    }
  ],
  %{__struct__: Telegex.TypeDefiner.ArrayType, elem_type: Telegex.Type.Update}
)

上面的 deftypedefmethod 分别是通过数据生成 structfun 的宏。它们会构造出完美的函数和 struct 模块,包括完整的类型规范和注释。

从 JSON 中提取输入生成宏调用代码

使用 eex 模板即可。解析 JSON 数据并注入到模板中,就能轻易的生成所有的宏调用代码。从模板生成代码文件是 Mix 任务,位于 telegex/lib/mix/tasks/gen.code.ex 文件。

用法

作为 Bot APi 的客户端,调用是非常简单的,所有 Bot 方法的调用函数都在 Telegex 模块中。对于更多高级用法,可参考 README.md 和文档。

实际上 Telegex 也可以称之为框架而不是简单的 API 客户端库,因为它同时提供了便捷的 updates 处理模块,和基于“链”的 update 处理模型。具体可参考 Telegex 仓库的 README.md 和部分模块的文档。

有关“链”的部分,暂时还没有写文档,也没有教程。在足够有空的时候,本博客会向你们分享它是如何设计和使用的。

结束语

基于文档数据的代码生成,可以将上游 API 版本变动带来的适配负担降低到接近于零,这让 Telegex 更正确可靠并且总是最新的。对于使用 Telegex 的开发者而言,也不会再因为库的缓慢更新而感到头疼。这是值得高兴的,所以写了这篇文章来宣传它。