成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

經(jīng)過一個月的探索,我如何將 AST 操作得跟呼吸一樣自然

開發(fā) 前端
一直以來,前端同學(xué)們對于編譯原理都存在著復(fù)雜的看法,大部分人都覺得自己寫業(yè)務(wù)也用不到這么高深的理論知識,況且編譯原理晦澀難懂,并不能提升自己在前端領(lǐng)域內(nèi)的專業(yè)知識。

[[435642]]

一直以來,前端同學(xué)們對于編譯原理都存在著復(fù)雜的看法,大部分人都覺得自己寫業(yè)務(wù)也用不到這么高深的理論知識,況且編譯原理晦澀難懂,并不能提升自己在前端領(lǐng)域內(nèi)的專業(yè)知識。我不覺得這種想法有什么錯,況且我之前也是這么認(rèn)為的。而在前端領(lǐng)域內(nèi),和編譯原理強相關(guān)的框架與工具類庫主要有這么幾種:

  • 以 Babel 為代表,主要做 ECMAScript 的語法支持,比如 ?. 與 ?? 對應(yīng)的 babel-plugin-optional-chaining[1] 與 babel-plugin-nullish-coalescing-operator[2],這一類工具還有 ESBuild 、swc 等。類似的,還有 Scss、Less 這一類最終編譯到 CSS 的“超集”。這一類工具的特點是轉(zhuǎn)換前的代碼與轉(zhuǎn)換產(chǎn)物實際上是同一層級的,它們的目標(biāo)是得到標(biāo)準(zhǔn)環(huán)境能夠運行的產(chǎn)物。
  • 以 Vue、Svelte 還有剛誕生不久的 Astro 為代表,主要做其他自定義文件到 JavaScript(或其他產(chǎn)物) 的編譯轉(zhuǎn)化,如 .vue .svelte .astro 這一類特殊的語法。這一類工具的特點是,轉(zhuǎn)換后的代碼可能會有多種產(chǎn)物,如 Vue 的 SFC 最終會構(gòu)建出 HTML、CSS、JavaScript。
  • 典型的 DSL 實現(xiàn),其沒有編譯產(chǎn)物,而是由獨一的編譯引擎消費, 如 GraphQL (.graphql)、Prisma (.prisma) 這一類工具庫(還有更熟悉一些的,如 HTML、SQL、Lex、XML 等),其不需要被編譯為 JavaScript,如 .graphql 文件直接由 GraphQL 各個語言自己實現(xiàn)的 Engine 來消費。
  • 語言層面的轉(zhuǎn)換,TypeScript、Flow、CoffeeScript 等,以及使用者不再一定是狹義上前端開發(fā)者的語言,如張宏波老師的 ReScript(原 BuckleScript)、Dart 等。

無論是哪一種情況,似乎對于非科班前端的同學(xué)來說都是地獄難度,但其實社區(qū)一直有各種各樣的方案,來嘗試降低 AST 操作的成本,如 FB 的 jscodeshift[3],相對于 Babel 的 Visitor API,jscodeshift 提供了命令式 + 鏈?zhǔn)秸{(diào)用的 API,更符合前端同學(xué)的認(rèn)知模式(因為就像 Lodash、RxJS 這樣),看看它們是怎么用的:

示例來自于 神光[4] 老師的文章。由于本文的重點并不是 jscodeshift 與 gogocode,這里就直接使用現(xiàn)成的示例了。

  1. // Babel 
  2. const { declare } = require("@babel/helper-plugin-utils"); 
  3.  
  4. const noFuncAssignLint = declare((api, options, dirname) => { 
  5.   api.assertVersion(7); 
  6.  
  7.   return { 
  8.     pre(file) { 
  9.       file.set("errors", []); 
  10.     }, 
  11.     visitor: { 
  12.       AssignmentExpression(path, state) { 
  13.         const errors = state.file.get("errors"); 
  14.         const assignTarget = path.get("left").toString(); 
  15.         const binding = path.scope.getBinding(assignTarget); 
  16.         if (binding) { 
  17.           if ( 
  18.             binding.path.isFunctionDeclaration() || 
  19.             binding.path.isFunctionExpression() 
  20.           ) { 
  21.             const tmp = Error.stackTraceLimit; 
  22.             Error.stackTraceLimit = 0; 
  23.             errors.push( 
  24.               path.buildCodeFrameError("can not reassign to function", Error) 
  25.             ); 
  26.             Error.stackTraceLimit = tmp; 
  27.           } 
  28.         } 
  29.       }, 
  30.     }, 
  31.     post(file) { 
  32.       console.log(file.get("errors")); 
  33.     }, 
  34.   }; 
  35. }); 
  36.  
  37. module.exports = noFuncAssignLint; 
  38.  
  39. // jscodeshift 
  40. module.exports = function (fileInfo, api) { 
  41.   return api 
  42.     .jscodeshift(fileInfo.source) 
  43.     .findVariableDeclarators("foo"
  44.     .renameTo("bar"
  45.     .toSource(); 
  46. }; 

雖然以上并不是同一類操作的對比,但還是能看出來二者 API 風(fēng)格的差異。

以及 阿里媽媽 的 gogocode[5],它基于 Babel 封裝了一層,得到了類似 jscodeshift 的命令式 + 鏈?zhǔn)?API,同時其 API 命名也能看出來主要面對的的是編譯原理小白,jscodeshift 還有 findVariableDeclaration 這種方法,但 gogocode 就完全是 find 、replace 這種了:

  1. $(code) 
  2.     .find("var a = 1"
  3.     .attr("declarations.0.id.name""c"
  4.     .root() 
  5.     .generate(); 

看起來真的很簡單,但這么做也可能會帶來一定的問題,為什么 Babel 要采用 Visitor API?類似的,還有 GraphQL Tools[6] 中,對 GraphQL Schema 添加 Directive 時同樣采用的是 Visitor API,如:

  1. import { SchemaDirectiveVisitor } from "graphql-tools"
  2.  
  3. export class DeprecatedDirective extends SchemaDirectiveVisitor { 
  4.   visitSchema(schema: GraphQLSchema) {} 
  5.   visitObject(object: GraphQLObjectType) {} 
  6.   visitFieldDefinition(field: GraphQLField<anyany>) {} 
  7.   visitArgumentDefinition(argument: GraphQLArgument) {} 
  8.   visitInterface(iface: GraphQLInterfaceType) {} 
  9.   visitInputObject(object: GraphQLInputObjectType) {} 
  10.   visitInputFieldDefinition(field: GraphQLInputField) {} 
  11.   visitScalar(scalar: GraphQLScalarType) {} 
  12.   visitUnion(union: GraphQLUnionType) {} 
  13.   visitEnum(type: GraphQLEnumType) {} 
  14.   visitEnumValue(value: GraphQLEnumValue) {} 

Visitor API 是聲明式的,我們聲明對哪一部分語句做哪些處理,比如我要把所有符合條件 If 語句的判斷都加上一個新的條件,然后 Babel 在遍歷 AST 時(@babel/traverse),發(fā)現(xiàn) If 語句被注冊了這么一個操作,那就執(zhí)行它。而 jscodeshift、gogocode 的 Chaining API 則是命令式(Imperative)的,我們需要先獲取到 AST 節(jié)點,然后對這個節(jié)點使用其提供(封裝)的 API,這就使得我們很可能遺漏掉一些邊界情況而產(chǎn)生不符預(yù)期的結(jié)果。

而 TypeScript 的 API 呢?TypeScript 的 Compiler API 是絕大部分開放的,足夠用于做一些 CodeMod、AST Checker 這一類的工具,如我們使用原生的 Compiler API ,來組裝一個函數(shù):

  1. import * as ts from "typescript"
  2.  
  3. function makeFactorialFunction() { 
  4.   const functionName = ts.factory.createIdentifier("factorial"); 
  5.   const paramName = ts.factory.createIdentifier("n"); 
  6.   const paramType = ts.factory.createKeywordTypeNode( 
  7.     ts.SyntaxKind.NumberKeyword 
  8.   ); 
  9.   const paramModifiers = ts.factory.createModifier( 
  10.     ts.SyntaxKind.ReadonlyKeyword 
  11.   ); 
  12.   const parameter = ts.factory.createParameterDeclaration( 
  13.     undefined, 
  14.     [paramModifiers], 
  15.     undefined, 
  16.     paramName, 
  17.     undefined, 
  18.     paramType 
  19.   ); 
  20.  
  21.   // n <= 1 
  22.   const condition = ts.factory.createBinaryExpression( 
  23.     paramName, 
  24.     ts.SyntaxKind.LessThanEqualsToken, 
  25.     ts.factory.createNumericLiteral(1) 
  26.   ); 
  27.  
  28.   const ifBody = ts.factory.createBlock( 
  29.     [ts.factory.createReturnStatement(ts.factory.createNumericLiteral(1))], 
  30.     true 
  31.   ); 
  32.  
  33.   const decrementedArg = ts.factory.createBinaryExpression( 
  34.     paramName, 
  35.     ts.SyntaxKind.MinusToken, 
  36.     ts.factory.createNumericLiteral(1) 
  37.   ); 
  38.  
  39.   const recurse = ts.factory.createBinaryExpression( 
  40.     paramName, 
  41.     ts.SyntaxKind.AsteriskToken, 
  42.     ts.factory.createCallExpression(functionName, undefined, [decrementedArg]) 
  43.   ); 
  44.  
  45.   const statements = [ 
  46.     ts.factory.createIfStatement(condition, ifBody), 
  47.     ts.factory.createReturnStatement(recurse), 
  48.   ]; 
  49.  
  50.   return ts.factory.createFunctionDeclaration( 
  51.     undefined, 
  52.     [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], 
  53.     undefined, 
  54.     functionName, 
  55.     undefined, 
  56.     [parameter], 
  57.     ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), 
  58.     ts.factory.createBlock(statements, true
  59.   ); 
  60.  
  61. const resultFile = ts.createSourceFile( 
  62.   "func.ts"
  63.   ""
  64.   ts.ScriptTarget.Latest, 
  65.   false
  66.   ts.ScriptKind.TS 
  67. ); 
  68.  
  69. const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 
  70.  
  71. const result = printer.printNode( 
  72.   ts.EmitHint.Unspecified, 
  73.   makeFactorialFunction(), 
  74.   resultFile 
  75. ); 
  76.  
  77. console.log(result); 

以上的代碼將會創(chuàng)建這么一個函數(shù):

  1. export function factorial(readonly n: number): number { 
  2.   if (n <= 1) { 
  3.     return 1; 
  4.   } 
  5.   return n * factorial(n - 1); 

可以看到,TypeScript Compiler API 屬于命令式,但和 jscodeshift 不同,它的 API 不是鏈?zhǔn)降模袷墙M合式的?我們從 identifier 開始創(chuàng)建,組裝參數(shù)、if 語句的條件與代碼塊、函數(shù)的返回語句,最后通過 createFunctionDeclaration 完成組裝。簡單的看一眼就知道其使用成本不低,你需要對 Expression、Declaration、Statement 等相關(guān)的概念有比較清晰地了解,比如上面的 If 語句需要使用哪些 token 來組裝,還需要了解 TypeScript 的 AST,如 interface、類型別名、裝飾器等(你可以在 ts-ast-viewer[7] 實時的查看 TypeScript AST 結(jié)構(gòu))。

因此,在這種情況下 ts-morph[8] 誕生了(原 ts-simple-ast ),它在 TypeScript Compiler API 的基礎(chǔ)上做了一層封裝,大大降低了使用成本,如上面的例子轉(zhuǎn)換為 ts-morph 是這樣的:

  1. import { Project } from "ts-morph"
  2.  
  3. const s = new Project().createSourceFile("./func.ts"""); 
  4.  
  5. s.addFunction({ 
  6.   isExported: true
  7.   name"factorial"
  8.   returnType: "number"
  9.   parameters: [ 
  10.     { 
  11.       name"n"
  12.       isReadonly: true
  13.       type: "number"
  14.     }, 
  15.   ], 
  16.   statements: (writer) => { 
  17.     writer.write(` 
  18. if (n <=1) { 
  19.   return 1; 
  20.  
  21. return n * factorial(n - 1); 
  22.     `); 
  23.   }, 
  24. }).addStatements([]); 
  25.  
  26. s.saveSync(); 
  27.  
  28. console.log(s.getText()); 

是的,為了避免像 TypeScript Compiler API 那樣組裝的場景,ts-morph 沒有提供創(chuàng)建 IfStatement 這一類語句的 API 或者是相關(guān)能力,最方便的方式是直接調(diào)用 writeFunction 來直接寫入。

很明顯,這樣的操作是有利有弊的,我們能夠在創(chuàng)建 Function、Class、Import 這一類聲明時,直接傳入其結(jié)構(gòu)即可,但對于函數(shù)(類方法)內(nèi)部的語句,ts-morph 目前的確只提供了這種最簡單的能力,這在很多場景下可能確實降低了很多成本,但也注定了無法使用在過于復(fù)雜或是要求更嚴(yán)格的場景下。

我在寫到這里時突然想到了一個特殊的例子:Vite[9],眾所周知,Vite 會對依賴進(jìn)行一次重寫,將裸引入(Bare Import)轉(zhuǎn)換為能實際鏈接到代碼的正確導(dǎo)入,如 import consola from 'consola' 會被重寫為 import consola from '/node_modules/consola/src/index.js' (具體路徑由 main 指定,對于 esm 模塊則會由 module 指定) ,這一部分的邏輯里主要依賴了 magic-string 和 es-module-lexer 這兩個庫,通過 es-module-lexer 獲取到導(dǎo)入語句的標(biāo)識在整個文件內(nèi)部的起始位置、結(jié)束位置,并通過 magic-string 將其替換為瀏覽器能夠解析的相對導(dǎo)入(如 importAnalysisBuild.ts[10])。這也帶來了一種新的啟發(fā):對于僅關(guān)注特定場景的代碼轉(zhuǎn)換,如導(dǎo)入語句之于 Vite,裝飾器之于 Inversify、TypeDI 這樣的場景,大動干戈的使用 AST 就屬于殺雞焉用牛刀了。同樣的,在只是對粒度較粗的 AST 節(jié)點(如整個 Class 結(jié)構(gòu))做操作時,ts-morph 也有著奇效。

實際上可能還是有類似的場景:

  • 我只想傳入文件路徑,然后希望得到這個文件里所有的 class 名,import 語句的標(biāo)識(如 fs 即為 import fs from 'fs' 的標(biāo)識符,也即是 Module Specifier),哪些是具名導(dǎo)入(import { spawn } from 'child_process'),哪些是僅類型導(dǎo)入 (import type { Options } from 'prettier'),然后對應(yīng)的做一些操作,ts-morph 的復(fù)雜度還是超出了我的預(yù)期。
  • 我想學(xué)習(xí)編譯相關(guān)的知識,但我不想從教科書和系統(tǒng)性的課程開始,就是想直接來理論實踐,看看 AST 操作究竟是怎么能玩出花來,這樣說不定以后學(xué)起來我更感興趣?
  • 我在維護(hù)開源項目,準(zhǔn)備發(fā)一個 Breaking Change,我希望提供 CodeMod,幫助用戶直接升級到新版本代碼,常用的操作可能有更新導(dǎo)入語句、更新 JSX 組件屬性等。或者說在腳手架 + 模板的場景中,我有部分模板只存在細(xì)微的代碼差異,又不想維護(hù)多份文件,而是希望抽離公共部分,并通過 AST 動態(tài)的寫入特異于模板的代碼。但是!我沒有學(xué)過編譯原理!也不想花時間把 ts-morph 的 API 都過一下...

做了這么多鋪墊,是時候迎來今天的主角了,@ts-morpher[11] 基于 ts-morph 之上又做了一層額外封裝,如果說 TypeScript Compiler API 的復(fù)雜度是 10,那么 ts-morph 的復(fù)雜度大概是 4,而 @ts-morpher 的復(fù)雜度大概只有 1 不到了。作為一個非科班、沒學(xué)過編譯原理、沒玩過 Babel 的前端仔,它是我在需要做 AST Checker、CodeMod 時產(chǎn)生的靈感。

我們知道,AST 操作通常可以很輕易的劃分為多個單元(如果你之前不知道,恭喜你現(xiàn)在知道了),比如獲取節(jié)點-檢查節(jié)點-修改節(jié)點 1-修改節(jié)點 2-保存源文件,這其中的每一個部分都是可以獨立拆分的,如果我們能像 Lodash 一樣調(diào)用一個個職責(zé)明確的方法,或者像 RxJS 那樣把一個個操作符串(pipe)起來,那么 AST 操作好像也沒那么可怕了。可能會有同學(xué)說,為什么要套娃?一層封一層?那我只能說,管它套娃不套娃呢,好用就完事了,什么 Declaration、Statement、Assignment...,我直接統(tǒng)統(tǒng)摁死,比如像這樣(更多示例請參考官網(wǎng)):

  1. import { Project } from "ts-morph"
  2. import path from "path"
  3. import fs from "fs-extra"
  4. import { createImportDeclaration } from "@ts-morpher/creator"
  5. import { checkImportExistByModuleSpecifier } from "@ts-morpher/checker"
  6. import { ImportType } from "@ts-morpher/types"
  7.  
  8. const sourceFilePath = path.join(__dirname, "./source.ts"); 
  9.  
  10. fs.rmSync(sourceFilePath); 
  11. fs.ensureFileSync(sourceFilePath); 
  12.  
  13. const p = new Project(); 
  14. const source = p.addSourceFileAtPath(sourceFilePath); 
  15.  
  16. createImportDeclaration(source, "fs""fs-extra", ImportType.DEFAULT_IMPORT); 
  17.  
  18. createImportDeclaration(source, "path""path", ImportType.NAMESPACE_IMPORT); 
  19.  
  20. createImportDeclaration( 
  21.   source, 
  22.   ["exec""execSync""spawn""spawnSync"], 
  23.   "child_process"
  24.   ImportType.NAMED_IMPORT 
  25. ); 
  26.  
  27. createImportDeclaration( 
  28.   source, 
  29.   // First item will be regarded as default import, and rest will be used as named imports. 
  30.   ["ts""transpileModule""CompilerOptions""factory"], 
  31.   "typescript"
  32.   ImportType.DEFAULT_WITH_NAMED_IMPORT 
  33. ); 
  34.  
  35. createImportDeclaration( 
  36.   source, 
  37.   ["SourceFile""VariableDeclarationKind"], 
  38.   "ts-morph"
  39.   ImportType.NAMED_IMPORT, 
  40.   true 
  41. ); 

這一連串的方法調(diào)用會創(chuàng)建:

  1. import fs from "fs-extra"
  2. import * as path from "path"
  3. import { exec, execSync, spawn, spawnSync } from "child_process"
  4. import ts, { transpileModule, CompilerOptions, factory } from "typescript"
  5. import type { SourceFile, VariableDeclarationKind } from "ts-morph"

再看一個稍微復(fù)雜點的例子:

  1. import { Project } from "ts-morph"
  2. import path from "path"
  3. import fs from "fs-extra"
  4. import { 
  5.   createBaseClass, 
  6.   createBaseClassProp, 
  7.   createBaseClassDecorator, 
  8.   createBaseInterfaceExport, 
  9.   createImportDeclaration, 
  10. from "@ts-morpher/creator"
  11. import { ImportType } from "@ts-morpher/types"
  12.  
  13. const sourceFilePath = path.join(__dirname, "./source.ts"); 
  14.  
  15. fs.rmSync(sourceFilePath); 
  16. fs.ensureFileSync(sourceFilePath); 
  17.  
  18. const p = new Project(); 
  19. const source = p.addSourceFileAtPath(sourceFilePath); 
  20.  
  21. createImportDeclaration( 
  22.   source, 
  23.   ["PrimaryGeneratedColumn""Column""BaseEntity""Entity"], 
  24.   "typeorm"
  25.   ImportType.NAMED_IMPORTS 
  26. ); 
  27.  
  28. createBaseInterfaceExport( 
  29.   source, 
  30.   "IUser"
  31.   [], 
  32.   [], 
  33.   [ 
  34.     { 
  35.       name"id"
  36.       type: "number"
  37.     }, 
  38.     { 
  39.       name"name"
  40.       type: "string"
  41.     }, 
  42.   ] 
  43. ); 
  44.  
  45. createBaseClass(source, { 
  46.   name"User"
  47.   isDefaultExport: true
  48.   extends: "BaseEntity"
  49.   implements: ["IUser"], 
  50. }); 
  51.  
  52. createBaseClassDecorator(source, "User", { 
  53.   name"Entity"
  54.   arguments: [], 
  55. }); 
  56.  
  57. createBaseClassProp(source, "User", { 
  58.   name"id"
  59.   type: "number"
  60.   decorators: [{ name"PrimaryGeneratedColumn", arguments: [] }], 
  61. }); 
  62.  
  63. createBaseClassProp(source, "User", { 
  64.   name"name"
  65.   type: "string"
  66.   decorators: [{ name"Column", arguments: [] }], 
  67. }); 

這些代碼將會創(chuàng)建:

  1. import { PrimaryGeneratedColumn, Column, BaseEntity, Entity } from "typeorm"
  2.  
  3. export interface IUser { 
  4.   id: number; 
  5.  
  6.   name: string; 
  7.  
  8. @Entity() 
  9. export default class User extends BaseEntity implements IUser { 
  10.   @PrimaryGeneratedColumn() 
  11.   id: number; 
  12.  
  13.   @Column() 
  14.   name: string; 

其實本質(zhì)上沒有什么復(fù)雜的地方,就是將 ts-morph 的鏈?zhǔn)?API 封裝好了針對于常用語句類型的增刪改查方法:

  • 目前支持了 Import、Export、Class,下一個支持的應(yīng)該會是 JSX(TSX)。
  • @ts-morpher 將增刪改查方法拆分到了不同的 package 下,如 @ts-morpher/helper 中的方法均用于獲取聲明或聲明 Identifier ,如你可以獲取一個文件里所有的導(dǎo)入的 Module Specifier(fs 之于 import fsMod from 'fs'),也可以獲取所有導(dǎo)入的聲明,但是你不用管這個聲明長什么樣,直接扔給 @ts-morpher/checker ,調(diào)用 checkImportType,看看這是個啥類型導(dǎo)入。

為什么我要搞這個東西?因為在我目前的項目中需要做一些源碼級的約束,如我想要強制所有主應(yīng)用與子應(yīng)用的入口文件,都導(dǎo)入了某個新的 SDK,如 import 'foo-error-reporter' ,如果沒有導(dǎo)入的話,那我就給你整一個!由于不是所有子應(yīng)用、主應(yīng)用都能納入管控,因此就需要這么一個究極強制卡口來放到 CI 流水線上。如果這樣的話,那么用 ts-morph 可能差不多夠了,誒,不好意思,我就是覺得 AST 操作還可以更簡單一點,干脆自己再搞一層好了。

它也有著 100% 的單測覆蓋率和 100+ 方法,而是說它還沒有達(dá)到理想狀態(tài),比如把 AST 操作的復(fù)雜度降到 0.5 以下,這一點我想可以通過提供可視化的 playground,讓你點擊按鈕來調(diào)用方法,同時實時的預(yù)覽轉(zhuǎn)換結(jié)果,還可以在這之上組合一些常見的能力,如合并兩個文件的導(dǎo)入語句,批量更改 JSX 組件等等。

這也是我從零折騰 AST 一個月來的些許收獲,希望你能有所收獲。

參考資料

[1]babel-plugin-optional-chaining: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-optional-chaining

[2]babel-plugin-nullish-coalescing-operator: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-nullish-coalescing-operator

[3]jscodeshift: https://github.com/facebook/jscodeshift

[4]神光: https://www.zhihu.com/people/di-xu-guang-50

[5]gogocode: https://gogocode.io/

[6]GraphQL Tools: https://github.com/ardatan/graphql-tools

[7]ts-ast-viewer: https://ts-ast-viewer.com/#

[8]ts-morph: https://ts-morph.com/

[9]Vite: https://github.com/vitejs/vite

[10]importAnalysisBuild.ts: https://github.com/vitejs/vite/blob/545b1f13cec069bbae5f37c7540171128f439e7b/packages/vite/src/node/plugins/importAnalysisBuild.ts#L217

[11]@ts-morpher: https://ts-morpher-docs.vercel.app/

 

責(zé)任編輯:武曉燕 來源: 三元同學(xué)
相關(guān)推薦

2021-04-26 07:32:30

Spring Boot組件JWT

2018-01-10 12:09:12

Android開發(fā)程序員

2013-05-27 09:47:33

Java開發(fā)Java跨平臺

2021-10-28 05:39:14

Windows 10操作系統(tǒng)微軟

2019-10-08 11:07:55

Python 開發(fā)編程語言

2009-11-23 08:52:02

Windows 7首月銷量

2016-01-11 19:38:51

七牛

2012-08-31 16:40:24

Mac操作系統(tǒng)

2021-07-20 08:57:26

滴滴上市網(wǎng)絡(luò)安全審查

2019-03-11 08:36:00

Office 應(yīng)用微軟

2020-02-14 14:36:23

DevOps落地認(rèn)知

2009-02-16 09:15:49

蘋果喬布斯CEO

2012-12-20 10:18:10

Windows 8

2013-08-12 16:35:22

2019-04-01 14:17:36

kotlin開發(fā)Java

2015-07-30 13:28:44

創(chuàng)業(yè)者無恥

2013-03-08 09:40:00

數(shù)據(jù)百度360

2023-09-04 14:28:33

FlarumDiscourse開源

2010-09-14 16:09:49

sql日期函數(shù)
點贊
收藏

51CTO技術(shù)棧公眾號

主站蜘蛛池模板: 午夜精品久久久久久不卡欧美一级 | 二区三区视频 | 国产一区二区三区在线视频 | av毛片| 国产精品日韩在线观看 | 国产成人午夜精品影院游乐网 | 国产一区二区三区四区 | 国产精品18久久久久久久 | 亚洲一级淫片 | 日韩在线视频观看 | 狠狠色综合欧美激情 | 欧美99久久精品乱码影视 | 日韩不卡一二区 | 国产精品欧美一区二区三区 | 成人国产精品 | 精品视频久久久久久 | 亚洲成人一区二区 | а_天堂中文最新版地址 | 精品国产乱码久久久久久丨区2区 | 国产精品一区二区三区在线 | 天堂亚洲 | 91一区二区三区 | 亚洲精品9999| 免费在线观看黄视频 | 羞羞视频在线网站观看 | 超碰在线人人 | 欧美精品啪啪 | 亚洲精品日韩一区二区电影 | 国产大学生情侣呻吟视频 | 日韩欧美1区2区 | 翔田千里一区二区 | 欧美日产国产成人免费图片 | 一二区成人影院电影网 | 性视频一区 | 国产中文在线 | 日韩一区二区成人 | www,黄色,com | 中文视频在线 | 精精国产xxxx视频在线播放7 | 亚洲 欧美 另类 综合 偷拍 | 成人精品一区二区三区中文字幕 |