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.tsimport { 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";
$ 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); } })
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);
Lassen Sie uns nun über die Codegenerierung nachdenken.
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);
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.
Hier können Sie sehen, mit welchen Knoten wir arbeiten werden.
Der Besucher muss zwei Hauptaktionen ausführen:
- Ersetzen von TypeAliasDeclarations durch InterfaceDeclarations
- 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); if (ts.isTypeReferenceNode(node)) { const symbol = checker.getSymbolAtLocation(node.typeName); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { return _.map(property.declarations, visit); }); return ts.createTypeLiteralNode(declarations.filter(ts.isTypeElement)); } if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => {
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