两年前,我开始开发 还有一个 从OpenAPI Specification v3到TypeScript的免费代码生成器( 可在Github上获得 )。 最初,我开始考虑使用各种JSON Schema功能,例如oneOf / anyOf / allOf等,在TypeScript中高效地生成原始数据和复杂数据类型。 (Swagger的本机解决方案对此存在一些问题)。 另一个想法是使用规范中的模式在系统的正面,背面和其他部分进行验证。

现在代码生成器已经准备就绪-处于MVP阶段。 它在生成数据类型方面有很多需求,还有一个用于生成前端服务的实验库(到目前为止,对于Angular)。 在本文中,我想展示这些开发,并告诉您使用TypeScript和OpenAPI v3时它们如何提供帮助。 在此过程中,我想分享一些在我的工作过程中出现的想法和考虑。 好吧,如果您有兴趣,可以阅读我藏在扰流板中的背景故事,以免使技术部分的阅读复杂化。
目录内容
- 背景知识
- 内容描述
- 安装及使用
- 练习使用代码生成器
- 在应用程序中使用生成的数据类型
- 分解符合OAS规范的电路
- 嵌套分解
- 自动生成的用于REST API的服务
- 为什么需要这个?
- 服务生成
- 使用生成的服务
- 而不是后记
背景知识
展开阅读(跳过)一切都始于两年前-然后我在一家开发数据挖掘平台的公司工作,负责前端(主要是TypeScript + Angular)。 项目功能是具有大量参数(30个或更多)的复杂数据结构,并且它们之间并不总是显而易见的业务关系。 公司发展壮大,软件环境不断变化。 前端必须精通细微差别,因为在前端和后端重复了一些计算。 就是说,当使用OpenAPI超出适当范围时就是这种情况。 我在公司工作了一段时间,几个月后,开发团队就获得了一个规范,这成为后端,前端乃至核心部门(隐藏在Web后端的背后)的通用知识库。 选择OpenAPI版本是为了“增长”-那时v3.0还很年轻
这不再是一个或多个静态YML / JSON文件中的规范,而不再是注释器的结果,而是一个按照平台DDD概念组织的组件,方法,模板和属性的完整库。 该库分为目录和文件,专门安排了一个收集者,为每个主题领域制作了OAS文档。 建立了实验性的工作流程,可以将其描述为“设计优先”。
Yandex.Money公司的博客上有一篇很好的文章 ,内容涉及Design First
“设计优先”和通用规范有助于减少知识的使用,但是一个新问题变得很明显-保持了代码的相关性。 该规范描述了数十种方法和数十种(以及后来的数百种)实体。 但是代码必须手动编写:数据类型,用于REST的服务等。 一两个带有平行故事的冲刺极大地改变了局面。 为多个故事和人为因素的合并增加了复杂性。 该例程可能非常重要,解决方案似乎很明显-您需要代码生成。 毕竟,OAS规范已经包含了所有必要的内容,以免手动输入。 但这不是那么简单。
前端处于生产周期的最后,因此与其他部门的同事相比,我感到更加痛苦的变化。 在设计REST API时,后端环境是决定性的,即使在“设计优先”获得批准后,惯性仍然存在。 对于前端,一切似乎都不那么明显。 实际上,我从一开始就了解这一点,并开始提前探究土壤-当谈论“通用”规范才刚刚开始时。 没有人谈论编写自己的代码生成器。 我只是想找到准备好的东西。
我很失望。 有两个问题:OAS版本3.0,似乎没有人提供支持,而且解决方案本身的质量也很不错-当时(我记得是两年前),我设法找到了两个相对现成的解决方案: Swagger和Microsoft (似乎是 )。 首先,对OAS 3.0的支持处于深度测试阶段。 第二个仅适用于2.x版,但没有明确的预测。 顺便说一句,即使在Swagger 2.0格式的测试文档上,我也无法启动Microsoft代码生成器。 Swagger的解决方案有效,但是带有$ ref链接的或多或少复杂的方案变成了难以理解的“ ERROR!”,并且递归依赖性将其发送到了无限循环中。 原始类型存在问题。 另外,我不太了解如何使用自动生成的服务-它们似乎是为展示而制作的,并且它们的实际使用产生了比其解决的问题更多(我认为)。 最后,将JAR文件集成到面向NPM的CI / CD中很不方便:我不得不手动下载必要的快照 (似乎重13兆字节),并对其进行处理。 总的来说,我休息了一会,决定观察接下来会发生什么。
大约五个月后,再次出现了代码生成问题。 我不得不重写和扩展Web应用程序的一部分,与此同时,我想重构用于使用REST API和数据类型的旧服务。 但是对复杂性的评估并不乐观:从一个人周到两个星期-但这仅用于REST服务和类型描述。 我不会说这让我非常沮丧,但仍然如此。 另一方面,我从未找到用于代码生成的解决方案,也没有等待,并且其实现几乎不需要花费更多时间。 也就是说,毫无疑问:收益是可疑的,风险是巨大的。 没有人会支持这个想法,我没有提出。 同时,五月假期临近,公司“欠”我几天时间在周末工作。 有两个星期,我从所有的工作经历中逃到了佐治亚州,在那里我曾经住了将近一年。
在聚会和宴会之间,我需要做一些事情,于是我决定写下自己的决定。 在Vake Park附近的夏季咖啡馆工作令人惊讶,工作效率很高,我带着现成的用于数据类型的代码生成器回到Peter。 然后又一个月,我在他准备上班之前的周末“完成”了服务。
从一开始,我就打开了代码生成器,并在业余时间使用它。 尽管实际上他是为工作草案而写的。 我不会说修订/磨合没有任何问题; 我不会说它们很重要。 但是到了某个时候,我注意到我停止使用Redoc / Swagger文档:只要代码始终是最新的并带有注释,浏览代码就会更加方便。 很快,我“得分”我的成就,而根本没有发展,直到一位同事(现在是六个月前我去另一家公司)建议我更认真地对待他们(他也想出了这个名字)。
我没有足够的空闲时间,因此花了几个月的时间我才能在后台完成工作: 操场 ,测试应用程序,项目重组。 现在,我准备接收反馈。
内容描述
目前,用于代码生成的解决方案包括三个NPM库,它们集成在@codegena @codegena
并位于一个公共的单色存储库中 :
安装及使用
最实用的选择是在从CLI运行的NodeJS脚本中使用。 首先,您需要安装依赖项:
npm i @codegena/oapi3ts, @codegena/ng-api-service, @codegena/oapi3ts-cli
然后,使用以下代码创建一个js文件(例如update-typings.js
):
"use strict"; var cliLib = require('@codegena/oapi3ts-cli'); var cliApp = new cliLib.CliApplication; cliApp.createTypings();
并通过传递三个参数启动它:
node ./update-typings.js --srcPath ./specs/todo-app-spec.json --destPath ./src/lib --separatedFiles true
在destPath
将生成文件,实际上, 该目录在项目存储库中的内容是以相同的方式创建的。 这是生成脚本 , 这就是它在NPM脚本中的运行方式。 但是,如果您愿意,甚至可以在浏览器中使用它,就像在Playground中一样 。
练习使用代码生成器
接下来,我想谈谈我们将得到的结果:这将如何帮助我们。 视觉辅助将是演示应用程序的代码。 它由两部分组成:一个后端(在NestJS框架上)和一个前端(在Angular上 )。 如果愿意,您甚至可以在本地运行它 。
即使您不熟悉Angular和/或NestJS,也不会引起问题:大多数TypeScript开发人员应理解将提供的代码示例。
尽管应用程序已尽可能简化(例如,后端将数据存储在会话中,而不是数据库中),但我还是尝试重新创建真实应用程序中固有的数据流和数据类型层次结构的功能。 大约80-85%已经准备好了,但是“完成”可能会延迟,但是现在更重要的是谈论已经存在的东西。
在应用程序中使用生成的数据类型
假设我们有一个必须使用的OpenAPI规范(例如, 此规范)。 不管是从头开始创建东西还是获得支持,我们最有可能从一开始就着手做一件事-输入。 我们将开始描述基本数据类型或对其进行更改。 大多数程序员这样做是为了促进他们将来的开发。 因此,您不必再次查看文档,请记住参数列表; 并且您可以确保IDE和/或编译器会注意到输入错误。
我们的规范可能包括也可能不包括components.schem的部分。 但无论如何,它将描述参数,请求和答案的集合-我们可以使用它。 考虑一个例子:
@Controller('group') export class AppController {
这是NestJS框架的控制器片段,其中键入了参数( RewriteGroupParameters
),请求正文( RewriteGroupRequest
)和响应正文( RewriteGroupResponse<T>
)。 在此代码片段中,我们已经看到了键入的好处:
- 如果混淆被破坏的参数
groupId
的名称,而改为指定groupId
, groupId
立即在编辑器中出现错误。

- 如果this.appService.rewriteGroup(groupId,body)方法具有类型化的参数,则我们可以控制传递的
body
参数的正确性。 如果控制器方法或服务方法的输入数据格式发生更改,我们将立即知道。 展望未来,我注意到service方法的输入方法的数据类型不同于RewriteGroupRequest
,但是在我们的例子中,它们将彼此相同。 但是,如果突然改变了服务方法,并开始接受ToDoGroup
而不是ToDoGroupBlank
,则IDE和编译器将立即显示差异的位置:

- 同样,我们可以控制返回结果的符合性。 如果成功响应的状态在规范中突然改变,从
202
变为200
,而不是200
,我们也会发现它,因为RewriteGroupResponse
是具有枚举类型的泛型 :

现在,让我们看一下前端应用程序中与另一个API方法一起使用的示例 :
protected initSelectedGroupData(truth: ComponentTruth): Observable<ComponentTruth> { return this.getGroupsService.request(null, { isComplete: null, withItems: false }).pipe( pickResponseBody<GetGroupsResponse<200>>(200, null, true), switchMap<ToDoGroup[], Observable<ComponentTruth>>( groups => this.loadItemsOfSelectedGroups({ ...truth, groups }) ) ); }
让我们不要超越自己,解析自定义RxJS运算符pickResponseBody
,而是让我们集中精力优化pickResponseBody
类型。 我们在RxJS运算符链中使用它,其后的运算符具有ToDoGroup[]
的输入细化。 如果该代码有效,则指示的数据类型彼此对应。 在这里,我们还可以控制类型匹配,如果API中的响应格式突然发生变化,这将不会引起我们的注意:

当然,还会键入this.getGroupsService.request
的调用参数。 但这是生成服务的主题。
在以上示例中,我们看到可以在系统的各个部分(前端,后端等)中使用请求,响应和参数的类型。 如果后端和前端在同一个单一存储库中,并且具有兼容的生态环境,则它们可以对生成的代码使用相同的共享库 。 但是,即使后端和前端由不同的团队支持,并且除了公共OAS规范外没有其他共同点,对于他们来说,同步代码仍然更加容易。
分解符合OAS规范的电路
可能在前面的示例中,您关注了ToDoGroupBlank
和ToDoGroup
,它们与RewriteGroupResponse
和RewriteGroupResponse
GetGroupsResponse
。 实际上, RewriteGroupResponse
只是ToDoGroup
, HttpErrorBadRequest
等的通用别名。 很容易猜到ToDoGroup和HttpErrorBadRequest都是rewriteGroup端点 (直接或通过中介 )引用的components.schem规范部分中的方案:
"responses": { "200": { "description": "Todo group saved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ToDoGroup" } } } }, "400": { "$ref": "#/components/responses/errorBadRequest" }, "404": { "$ref": "#/components/responses/errorGroupNotFound" }, "409": { "$ref": "#/components/responses/errorConflict" }, "500": { "$ref": "#/components/responses/errorServer" } }
这是数据结构的常见分解,其原理与其他编程语言相同。 组件也可以分解:引用其他组件(包括递归),使用组合和其他JSON Schema功能。 但是,无论复杂度如何,都应将其正确转换为数据类型的描述。 我想展示如何在OpenAPI中使用分解,以及生成的代码的外观。
精心设计的OAS规范中的组件将与使用它的应用程序的DDD模型重叠。 但是,即使规范不完善,您也可以依靠它来构建自己的数据模型。 这将使您可以更好地控制数据类型与可集成子系统的数据类型之间的对应关系。
由于我们的应用程序是任务列表,因此主要的本质是任务。 首先将其放入组件是合乎逻辑的,因为 其他实体和端点将以某种方式与其连接。 但是在此之前,您需要了解两件事:
- 我们不仅描述抽象,而且描述验证的规则,并且它们越准确和明确,就越好。
- 像数据库中存储的任何实体一样,任务具有两种类型的属性:服务和用户输入。
事实证明,根据使用情况,我们有两个数据结构:用户刚创建的Task和已经存储在数据库中的Task。 在第二种情况下,它具有唯一的UID,创建日期,更改等,并且必须在后端分配此数据。 我描述了两个实体( ToDoTaskBlank
和ToDoTask
),第一个是第二个的子集:
"components": { "ToDoTaskBlank": { "title": "Base part of data of item in todo's group", "description": "Data about group item needed for creation of it", "properties": { "groupUid": { "description": "An unique id of group that item belongs to", "$ref": "#/components/schemas/Uid" }, "title": { "description": "Short brief of task to be done", "type": "string", "minLength": 3, "maxLength": 64 }, "description": { "description": "Detailed description and context of the task. Allowed using of Common Markdown.", "type": ["string", "null"], "minLength": 10, "maxLength": 1024 }, "isDone": { "description": "Status of task: is done or not", "type": "boolean", "default": "false", "example": false }, "position": { "description": "Position of a task in group. Allows to track changing of state of a concrete item, including changing od position.", "type": "number", "min": 0, "max": 4096, "example": 0 }, "attachments": { "type": "array", "description": "Any material attached to the task: may be screenshots, photos, pdf- or doc- documents on something else", "items": { "$ref": "#/components/schemas/AttachmentMeta" }, "maxItems": 16, "example": [] } }, "required": [ "isDone", "title" ], "example": { "isDone": false, "title": "Book soccer field", "description": "The complainant agreed and recruited more members to play soccer." } }, "ToDoTask": { "title": "Item in todo's group", "description": "Describe data structure of an item in group of tasks", "allOf": [ { "$ref": "#/components/schemas/ToDoTaskBlank" }, { "type": "object", "properties": { "uid": { "description": "An unique id of task", "$ref": "#/components/schemas/Uid", "readOnly": true }, "dateCreated": { "description": "Date/time (ISO) when task was created", "type": "string", "format": "date-time", "readOnly": true, "example": "2019-11-17T11:20:51.555Z" }, "dateChanged": { "description": "Date/time (ISO) when task was changed last time", "type": "string", "format": "date-time", "readOnly": true, "example": "2019-11-17T11:20:51.555Z" } }, "required": [ "dateChanged", "dateCreated", "position", "uid" ] } ] } }
在输出中,我们得到两个TypeScript接口,第一个将被第二个继承:
export interface ToDoTaskBlank {
现在,我们有了Task实体的基本描述,并且像在演示应用程序中所做的那样,在我们的应用程序代码中引用了它们:
import { ToDoTask, ToDoTaskBlank, } from '@our-npm-scope/our-generated-lib'; export interface ToDoTaskTeaser extends ToDoTask { isInvalid?: boolean; isJustCreated?: boolean; isPending?: boolean; prevTempUid?: string; }
在此示例中,我们描述了一个新实体,向ToDoTask
添加了前端应用程序端缺少的那些属性。 也就是说,实际上,我们在考虑到本地细节的情况下扩展了结果数据模型。 围绕此模型,一组本地工具以及诸如原始DTO之类的东西逐渐增长:
export function downgradeTeaserToTask( taskTeaser: ToDoTaskTeaser ): ToDoTask { const task = { ...taskTeaser }; if (!task.description || !task.description.trim()) { delete task.description; } else { task.description = task.description.trim(); } delete task.isJustCreated; delete task.isPending; delete task.prevTempUid; return task; } export function downgradeTeaserToTaskBlank( taskTeaser: ToDoTaskTeaser ): ToDoTaskBlank { const task = downgradeTeaserToTask(taskTeaser) as any; delete task.dateChanged; delete task.dateCreated; delete task.uid; return task; }
有人更喜欢使数据模型更完整并使用类。 export class ToDoTaskTeaser implements ToDoTask {
但这是样式,适当性以及应用程序体系结构将如何开发的问题。 通常,无论采用哪种方法,我们都可以依靠基本的数据模型并更好地控制键入的一致性。 因此,如果由于某种原因ToDoTask
的uid
变成了数字,我们将了解需要更新的代码的所有部分:

嵌套分解
所以现在我们有了ToDoTask
接口,我们可以引用它。 同样,我们将描述ToDoTaskGroup和ToDoTaskGroupBlank ,它们将分别包含ToDoTask
和ToDoTaskBlank
类型的属性。 但是现在我们将“任务组”分为两个部分,而不是三个部分:为清楚起见,我们将在ToDoGroupExtendedData中描述增量。 因此,我想演示一种方法,其中一个组件是由其他两个组件创建的:
"ToDoGroup": { "allOf": [ { "$ref": "#/components/schemas/ToDoGroupBlank" }, { "$ref": "#/components/schemas/ToDoGroupExtendedData" } ] }
开始生成代码后,我们得到了稍微不同的TypeScript构造:
export type ToDoGroup = ToDoGroupBlank &
由于ToDoGroup
没有自己的“主体”,因此代码生成器倾向于将其转换为接口的并集。 但是,如果您使用自己的(匿名)方案添加第三部分,则结果将是具有两个祖先的接口(但最好不要这样做)。 并且请注意, ToDoGroupBlank
接口的items
属性键入为ToDoTaskBlank
的数组,并在ToDoGroupBlank
的ToDoTask
重新定义。 因此,代码生成器能够将相当复杂的分解细节从JSON模式传递到TypeScipt。
import { ToDoTaskBlank } from './to-do-task-blank'; export interface ToDoGroupBlank {
import { ToDoTask } from './to-do-task'; export interface ToDoGroupExtendedData {
好吧,当然,在ToDoTask
/ ToDoTaskBlank
我们也可以使用分解。 您可能已经注意到, attachments
属性描述为AttachmentMeta类型的元素数组。 并且此组件描述如下:
"AttachmentMeta": { "description": "Common meta data model of any type of attachment", "oneOf": [ {"$ref": "#/components/schemas/AttachmentMetaImage"}, {"$ref": "#/components/schemas/AttachmentMetaDocument"}, {"$ref": "#/components/schemas/ExternalResource"} ] }
即,该组件是指其他组件。 由于它没有自己的方案,因此代码生成器不会将其设置为单独的数据类型,以免与实体相乘,而是将其枚举类型设为匿名描述:
attachments?: Array< | AttachmentMetaImage
同时,对于AttachmentMetaImage
和AttachmentMetaDocument
组件,描述了使用它们导入文件的非匿名接口:
import { AttachmentMetaDocument } from './attachment-meta-document'; import { AttachmentMetaImage } from './attachment-meta-image';
但是即使在AttachmentMetaImage中,我们也可以找到另一个渲染的ImageOptions接口的链接,该接口被使用了两次,包括在一个匿名接口内部(从AdditionalProperties转换的结果):
import { ImageOptions } from './image-options'; export interface AttachmentMetaImage {
因此,基于ToDoTask
或ToDoGroup
,我们实际上将几个实体及其业务连接链集成到我们的代码中,这使我们可以更好地控制超出代码范围的整个系统中的更改。 当然,这并非在所有情况下都有意义。 但是,如果您使用的是OpenAPI,那么除了实际的文档外,您可能还会获得一笔额外的红利。
自动生成的用于REST API的服务
为什么需要这个?
如果我们采用一个普通的统计前端应用程序,该应用程序可以使用或多或少复杂的REST API,则其代码的相当一部分将是用于访问API的服务(或仅仅是函数)。 它们将包括:
- URL和参数映射
- 验证参数,请求和响应
- 数据提取和紧急处理
令人不快的是,这在许多方面都是典型的,并且不包含任何唯一逻辑。 让我们假设一些示例-作为一般概述,可以构建使用API的方法:
使用REST API的简化原理图示例 import _ from 'lodash'; import { Observable, fromFetch, throwError } from 'rxjs'; import { switchMap } from 'rxjs/operators';
您可以使用高级抽象来使用REST-根据所使用的堆栈,它可以是: Axios , Angular HttpClient或任何其他类似的解决方案。 但最有可能的是,基本上您的代码将与此示例一致。 几乎可以肯定的是,它将包括:
- 用于访问特定端点的服务或函数(在我们的示例中为
getTasksFromServer
函数) - 处理结果的代码段(
getRemainedTasks
函数)
在来自现实世界的应用程序中,此代码将更加复杂:演示应用程序的规范描述了5-6个答案选项 。 通常,REST API的设计方式是必须相应处理服务器的每个响应状态。 但是,即使在应用程序开发过程中,即使检查输入数据也往往变得更加困难:支持和处理错误检查所花费的时间越多,您就越想知道应用程序中数据流通的瓶颈。
在软件部件对接的每个节点上可能会发生错误,对其进行不及时的检测(以及对难以诊断的问题的搜索)对于企业而言可能是非常昂贵的。 因此,将进行其他澄清检查。 随着代码库的增长和涵盖的案例数的增加,进行更改的复杂性也随之增加。 但是业务是不断变化的,因此无法克服。 因此,我们应该在意如何预先进行更改。
回到OpenAPI主题,我们注意到在OAS规范中可能有足够的信息来:
— . , , / — 5, 10 200, . , , : , , , RxJS- pickResponseBody , , - ; tapResponse , side-effect (tap) HTTP-. , - . , , .
, — -, . , , , "" / API "-" "" . - , "" ( ), .
, REST API Angular. , , /. . , , . , , .. .
" " . Angular-, update-typings.js
:
"use strict"; var cliLib = require('@codegena/oapi3ts-cli'); var cliApp = new cliLib.CliApplication; cliApp.createTypings(); cliApp.createServices('angular');
, Angular- API . , - - , . , RewriteGroupService . ApiService , , , -:
, JSON Schema , . , , :
import { schema as domainSchema } from './schema.b4c655ec1635af1be28bd6';
, schema.b4c655ec1635af1be28bd6.ts
, , .
, Angular-.
Angular-为此,只需导入模块ApiModule
并在提供程序中指定我们需要的服务即可:
import { ApiModule, API_ERROR_HANDLER } from '@codegena/ng-api-service'; import { CreateGroupItemService, GetGroupsService, GetGroupItemsService, UpdateFewItemsService } from '@codegena/todo-app-scheme'; @NgModule({ imports: [ ApiModule, // ... ], providers: [ RewriteGroupService, { provide: API_ERROR_HANDLER, useClass: ApiErrorHandlerService }, // ... ], // ... }) export class TodoAppModule { }
, [])( https://angular.io/guide/dependency-injection ):
@Injectable() export class TodoTasksStore { constructor( protected createGroupItemService: CreateGroupItemService, protected getGroupsService: GetGroupsService, protected getGroupItemsService: GetGroupItemsService, protected updateFewItemsService: UpdateFewItemsService ) {} }
— , request , :
return this.getGroupsService.request(null, { isComplete: null, withItems: false }).pipe( pickResponseBody<GetGroupsResponse<200>>(200, null, true), switchMap<ToDoGroup[], Observable<ComponentTruth>>( groups => this.loadItemsOfSelectedGroups({ ...truth, groups }) ) );
request
Observable<HttpResponse<R> | HttpEvent<R>>
, , . , , . , , , . RxJS- pickResponseBody
.
, , , . API, . . , :

. JSON Schema . , "" - . , Sentry Kibana , . . , , .
, . , :)
而不是后记
, . -, " " — . , , , .
— , - / ( ). , — .
.