如何利用 IOC 改善工程設計

控制反轉 (Inversion of Control) 及其背後的 SOLID 設計原則已經非常成熟,並且在傳統軟件開發領域得到了驗證。

本文從 JavaScript 生態出發,結合領域內流行的基礎設施和成功的項目樣例,對這套這套方法論進行重新審視。

緒論

什麼是 IOC 控制反轉

一個來自 React 的例子:Context – React[1]

Avatar 雖然被諸多底層組件依賴,但是它卻不是被底層組件引入並初始化的,這樣就實現了底層組件與 Avatar 的解耦。

完成了複雜度的收束,並且沒有影響代碼的能力。

這個例子僅說明了 IOC 的核心,完整的 IOC 實踐與 SOLID 設計原則 緊密相關

維基百科: SOLID (面向對象設計)

實際上這也是 IOC 難以被講透的主要原因:IOC 不是一種單獨的技術,而是一整套方法論。

這套方法論試圖解決從項目架構設計,開發協作流程,再到後期項目迭代直到代碼老化等多個環節中的多個問題。

很難說某些優勢是不是 IOC 直接帶來的,但是 IOC 確實和這套方法論配合良好,後面可以看到例子。

兩個關鍵點:

模塊與 IOC

在社區中,也有一些聲音認爲藉助模塊系統的能力,JavaScript 可以獲取與 IOC 類似的優勢。

舉個例子:

// my-class.ts
Class MyClass {}

// 單例
export const myClass = new MyClass();

// 工廠函數
export const makeMyClass() {
 return new Myclass();
}


// foo.ts
import { myClass } from 'my-class.ts';

// bar.ts
import { makeMyClass } from 'my-class.ts';

這裏的 myClass 可以是單例的,並且在它自己的模塊中被初始化,其它模塊不需要知道細節。

在小型項目中,這樣處理是足夠好的,簡單且符合直覺,但是:

InversifyJS:JavaScript 生態內最流行的 IOC 框架

InversifyJS 是一個輕量的 (4KB) IOC 容器 ,可用於編寫 TypeScript 和 JavaScript 應用。

主要目標:

一分鐘認識 InversifyJS

標識符 (Service Identifier) 也可以使用 string 或者其它類型,只要意義清晰即可。其 TS 聲明如下:

InversifyJS 實戰

本小節基於官方文檔改編

步驟 1: 聲明接口和類型

目標是編寫遵循依賴倒置原則的代碼,這意味着我們應該 “依賴於抽象而不依賴於具體實現”。先聲明一些 interface:

// file interfaces.ts

interface Warrior {
    fight(): string;
    sneak(): string;
}

interface Weapon {
    hit(): string;
}

interface ThrowableWeapon {
    throw(): string;
}

Inversifyjs 需要在運行時使用類型標記作爲標識符。接下來將使用 Symbol 作爲標識符:

// file types.ts

const TYPES = {
    Warrior: Symbol.for("Warrior"),
    Weapon: Symbol.for("Weapon"),
    ThrowableWeapon: Symbol.for("ThrowableWeapon")
};

export { TYPES };

這一步完成了整個應用的 模塊抽象 設計。

步驟 2: 使用 @injectable@inject 裝飾器聲明依賴

編寫一些類,來實現上一步聲明的 interface。

希望使用依賴注入的類需要添加 @injectable 裝飾器來激活這個特性,然後就可以使用@inject 聲明依賴。

// file entities.ts
import { injectable, inject } from "inversify";
import "reflect-metadata";
import { Weapon, ThrowableWeapon, Warrior } from "./interfaces"
import { TYPES } from "./types";

@injectable()
class Katana implements Weapon {
    public hit() {
        return "cut!";
    }
}

@injectable()
class Shuriken implements ThrowableWeapon {
    public throw() {
        return "hit!";
    }
}

@injectable()
class Ninja implements Warrior {

    private _katana: Weapon;
    private _shuriken: ThrowableWeapon;

    public constructor(
        @inject(TYPES.Weapon) katana: Weapon,
        @inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
    ) {
        this._katana = katana;
        this._shuriken = shuriken;
    }

    public fight() { return this._katana.hit(); }
    public sneak() { return this._shuriken.throw(); }

}

export { Ninja, Katana, Shuriken };

可選地,也支持使用屬性注入來代替構造函數注入,更加簡潔:

@injectable()
class Ninja implements Warrior {
    @inject(TYPES.Weapon) private _katana: Weapon;
    @inject(TYPES.ThrowableWeapon) private _shuriken: ThrowableWeapon;
    public fight() { return this._katana.hit(); }
    public sneak() { return this._shuriken.throw(); }
}

步驟 3: 創建和配置容器

這一步驟我們真正將 實現 綁定到各自的 抽象 上。

推薦在命名爲 inversify.config.ts 的文件中創建和配置容器。

這是唯一有耦合的地方,項目的其它地方,不應該包含對其他類的引用。

// file inversify.config.ts
import { Container } from "inversify";
import { TYPES } from "./types";
import { Warrior, Weapon, ThrowableWeapon } from "./interfaces";
import { Ninja, Katana, Shuriken } from "./entities";

const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja);
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana);
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken);

export { myContainer };

步驟 4: 解析依賴

您可以使用方法 get<T>從 Container 中獲得依賴。

應該在根結構 (儘可能靠近應用程序的入口點的位置) 去解析依賴(指引入 inversify.config),避免反模式的服務定位器問題。

譯文:服務定位器 Service Locator 是一種反模式的設計 [4]

import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { Warrior } from "./interfaces";

const ninja = myContainer.get<Warrior>(TYPES.Warrior);

expect(ninja.fight()).eql("cut!"); // true
expect(ninja.sneak()).eql("hit!"); // true

InversifyJS 的優勢

本小節基於官方文檔改編

解耦與依賴抽象

InversifyJS 賦予你真正解耦的能力。

在上一小節的實戰中,Ninja 類永遠不會直接持有 Katana 或者 Shuriken 類。但是,它會指向接口(在設計時)或者符號(在運行時)。

由於這是抽象的所以這是可接受的。畢竟 依賴抽象 正是依賴反轉所要做的。InversifyJS 容器是應用中唯一清楚生命週期和依賴關係的元素。

應用中所有的耦合關係發生在唯一一處:inversify.config.ts 文件中。這非常重要,想象我們正在更改一個遊戲的難度級別,只需要去 inversify.config.ts 文件中並且修改 Katana 的綁定即可:

import { Katana } from "./entitites/SharpKatana";

if(difficulty === "hard") {
    container.bind<Katana>(TYPES.KATANA).to(SharpKatana);
} else {
    container.bind<Katana>(TYPES.KATANA).to(Katana);
}

你根本不需要修改 Ninja 文件!

想象一下,如果你在 inversify.config 當中實現一些小機制,理論上可以在運行時對應用的所有功能單元進行動態替換,然後你得到了一個所有內部單元都可以做 AB 測試 / 灰度發佈應用

在下一小節 Theia 的架構中,可以看到此機制是如何提供了魔法般的高度的可定製性與靈活性。

需要付出的代價是符號或者字符串字面量的使用,但是隻要你在一個文件中定義所有的字符串字面量,那麼這個代價將有所緩和 (Redux 中的 actions 就是這麼做的)。

好消息是未來這些符號或者字符串字面量能夠由 TS 編譯器自動生成,但是目前這還在 TC39 委員會的手中。

解決對象組合的痛點

一個常見的模式:

var svc = new ShippingService(
    new Productlocator(),
    new PricingService(),
    new InventoryService(),
    new TrackingRepository(new ConfigProvider()),
    new Logger(new EmailLogger(new ConfigProvider()))
 );

單元之間層層嵌套的依賴關係是 OOP 的一個痛點,並且這種嵌套關係會很快增長到無法有效維護。即使使用工廠函數,你所編寫的額外代碼仍然是不划算的。

類型安全

支持 TypeScript ,被注入的模塊有完整的類型聲明

高級特性

Dive Into Theia

Eclipse Theia [5] 是一個使用現代 Web 技術構建自定義雲和桌面 IDE 和工具的平臺。

Theia 本身並不是一個工具,Theia 是一個開發 IDE 的框架,可以基於 Theia 創建自己的 IDE。Theia 使用 Typescript 編寫,整體技術體系和 Visual Studio Code 類似。

Theia 爲什麼是一個好例子

Theia 的目標與挑戰

這幾個目標對於應用架構設計提出了極高的要求。

Theia 的架構設計

Theia 整體上分爲前端和後端兩個子應用,中間使用 JSON-RPC 通信。

前端

負責顯示 UI,處理交互,運行在瀏覽器 (或 Electron 窗口) 中。前端進程啓動時,將首先加載所有 Extension 貢獻的 DI 模塊,然後獲取 FrontendApplication 的實例並在其上調用 start()。

後端

運行在 Node.js 中,是一個基於 Express.js 的服務。後端應用程序的啓動會首先加載所有所有 Extension 貢獻的 DI 模塊,然後它會獲取一個 BackendApplication 實例並在其上調用 start(portNumber)。

依賴注入

前後端都使用 DI(具體來說就是 Inversify.js) 來組合邏輯,稍後我們會詳細討論。

Extension

Extension 是 Theia 中的功能模塊(npm package),Theia 就是由無數個 Extension 組成的。

編寫一個 Extension 是用戶定製 Theia 的主要方式。用戶提供的 Extension 會和 Theia 內置的 Extension 一起經歷編譯過程,併產出一個可運行的應用。

用戶 Extension 和內置 Extension 地位相同,其權限和能力幾乎不受限制。

注意這與 VS Code 定義的插件 (VS Code Extension) 是不同的。

插件是運行時可動態加載的,在 Theia 中被稱爲 Plugin

因爲 Theia 由前後端兩個子應用組成,所以 Extension 一般也由前後端兩部分組成,其典型目錄結構爲:

可以通過 Theia 的內置模塊 [6],來一窺其是怎麼進行模塊劃分的。

扁平且清晰。

構建 Theia 應用

Theia 可以基於 Package.json 聲明構建:

{
  "private": true,
  "dependencies"{
    "@theia/callhierarchy""latest",
    "@theia/console""latest",
    "@theia/core""latest",
    "@theia/debug""latest",
    "@theia/editor""latest",
    "@theia/editor-preview""latest",
    "@theia/file-search""latest",
    "@theia/filesystem""latest",
    "@theia/getting-started""latest",
    // 以下省略
  },
    "devDependencies"{
        "@theia/cli""latest"
    },
    "scripts"{
        "preinstall""node-gyp install"
    }
}

通過編輯 dependencies, 可以挑選本次構建包含哪些功能模塊。

拆解一個 Theia Extension

此處以內置 Package file-search 爲例,探索一下其內部實現,這個模塊實現了彈出式文件選擇彈窗:

其目錄結構如下:

Common

common/file-search-service.ts

import { CancellationToken } from '@theia/core';

export const fileSearchServicePath = '/services/search';

/**
 * The JSON-RPC file search service interface.
 */
export interface FileSearchService {

    /**
     * finds files by a given search pattern.
     * @return the matching file uris
     */
    find(searchPattern: string, options: FileSearchService.Options, cancellationToken?: CancellationToken): Promise<string[]>;

}

export const FileSearchService = Symbol('FileSearchService');
export namespace FileSearchService {
    export interface BaseOptions {
        useGitIgnore?: boolean
        includePatterns?: string[]
        excludePatterns?: string[]
    }
    export interface RootOptions {
        [rootUri: string]: BaseOptions
    }
    export interface Options extends BaseOptions {
        rootUris?: string[]
        rootOptions?: RootOptions
        fuzzyMatch?: boolean
        limit?: number
    }
}

export const WHITESPACE_QUERY_SEPARATOR = /\s+/;

後端

node/file-search-service-impl.ts

這裏實現了功能的後端服務,依賴的模塊使用 @inject 注入:

import * as cp from 'child_process';
import * as readline from 'readline';

import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';

import { FileUri } from '@theia/core/lib/node/file-uri';
import { RawProcessFactory } from '@theia/process/lib/node';
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
import * as path from 'path';

@injectable()
export class FileSearchServiceImpl implements FileSearchService {

    constructor(
        @inject(ILogger) protected readonly logger: ILogger,
        /** @deprecated since 1.7.0 */
        @inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
    ) { }

    async find(searchPattern: string, options: FileSearchService.Options, clientToken?: CancellationToken): Promise<string[]{
        // 略去具體實現
    }

    private doFind(rootUri: URI, options: FileSearchService.BaseOptions, accept: (fileUri: string) => void, token: CancellationToken): Promise<void> {
        // 略去具體實現
    }

    private getSearchArgs(options: FileSearchService.BaseOptions): string[] {
        // 略去具體實現
    }

}

node/file-search-backend-module.ts

類似 inversify.config.ts 的作用,將 FileSearchServiceImplConnectionHandler 綁定到其抽象:

import { ContainerModule } from '@theia/core/shared/inversify';
import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core/lib/common';
import { FileSearchServiceImpl } from './file-search-service-impl';
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';

export default new ContainerModule(bind ={
    bind(FileSearchService).to(FileSearchServiceImpl).inSingletonScope();
    bind(ConnectionHandler).toDynamicValue(ctx =>
        new JsonRpcConnectionHandler(fileSearchServicePath, () =>
            ctx.container.get(FileSearchService)
        )
    ).inSingletonScope();
});

前端

browser/quick-file-open.ts

包含 UI 相關的主要業務邏輯:

import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import { OpenerService, KeybindingRegistry, QuickAccessRegistry, QuickAccessProvider, CommonCommands } from '@theia/core/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import URI from '@theia/core/lib/common/uri';
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
import { CancellationToken, Command, nls } from '@theia/core/lib/common';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
import * as fuzzy from '@theia/core/shared/fuzzy';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileSystemPreferences } from '@theia/filesystem/lib/browser';
import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser';
import { findMatches, QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';

export const quickFileOpen = Command.toDefaultLocalizedCommand({
    id: 'file-search.openFile',
    category: CommonCommands.FILE_CATEGORY,
    label: 'Open File...'
});
export interface FilterAndRange {
    filter: string;
    range?: Range;
}

// Supports patterns of <path><#|:><line><#|:|,><col?>
const LINE_COLON_PATTERN = /\s?[#:(](?:line "#:(")?(\d*)(?:[#:,](\d* "#:,"))?)?\s*$/;
export type FileQuickPickItem = QuickPickItem & { uri: URI };

@injectable()
export class QuickFileOpenService implements QuickAccessProvider {
    static readonly PREFIX = '';

    @inject(KeybindingRegistry)
    protected readonly keybindingRegistry: KeybindingRegistry;
    @inject(WorkspaceService)
    protected readonly workspaceService: WorkspaceService;
    @inject(OpenerService)
    protected readonly openerService: OpenerService;
    @inject(QuickInputService) @optional()
    protected readonly quickInputService: QuickInputService;
    @inject(QuickAccessRegistry)
    protected readonly quickAccessRegistry: QuickAccessRegistry;
    @inject(FileSearchService)
    protected readonly fileSearchService: FileSearchService;
    @inject(LabelProvider)
    protected readonly labelProvider: LabelProvider;
    @inject(NavigationLocationService)
    protected readonly navigationLocationService: NavigationLocationService;
    @inject(MessageService)
    protected readonly messageService: MessageService;
    @inject(FileSystemPreferences)
    protected readonly fsPreferences: FileSystemPreferences;

    registerQuickAccessProvider(): void {
        this.quickAccessRegistry.registerQuickAccessProvider({
            getInstance: () => this,
            prefix: QuickFileOpenService.PREFIX,
            placeholder: this.getPlaceHolder(),
            helpEntries: [{ description: 'Open File', needsEditor: false }]
        });
    }
    /**
     * Whether to hide .gitignored (and other ignored) files.
     */
    protected hideIgnoredFiles = true;
    /**
     * Whether the dialog is currently open.
     */
    protected isOpen = false;
    private updateIsOpen = true;
    protected filterAndRangeDefault = { filter: '', range: undefined };
    /**
     * Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
     */
    protected filterAndRange: FilterAndRange = this.filterAndRangeDefault;
    /**
     * The score constants when comparing file search results.
     */
    private static readonly Scores = {
        max: 1000,  // represents the maximum score from fuzzy matching (Infinity).
        exact: 500, // represents the score assigned to exact matching.
        partial: 250 // represents the score assigned to partial matching.
    };
    @postConstruct()
    protected init(): void {
        // 省略
    }
    isEnabled(): boolean {
        return this.workspaceService.opened;
    }
    open(): void {
        // 省略
    }
}

browser/quick-file-open-contribution.ts

註冊菜單,快捷鍵和 Command,實現觸發時的回調:

import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { QuickFileOpenService, quickFileOpen } from './quick-file-open';
import { CommandRegistry, CommandContribution, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common';
import { KeybindingRegistry, KeybindingContribution, QuickAccessContribution } from '@theia/core/lib/browser';
import { EditorMainMenu } from '@theia/editor/lib/browser';
import { nls } from '@theia/core/lib/common/nls';

@injectable()
export class QuickFileOpenFrontendContribution implements QuickAccessContribution, CommandContribution, KeybindingContribution, MenuContribution {

    @inject(QuickFileOpenService)
    protected readonly quickFileOpenService: QuickFileOpenService;

    registerCommands(commands: CommandRegistry): void {
        commands.registerCommand(quickFileOpen, {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            execute: (...args: any[]) ={
                let fileURI: string | undefined;
                if (args) {
                    [fileURI] = args;
                }
                if (fileURI) {
                    this.quickFileOpenService.openFile(new URI(fileURI));
                } else {
                    this.quickFileOpenService.open();
                }
            }
        });
    }

    registerKeybindings(keybindings: KeybindingRegistry): void {
        keybindings.registerKeybinding({
            command: quickFileOpen.id,
            keybinding: 'ctrlcmd+p'
        });
    }

    registerMenus(menus: MenuModelRegistry): void {
        menus.registerMenuAction(EditorMainMenu.WORKSPACE_GROUP, {
            commandId: quickFileOpen.id,
            label: nls.localizeByDefault('Go to File...'),
            order: '1',
        });
    }

    registerQuickAccessProvider(): void {
        this.quickFileOpenService.registerQuickAccessProvider();
    }
}

browser/file-search-frontend-module.ts

與後端類似,完成實現到抽象的綁定。

import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { CommandContribution, MenuContribution } from '@theia/core/lib/common';
import { WebSocketConnectionProvider, KeybindingContribution } from '@theia/core/lib/browser';
import { QuickFileOpenFrontendContribution } from './quick-file-open-contribution';
import { QuickFileOpenService } from './quick-file-open';
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';
import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/quick-access';

export default new ContainerModule((bind: interfaces.Bind) ={
    bind(FileSearchService).toDynamicValue(ctx ={
        const provider = ctx.container.get(WebSocketConnectionProvider);
        return provider.createProxy<FileSearchService>(fileSearchServicePath);
    }).inSingletonScope();

    bind(QuickFileOpenFrontendContribution).toSelf().inSingletonScope();
    [CommandContribution, KeybindingContribution, MenuContribution, QuickAccessContribution].forEach(serviceIdentifier =>
        bind(serviceIdentifier).toService(QuickFileOpenFrontendContribution)
    );

    bind(QuickFileOpenService).toSelf().inSingletonScope();
});

注意:以下代碼引入的是 Interface 而非具體實現。此外,Interface 同時也充當了標識符。

 import { KeybindingRegistry, KeybindingContribution, QuickAccessContribution } from '@theia/core/lib/browser';

在這個例子中,InversifyJS 是鏈接代碼模塊的基礎設施,即使處於同個 Package 中的不同模塊, 也是通過 DI 訪問的。

如何給正在行駛的汽車換輪子,並且不讓司機知道

因爲 IOC 的存在,只需要實現一個與原模塊接口相同的模塊,並且覆蓋其綁定,就可以便捷地改變應用的行爲。比如上文中的 QuickFileOpenService,如果對其行爲不滿意,可以創建一個 file-search-patched 的 Extension, 在其中實現一個新的 MyQuickFileOpenService,然後綁定到原抽象即可:

import { QuickFileOpenService } from '@theia/file-search/lib/browser/quick-file-open';
import { MyQuickFileOpenService } from './my-quick-file-open';

bind(QuickFileOpenService).to(MyQuickFileOpenService).inSingletonScope();

神奇的是,QuickFileOpenFrontendContribution 仍然可以正常工作,儘管它:

QuickFileOpenFrontendContribution 通過 @inject 獲取到我們提供的修改版 MyQuickFileOpenService,並且和舊實現接口兼容,所以 QuickFileOpenFrontendContribution 不需要做任何事情。

如何保證所有使用 QuickFileOpenService 的地方都能獲取到的新版實現?

inject 發生於應用邏輯的運行時,而所有的 bind 都在應用入口就提前完成了。

只要 bind 的順序是確定的,那麼可供 inject 的內容就是完全確定的。

在 InversifyJS 的推薦的標準實踐中, bind 集中發生在 Inversify.config 中,順序當然是確定的。

在 Theia 中,因爲用戶通過新增 Package 的方式擴展功能,所以 bind 自然分散在各模塊中。但是 Theia 在構建時引入 Extension 的順序是確定的,然後在應用邏輯啓動前按順序先完成所有模塊的 bind,這樣也就保證了 inject 的結果是確定的。

基於 Theia 進行開發的體驗

直觀來說,Theia 的這套體系解決了:

對開發者來說,解決了在哪寫怎麼寫這兩個核心問題後,出錯的可能就不多了。

筆者之前寫過幾個 Theia 的 Extension,有一些還涉及了深度的定製。在缺乏文檔的情況下,依靠 TS 和參照 Theia 官方 Package,就實現了功能。

雖然 Theia 在其它方面設計也很優秀,但是如果沒有基於 IOC 的這一套方法論,很難想象一個新手開發者經過簡單的學習後可以對這樣一個龐然大物進行二次開發,並且保證架構合理和功能可靠。

沒有銀彈: IOC 的問題

總結

涉及到設計模式的討論,總會有很多似是而非的觀點。

如何將設計模式落地到項目,真正地改善工程設計,是一個複雜的開放性問題,希望這篇文章可以給各位帶來一些啓發。

參考資料

[1]

Context – React: https://zh-hans.reactjs.org/docs/context.html#before-you-use-context

[2]

Modules: CommonJS modules | Node.js v19.4.0 Documentation: https://nodejs.org/api/modules.html#modules_cycles

[3]

ES6 Modules and Circular Dependency: https://stackoverflow.com/questions/46589957/es6-modules-and-circular-dependency

[4]

譯文:服務定位器 Service Locator 是一種反模式的設計: https://juejin.cn/post/7195850600503083066?

[5]

Eclipse Theia : https://theia-ide.org/

[6]

Theia 的內置模塊: https://github.com/eclipse-theia/theia/tree/master/packages

[7]

Contribution 機制: https://theia-ide.org/docs/frontend_application_contribution/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AvoFeACu4wCjQhYeLkP2bw