نكتب محول مخصص AST على TypeScript

عاد فريق TestMace معك. هذه المرة نقوم بنشر ترجمة لمقال حول تحويل كود TypeScript باستخدام المترجم. هل لديك قراءة لطيفة!


مقدمة


هذه هي مشاركتي الأولى ، وأود أن أبدي فيها حلاً لمشكلة واحدة باستخدام واجهة برمجة تطبيقات برنامج التحويل البرمجي لـ TypeScript. لإيجاد هذا الحل بالذات ، قمت بالبحث في العديد من المدونات لفترة طويلة واستوعبت الإجابات على StackOverflow ، لذا لحمايتك من نفس المصير ، سأشارك كل شيء تعلمته حول صندوق الأدوات القوي هذا ، لكن ليس موثقًا بشكل جيد.


المفاهيم الأساسية


أساسيات برنامج التحويل البرمجي لـ TypeScript (مصطلحات المحلل اللغوي ، واجهة برمجة تطبيقات التحويل ، بنية الطبقات) ، شجرة بناء الجملة المجردة (AST) ، نمط تصميم الزائر ، توليد الشفرة.


توصية صغيرة


إذا كانت هذه هي المرة الأولى التي تسمع فيها عن مفهوم AST ، فإنني أوصي بشدة بقراءة هذا المقال بواسطة Videhi Joshi . مجموعتها الكاملة من المقالات من basecs جاءت رائعة ، ستحبها.


وصف المهمة


في Avero ، نستخدم GraphQL ونود أن نضيف سلامة النوع في محللات. بمجرد أن صادفت graphqlgen ، ومعها تمكنت من حل العديد من المشكلات المتعلقة بمفهوم النماذج في GraphQL. لن أتطرق إلى هذه المسألة هنا - ولهذا فإنني أخطط لكتابة مقال منفصل. باختصار ، تصف النماذج قيم الإرجاع للمحللات ، وفي graphqlgen تتصل هذه النماذج بالواجهات من خلال نوع من التكوين (YAML أو ملف TypeScript مع إعلان كتابة).


أثناء التشغيل ، نقوم بتشغيل خدمات gRPC الصغيرة ، وتعمل GQL للجزء الأكبر كواجهة. لقد قمنا بالفعل بنشر واجهات TypeScript بما يتماشى مع عقود proto ، وأردت استخدام هذه الأنواع كنماذج ، لكنني واجهت بعض المشكلات الناجمة عن دعم أنواع التصدير والطريقة التي يتم بها تنفيذ وصف واجهاتنا (تكديس مساحات الأسماء ، وعدد كبير من المراجع).


وفقًا لقواعد النغمة الجيدة للعمل مع شفرة المصدر المفتوح ، كانت خطوتي الأولى هي صقل ما تم فعله بالفعل في مستودع graphqlgen ومن ثم تقديم مساهماتي المفيدة. لتطبيق آلية الاستبطان ، يستخدم graphqlgen المحلل اللغوي @ babel / parser لقراءة ملف وجمع المعلومات حول أسماء الواجهة والإعلانات (حقول الواجهة).


في كل مرة أحتاج إلى القيام بشيء مع AST ، أفتح أولاً astexplorer.net ثم أبدأ في التمثيل. تتيح لك هذه الأداة تحليل AST الذي تم إنشاؤه بواسطة العديد من المحللون ، بما في ذلك babel / parser ومحلل برنامج التحويل البرمجي TypeScript. باستخدام astexplorer.net ، يمكنك تصور هياكل البيانات التي يجب عليك التعامل معها والتعرف على أنواع العقد AST لكل محلل.


ألقِ نظرة على مثال ملف البيانات المصدر و AST الذي تم إنشاؤه على أساسه باستخدام babel-parser:


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" } ] } 

يحتوي جذر الشجرة (عقدة من نوع البرنامج ) على اثنين من العوامل في نصها - ImportDeclaration و ExportNamedDeclaration .


في ImportDeclaration ، نحن مهتمون بشكل خاص بخاصيتين - المصدر والمحددات ، التي تحتوي على معلومات حول النص المصدر. على سبيل المثال ، في حالتنا ، فإن قيمة المصدر تساوي my_company_protos . من المستحيل أن نفهم بهذه القيمة ما إذا كان هذا مسارًا نسبيًا إلى ملف أو رابط إلى وحدة نمطية خارجية. هذا هو بالضبط ما يفعله المحلل اللغوي.


وبالمثل ، يتم تضمين معلومات المصدر في ExportNamedDeclaration . مساحات الأسماء فقط تعقيد هذه البنية عن طريق إضافة تداخل تعسفي إليها ، مما يؤدي إلى المزيد والمزيد من QualifiedTypeIdentifiers . هذه مهمة أخرى يتعين علينا حلها في إطار النهج المختار مع المحلل اللغوي.


لكنني لم أتوصل حتى إلى حل الأنواع من الواردات حتى الآن! نظرًا لأن المحلل اللغوي و AST بشكل افتراضي يوفران كمية محدودة من المعلومات حول النص المصدر ، ثم لإضافة هذه المعلومات إلى الشجرة النهائية ، من الضروري تحليل جميع الملفات المستوردة. ولكن كل ملف من هذا القبيل يمكن أن يكون لها وارداتها!


يبدو أن حل المهام بمساعدة المحلل اللغوي ، نحصل على الكثير من التعليمات البرمجية ... دعنا نتراجع ونفكر مرة أخرى.


الواردات ليست مهمة بالنسبة لنا ، تمامًا مثل بنية الملف غير مهمة. نريد أن نكون قادرين على تمكين جميع خصائص نوع protos.user.User بدلاً من استخدام مراجع الاستيراد. وأين يمكن الحصول على معلومات النوع اللازمة لإنشاء ملف جديد؟


TypeChecker


نظرًا لأننا وجدنا أن الحل مع المحلل اللغوي غير مناسب للحصول على معلومات حول أنواع الواجهات المستوردة ، فلنلقِ نظرة على عملية تجميع TypeScript ومحاولة إيجاد مخرج آخر.


إليك ما يتبادر إلى الذهن على الفور:


TypeChecker هو أساس نظام TypeScript ، ويمكن إنشاؤه من مثيل البرنامج. إنه مسؤول عن تفاعل الشخصيات من ملفات مختلفة مع بعضها البعض ، وتحديد أنواع الحروف وإجراء التحقق الدلالي (على سبيل المثال ، اكتشاف الأخطاء).
أول شيء يفعله TypeChecker هو جمع كل الأحرف من ملفات مصدر مختلفة في عرض واحد ، ثم إنشاء جدول أحرف واحد ، ودمج الأحرف نفسها (على سبيل المثال ، مساحات الأسماء الموجودة في العديد من الملفات المختلفة).
بعد تهيئة الحالة الأولية ، يكون TypeChecker جاهزًا لتقديم إجابات على أي أسئلة حول البرنامج. قد تشمل هذه الأسئلة:
أي رمز يتوافق مع هذه العقدة؟
ما هو نوع الرمز هذا؟
ما هي الشخصيات المرئية في هذا الجزء من AST؟
ما هي التوقيعات المتاحة لإعلان وظيفة؟
ما هي الأخطاء التي يجب إخراجها من هذا الملف؟

TypeChecker هو بالضبط ما نحتاجه! بعد الوصول إلى جدول الرموز وواجهة برمجة التطبيقات ، يمكننا الإجابة عن السؤالين الأولين: ما الرمز الذي يتوافق مع هذه العقدة؟ ما هو نوع الرمز هذا؟ من خلال دمج جميع الأحرف الشائعة ، سيكون بإمكان TypeChecker حل المشكلة مع تراكم مساحات الأسماء التي تم ذكرها مسبقًا!


إذا كيف يمكنك الوصول إلى واجهة برمجة التطبيقات هذه؟


هنا مثال واحد يمكن أن أجده على الشبكة. يوضح أنه يمكن الوصول إلى TypeChecker من خلال أسلوب مثيل البرنامج. له طريقتان checker.getSymbolAtLocation - checker.getSymbolAtLocation و checker.getTypeOfSymbolAtLocation ، والتي تبدو مشابهة جدًا لما نبحث عنه.


لنبدأ العمل على الكود.


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 

نحن مهتمون فقط بالإعلان عن الاسم المستعار للنوع ، لذلك نعيد كتابة الشفرة قليلاً:


printer.ts-نوع
 ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { console.log(node.kind); } }) // prints TypeAliasDeclaration 

يوفر TypeScript الحماية لكل نوع من العقدة ، والتي يمكنك من خلالها معرفة نوع العقدة بالضبط:



الآن عدنا إلى السؤالين اللذين تم طرحهما سابقًا: أي رمز يتوافق مع هذه العقدة؟ ما هو نوع الرمز هذا؟


لذلك ، حصلنا على الأسماء التي تم إدخالها بواسطة تعريفات واجهة الاسم المستعار للنوع من خلال التفاعل مع جدول حروف TypeChecker . بينما لا نزال في بداية الرحلة ، إلا أن هذا وضع جيد للانطلاق من وجهة نظر التأمل .


المدقق-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 }); } }); 

الآن دعونا نفكر في توليد الشفرة .


تحويل API


كما ذكرنا سابقًا ، هدفنا هو تحليل واستكشاف الملف المصدر لـ TypeScript وإنشاء ملف جديد. غالبًا ما يستخدم تحويل AST -> حتى أن فريق TypeScript قد فكر في واجهة برمجة التطبيقات لإنشاء محولات مخصصة !


قبل الانتقال إلى المهمة الرئيسية ، سنحاول إنشاء محول بسيط. شكر خاص لجيمس غاربوت على القالب الأصلي له.


لنجعل المحول يغير القيم العددية إلى الأوتار.


رقم transformer.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"; 

الجزء الأكثر أهمية منه هو VisitorResult Visitor و VisitorResult :


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

الهدف الرئيسي عند إنشاء محول هو كتابة الزائر . منطقياً ، من الضروري تطبيق اجتياز متكرر لكل عقدة AST وإرجاع نتيجة VisitResult (عقد واحدة أو عدة أو صفر AST). يمكنك تكوين العاكس بحيث تستجيب العقد المحددة فقط للتغيير.


المدخلات و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; } } } 

هنا يمكنك رؤية العقد التي سنعمل معها.


يجب على الزائر القيام بعملين رئيسيين:


  1. استبدال TypeAliasDeclarations مع InterfaceDeclarations
  2. تحويل TypeReferences إلى TypeLiterals

قرار


هذا هو ما يشبه رمز الزائر:


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)) ); 

أنا أحب كيف يبدو حل بلدي. إنها تجسد قوة التجريدات الجيدة ، ومترجم ذكي ، وأدوات تطوير مفيدة (الإكمال التلقائي VSCode ، AST explorer ، إلخ) ومجموعات من المطورين المهرة الآخرين. يمكن الاطلاع على التعليمات البرمجية المصدر الكامل مع التحديثات هنا . لست متأكدًا من مدى فائدة ذلك في الحالات العامة ، بخلاف حالاتي الخاصة. أردت فقط إظهار قدرات مجموعة أدوات برنامج التحويل البرمجي لـ TypeScript ، وكذلك وضع أفكاري على الورق لحل مشكلة غير قياسية أزعجتني لفترة طويلة.


آمل أن يساعد مثالي شخص ما في تبسيط حياته. إذا لم يتم فهم موضوع AST والمترجمين والتحويلات فهمًا تامًا من جانبك ، فاتبع الارتباطات الخاصة بمصادر وقوالب الجهات الخارجية التي قدمتها ، فيجب عليهم مساعدتك. اضطررت لقضاء الكثير من الوقت في دراسة هذه المعلومات من أجل إيجاد حل أخيرًا. إن محاولاتي الأولى في مستودعات جيثب الخاصة ، بما في ذلك 45 // @ts-ignores and assert s ، جعلتني أشعر بالخجل.


الموارد التي ساعدتني:


مايكروسوفت / النوع


إنشاء محول TypeScript


TypeScript مترجم واجهات برمجة التطبيقات


AST المستكشف

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


All Articles