Wir schreiben benutzerdefinierte Transformatoren AST auf TypeScript

Das TestMace- Team ist zurück bei Ihnen. Dieses Mal veröffentlichen wir eine Übersetzung eines Artikels über die TypScript-Codekonvertierung mit dem Compiler. Viel Spaß beim Lesen!


Einführung


Dies ist mein erster Beitrag, und darin möchte ich eine Lösung für ein Problem mithilfe der TypeScript- Compiler-API zeigen . Um genau diese Lösung zu finden, habe ich mich lange Zeit mit zahlreichen Blogs befasst und die Antworten auf StackOverflow verdaut. Um Sie vor dem gleichen Schicksal zu schützen, werde ich alles teilen, was ich über eine so leistungsstarke, aber schlecht dokumentierte Toolbox gelernt habe.


Schlüsselkonzepte


Die Grundlagen der TypeScript-Compiler-API (Parser-Terminologie, Transformations-API, Ebenenarchitektur), des abstrakten Syntaxbaums (AST), des Besucherentwurfsmusters und der Codegenerierung.


Kleine Empfehlung


Wenn Sie zum ersten Mal von dem AST-Konzept hören, würde ich Ihnen dringend empfehlen, diesen Artikel von @Vaidehi Joshi zu lesen . Ihre gesamte Artikelserie von basecs kam großartig heraus, Sie werden es lieben.


Aufgabenbeschreibung


Bei Avero verwenden wir GraphQL und möchten die Typensicherheit in Resolvern erhöhen. Als ich auf graphqlgen stieß , konnte ich damit viele Probleme bezüglich des Konzeptes von Modellen in GraphQL lösen. Ich werde hier nicht auf dieses Thema eingehen - dafür habe ich vor, einen separaten Artikel zu schreiben. Kurz gesagt, die Modelle beschreiben die Rückgabewerte der Resolver, und in graphqlgen kommunizieren diese Modelle über eine Art Konfiguration (YAML- oder TypeScript-Datei mit Typdeklaration) mit den Schnittstellen.


Während des Betriebs führen wir gRPC- Mikrodienste aus, und GQL dient größtenteils als Fassade. Wir haben bereits TypeScript-Schnittstellen veröffentlicht, die mit Protoverträgen übereinstimmen , und ich wollte diese Typen als Modelle verwenden, aber ich bin auf einige Probleme gestoßen, die durch die Unterstützung des Exportierens von Typen und die Art und Weise, wie die Beschreibung unserer Schnittstellen implementiert wird (Anhäufung von Namespaces, eine große Anzahl von Links).


Nach den Regeln des guten Tons für die Arbeit mit Open Source-Code bestand mein erster Schritt darin, das zu verfeinern, was bereits im graphqlgen-Repository getan wurde, und damit meinen sinnvollen Beitrag zu leisten. Um den Introspektionsmechanismus zu implementieren, verwendet graphqlgen den Parser @ babel / parser, um eine Datei zu lesen und Informationen über Schnittstellennamen und Deklarationen (Schnittstellenfelder) zu sammeln.


Jedes Mal, wenn ich etwas mit AST machen muss, öffne ich zuerst astexplorer.net und beginne dann zu handeln. Mit diesem Tool können Sie den von verschiedenen Parsern erstellten AST analysieren, einschließlich Babel / Parser und TypeScript-Compiler-Parser. Mit astexplorer.net können Sie die Datenstrukturen visualisieren, mit denen Sie arbeiten müssen, und sich mit den Typen der AST-Knoten jedes Parsers vertraut machen.


Schauen Sie sich das Beispiel der Quelldatendatei und des AST an, die auf ihrer Basis mit dem Babel-Parser erstellt wurden:


example.ts
import { protos } from 'my_company_protos' export type User = protos.user.User; 

ast.json
 { "type": "Program", "start": 0, "end": 80, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 36 } }, "comments": [], "range": [ 0, 80 ], "sourceType": "module", "body": [ { "type": "ImportDeclaration", "start": 0, "end": 42, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 42 } }, "specifiers": [ { "type": "ImportSpecifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 } }, "imported": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "importKind": null, "local": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "range": [ 9, 15 ], "_babelType": "ImportSpecifier" } ], "importKind": "value", "source": { "type": "Literal", "start": 23, "end": 42, "loc": { "start": { "line": 1, "column": 23 }, "end": { "line": 1, "column": 42 } }, "extra": { "rawValue": "my_company_protos", "raw": "'my_company_protos'" }, "value": "my_company_protos", "range": [ 23, 42 ], "_babelType": "StringLiteral", "raw": "'my_company_protos'" }, "range": [ 0, 42 ], "_babelType": "ImportDeclaration" }, { "type": "ExportNamedDeclaration", "start": 44, "end": 80, "loc": { "start": { "line": 3, "column": 0 }, "end": { "line": 3, "column": 36 } }, "specifiers": [], "source": null, "exportKind": "type", "declaration": { "type": "TypeAlias", "start": 51, "end": 80, "loc": { "start": { "line": 3, "column": 7 }, "end": { "line": 3, "column": 36 } }, "id": { "type": "Identifier", "start": 56, "end": 60, "loc": { "start": { "line": 3, "column": 12 }, "end": { "line": 3, "column": 16 }, "identifierName": "User" }, "name": "User", "range": [ 56, 60 ], "_babelType": "Identifier" }, "typeParameters": null, "right": { "type": "GenericTypeAnnotation", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "typeParameters": null, "id": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "qualification": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 74, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 30 } }, "qualification": { "type": "Identifier", "start": 63, "end": 69, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 25 }, "identifierName": "protos" }, "name": "protos", "range": [ 63, 69 ], "_babelType": "Identifier" }, "range": [ 63, 74 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "GenericTypeAnnotation" }, "range": [ 51, 80 ], "_babelType": "TypeAlias" }, "range": [ 44, 80 ], "_babelType": "ExportNamedDeclaration" } ] } 

Die Wurzel des Baums (ein Knoten vom Typ Programm ) enthält zwei Operatoren in seinem Hauptteil - ImportDeclaration und ExportNamedDeclaration .


In ImportDeclaration interessieren uns insbesondere zwei Eigenschaften - Quelle und Bezeichner, die Informationen zum Quelltext enthalten. In unserem Fall entspricht der Wert der Quelle beispielsweise my_company_protos . An diesem Wert kann nicht verstanden werden, ob dies ein relativer Pfad zu einer Datei oder ein Link zu einem externen Modul ist. Genau das macht der Parser.


Ebenso sind Quellinformationen in ExportNamedDeclaration enthalten . Namespaces erschweren diese Struktur nur und fügen ihr eine beliebige Verschachtelung hinzu, wodurch immer mehr QualifiedTypeIdentifiers angezeigt werden. Dies ist eine weitere Aufgabe, die wir im Rahmen des gewählten Ansatzes mit dem Parser lösen müssen.


Aber ich habe noch nicht einmal die Auflösung von Typen aus Importen erreicht! Da der Parser und AST standardmäßig eine begrenzte Menge an Informationen zum Quelltext bereitstellen, müssen alle importierten Dateien analysiert werden, um diese Informationen zum endgültigen Baum hinzuzufügen. Aber jede solche Datei kann ihre eigenen Importe haben!


Es scheint, dass wir beim Lösen der Aufgaben mit Hilfe des Parsers zu viel Code erhalten ... Machen wir einen Schritt zurück und überlegen noch einmal.


Importe sind für uns nicht wichtig, ebenso wie die Dateistruktur nicht wichtig ist. Wir möchten in der Lage sein, alle Eigenschaften des Typs protos.user.User und einzubetten, anstatt Importreferenzen zu verwenden. Und woher erhalten Sie die erforderlichen Typinformationen zum Erstellen einer neuen Datei?


Typechecker


Da wir festgestellt haben, dass die Lösung mit dem Parser nicht zum Abrufen von Informationen über die Arten importierter Schnittstellen geeignet ist, schauen wir uns den Prozess des Kompilierens von TypeScript an und versuchen, einen anderen Ausweg zu finden.


Folgendes fällt mir sofort ein:


TypeChecker ist die Grundlage des TypeScript-Typsystems und kann aus einer Programminstanz erstellt werden. Er ist verantwortlich für die Interaktion von Zeichen aus verschiedenen Dateien miteinander, das Festlegen von Zeichentypen und das Durchführen einer semantischen Überprüfung (z. B. Fehlererkennung).
Als erstes sammelt TypeChecker alle Zeichen aus verschiedenen Quelldateien in einer Ansicht und erstellt dann eine einzelne Zeichentabelle, in der dieselben Zeichen zusammengeführt werden (z. B. Namespaces in mehreren verschiedenen Dateien).
Nach dem Initialisieren des Anfangszustands ist TypeChecker bereit, Antworten auf alle Fragen zum Programm zu geben. Diese Fragen können umfassen:
Welches Symbol entspricht diesem Knoten?
Was für ein Symbol ist das?
Welche Zeichen sind in diesem Teil des AST sichtbar?
Welche Signaturen stehen zur Deklaration einer Funktion zur Verfügung?
Welche Fehler sollten für diese Datei ausgegeben werden?

TypeChecker ist genau das, was wir brauchten! Mit Zugriff auf die Symboltabelle und die API können wir die ersten beiden Fragen beantworten: Welches Symbol entspricht diesem Knoten? Was für ein Symbol ist das? Durch das Zusammenführen aller gängigen Zeichen kann TypeChecker sogar das zuvor erwähnte Problem der Anhäufung von Namespaces lösen!


Wie kommt man zu dieser API?


Hier ist ein Beispiel, das ich im Internet finden konnte. Es zeigt, dass auf TypeChecker über die Programminstanzmethode zugegriffen werden kann. Es gibt zwei interessante Methoden - checker.getSymbolAtLocation und checker.getTypeOfSymbolAtLocation , die dem, was wir suchen, sehr ähnlich sehen.


Beginnen wir mit der Arbeit am Code.


models.ts
 import { protos } from './my_company_protos' export type User = protos.user.User; 

my_company_protos.ts
 export namespace protos { export namespace user { export interface User { username: string; info: protos.Info.User; } } export namespace Info { export interface User { name: protos.Info.Name; } export interface Name { firstName: string; lastName: string; } } } 

ts-alias.ts
 import ts from "typescript"; // hardcode our input file const filePath = "./src/models.ts"; // create a program instance, which is a collection of source files // in this case we only have one source file const program = ts.createProgram([filePath], {}); // pull off the typechecker instance from our program const checker = program.getTypeChecker(); // get our models.ts source file AST const source = program.getSourceFile(filePath); // create TS printer instance which gives us utilities to pretty print our final AST const printer = ts.createPrinter(); // helper to give us Node string type given kind const syntaxToKind = (kind: ts.Node["kind"]) => { return ts.SyntaxKind[kind]; }; // visit each node in the root AST and log its kind ts.forEachChild(source, node => { console.log(syntaxToKind(node.kind)); }); 

 $ ts-node ./src/ts-alias.ts prints ImportDeclaration TypeAliasDeclaration EndOfFileToken 

Wir sind nur daran interessiert, einen Typalias zu deklarieren, deshalb schreiben wir den Code ein wenig um:


Art-Drucker.ts
 ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { console.log(node.kind); } }) // prints TypeAliasDeclaration 

TypeScript bietet Schutz für jeden Knotentyp, mit dem Sie den genauen Knotentyp ermitteln können:



Nun zurück zu den beiden Fragen, die zuvor gestellt wurden: Welches Symbol entspricht diesem Knoten? Was für ein Symbol ist das?


Wir haben also die Namen erhalten, die von den Deklarationen der Typ-Alias-Schnittstelle eingegeben wurden, indem wir mit der TypeChecker- Zeichentabelle interagiert haben. Wir stehen zwar noch ganz am Anfang der Reise, aber dies ist aus Sicht der Selbstbeobachtung eine gute Ausgangsposition.


checker-example.ts
 ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const properties = checker.getPropertiesOfType(type); properties.forEach(declaration => { console.log(declaration.name); // prints username, info }); } }); 

Lassen Sie uns nun über die Codegenerierung nachdenken.


Transformations-API


Wie bereits erwähnt, ist es unser Ziel, die TypeScript-Quelldatei zu analysieren, zu überprüfen und eine neue Datei zu erstellen. Die AST -> AST- Konvertierung wird so oft verwendet, dass das TypeScript-Team sogar an eine API zum Erstellen benutzerdefinierter Transformatoren dachte!


Bevor wir zur Hauptaufgabe übergehen, werden wir versuchen, einen einfachen Transformator zu erstellen. Besonderer Dank geht an James Garbutt für die Originalvorlage für ihn.


Lassen Sie den Transformator numerische Literale in Zeichenfolgenliterale ändern.


Zahlentransformator.ts
 const source = ` const two = 2; const four = 4; `; function numberTransformer<T extends ts.Node>(): ts.TransformerFactory<T> { return context => { const visit: ts.Visitor = node => { if (ts.isNumericLiteral(node)) { return ts.createStringLiteral(node.text); } return ts.visitEachChild(node, child => visit(child), context); }; return node => ts.visitNode(node, visit); }; } let result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }, transformers: { before: [numberTransformer()] } }); console.log(result.outputText); /* var two = "2"; var four = "4"; 

Der wichtigste Teil davon sind die VisitorResult Visitor und VisitorResult :


 type Visitor = (node: Node) => VisitResult<Node>; type VisitResult<T extends Node> = T | T[] | undefined; 

Das Hauptziel beim Erstellen eines Transformators ist das Schreiben von Visitor . Logischerweise ist es notwendig, eine rekursive Durchquerung jedes AST-Knotens zu implementieren und ein VisitResult-Ergebnis zurückzugeben (ein, mehrere oder null AST-Knoten). Sie können den Wechselrichter so konfigurieren, dass nur die ausgewählten Knoten auf die Änderung reagieren.


input-output.ts
 // input export namespace protos { // ModuleDeclaration export namespace user { // ModuleDeclaration // Module Block export interface User { // InterfaceDeclaration username: string; // username: string is PropertySignature info: protos.Info.User; // TypeReference } } export namespace Info { export interface User { name: protos.Info.Name; // TypeReference } export interface Name { firstName: string; lastName: string; } } } // this line is a TypeAliasDeclaration export type User = protos.user.User; // protos.user.User is a TypeReference // output export interface User { username: string; info: { // info: { .. } is a TypeLiteral name: { // name: { .. } is a TypeLiteral firstName: string; lastName: string; } } } 

Hier können Sie sehen, mit welchen Knoten wir arbeiten werden.


Der Besucher muss zwei Hauptaktionen ausführen:


  1. Ersetzen von TypeAliasDeclarations durch InterfaceDeclarations
  2. Konvertieren von TypeReferences in TypeLiterals

Lösung


So sieht der Besuchercode aus:


aliasTransformer.ts
 import path from 'path'; import ts from 'typescript'; import _ from 'lodash'; import fs from 'fs'; const filePath = path.resolve(_.first(process.argv.slice(2))); const program = ts.createProgram([filePath], {}); const checker = program.getTypeChecker(); const source = program.getSourceFile(filePath); const printer = ts.createPrinter(); const typeAliasToInterfaceTransformer: ts.TransformerFactory<ts.SourceFile> = context => { const visit: ts.Visitor = node => { node = ts.visitEachChild(node, visit, context); /* Convert type references to type literals interface IUser { username: string } type User = IUser <--- IUser is a type reference interface Context { user: User <--- User is a type reference } In both cases we want to convert the type reference to it's primitive literals. We want: interface IUser { username: string } type User = { username: string } interface Context { user: { username: string } } */ if (ts.isTypeReferenceNode(node)) { const symbol = checker.getSymbolAtLocation(node.typeName); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { /* Type references declarations may themselves have type references, so we need to resolve those literals as well */ return _.map(property.declarations, visit); }); return ts.createTypeLiteralNode(declarations.filter(ts.isTypeElement)); } /* Convert type alias to interface declaration interface IUser { username: string } type User = IUser We want to remove all type aliases interface IUser { username: string } interface User { username: string <-- Also need to resolve IUser } */ if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { // Resolve type alias to it's literals return _.map(property.declarations, visit); }); // Create interface with fully resolved types return ts.createInterfaceDeclaration( [], [ts.createToken(ts.SyntaxKind.ExportKeyword)], node.name.getText(), [], [], declarations.filter(ts.isTypeElement) ); } // Remove all export declarations if (ts.isImportDeclaration(node)) { return null; } return node; }; return node => ts.visitNode(node, visit); }; // Run source file through our transformer const result = ts.transform(source, [typeAliasToInterfaceTransformer]); // Create our output folder const outputDir = path.resolve(__dirname, '../generated'); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir); } // Write pretty printed transformed typescript to output directory fs.writeFileSync( path.resolve(__dirname, '../generated/models.ts'), printer.printFile(_.first(result.transformed)) ); 

Mir gefällt, wie meine Lösung aussieht. Es verkörpert die Leistungsfähigkeit guter Abstraktionen, einen intelligenten Compiler, nützliche Entwicklungstools (VSCode für die automatische Vervollständigung, AST-Explorer usw.) und die Erfahrung anderer erfahrener Entwickler. Den vollständigen Quellcode mit Updates finden Sie hier . Ich bin mir nicht sicher, wie nützlich es für allgemeinere Fälle sein wird, außer für meine privaten. Ich wollte nur die Funktionen des TypeScript-Compiler-Toolkits zeigen und meine Gedanken auf Papier bringen, um ein nicht standardmäßiges Problem zu lösen, das mich lange Zeit beschäftigt hat.


Ich hoffe, dass mein Beispiel jemandem hilft, sein Leben zu vereinfachen. Wenn Sie das Thema AST, Compiler und Transformationen nicht vollständig verstanden haben, klicken Sie auf die von mir bereitgestellten Links zu Ressourcen und Vorlagen von Drittanbietern. Diese sollten Ihnen helfen. Ich musste viel Zeit damit verbringen, diese Informationen zu studieren, um endlich eine Lösung zu finden. Meine ersten Versuche mit privaten Github-Repositories, einschließlich 45 // @ts-ignores und assert s, ließen mich vor Scham rot werden.


Ressourcen, die mir geholfen haben:


Microsoft / TypeScript


Erstellen eines TypeScript-Transformators


TypeScript-Compiler-APIs wurden überarbeitet


AST Explorer

Source: https://habr.com/ru/post/de457770/


All Articles