Babel 插件開發與單元測試覆蓋度檢查

DelbertKibb 8年前發布 | 35K 次閱讀 單元測試 JavaScript

單元測試,對于控制項目代碼質量,保障線上版本的安全性和穩定性,節省回歸測試成本和降低風險,都有著非常重要的作用。

代碼覆蓋度(Code coverage) 是衡量單元測試有效程度的一個重要指標。

代碼覆蓋度也是持續集成質量把控的一個環節。 coveralls 是一個非常好的代碼覆蓋度檢查平臺,它可以和我們的 github repo 以及 Travis CI 無縫集成在一起。

結合 node-coveralls ,有很多 Node.js 工具可以生成并發送代碼覆蓋度報告,相關的工具包括 MochaNodeunitBlanke.jsJSCoveragePonchoLab 等等。

在本文中,我將介紹使用 Babel 來開發插件,編譯 js 原文件生成可被 Mocha 等工具生成代碼覆蓋度報告的測試文件的方法。使用這個插件,可以讓 coveralls 相關的工具很好地支持對最新的 ES6+ 代碼生成代碼覆蓋度報告。

長文預警:本文內容很長,我先介紹怎樣使用相關工具生成代碼覆蓋度數據,如果對 coveralls、lcov data 已經有所了解,可以跳過這一部分,直接看 Babel 插件的部分。

coveralls 與 lcov data

coveralls 接收一個 lcov data 文件,它用這個文件來計算代碼覆蓋度。一個 lcov data 文件格式看起來如下:

SF:file1.js
DA:3,1
DA:4,19
DA:5,19
DA:6,19
DA:9,1

……

DA:115,2
DA:118,2
DA:121,1
end_of_record
SF:file2.js
DA:1,1
DA:2,1

……

end_of_record

這個文件格式還是比較簡單的, SF: 與 end_of_record 之間表示文件的覆蓋數據,格式為: DA:行號,執行次數 ,例如: DA:5,19 表示代碼的第 5 行被執行了 19 次。

有了 lcov data 文件,我們就可以通過 node-coveralls 與 Travis CI 集成,得到代碼覆蓋率結果。

.travis.yml 大致的內容如下:

language: node_js
node_js:
  - "4"
sudo: false
script:
  - "npm run test-cov"
after_script: "npm install coveralls && cd test-cov && cat coverage.lcov | ../node_modules/coveralls/bin/coveralls.js && cd .. && rm -rf ./test-cov && rm -rf ./test-app"

如果不知道怎樣集成 Travis CI 與 coveralls,可以參考這篇文章。

lcov data 文件

前面說過有很多工具可以配合單元測試工具生成 lcov data 文件。

喜歡用 Mocha 來做單元測試的同學,可以簡單用 mocha-lcov-reporter 來生成 lcov data 文件:

安裝 mocha 和 mocha-lcov-reporter:

$ npm install mocha --save-dev
$ npm install mocha-lcov-reporter --save-dev

安裝一個斷言庫,我喜歡用 chai:

$ npm install chai --save-dev

寫一段 js 文件和測試代碼:

//src/person.js
function Person(name){
  this.name = name;
}

Person.prototype.greeting = function(){
  console.log("hello", this.name);
}

module.exports = Person;
//test/test.js
var Person = require("../src/person");
var chai = require("chai");

describe("test", function(){
  var expect = chai.expect;

  it("person name", function(){
    var akira = new Person("akira");

    expect(akira.name).to.equal("akira");
  });
});

執行測試:

$ mocha --reporter=mocha-lcov-reporter > coverage.lcov

你會發現,目錄下多了一個 coverage.lcov 文件,這是我們需要的代碼覆蓋度文件,然而,這個文件的內容現在什么也沒有。

這是正常的,并不是出了什么錯誤。因為 Mocha 只負責單元測試,并不會修改和監控 person.js 的原始代碼,因此,為了讓它正常生成有內容的 coverage.lcov 文件,我們可以對 person.js 文件做“一點”修改:

/*function Person(name){ this.name = name; } Person.prototype.greeting = function(){ console.log("hello", this.name); } module.exports = Person;*/

global._$jscoverage = global._$jscoverage || {};

_$jscoverage["person.js"] = {};

_$jscoverage["person.js"][1] = 0;
_$jscoverage["person.js"][2] = 0;
_$jscoverage["person.js"][5] = 0;
_$jscoverage["person.js"][6] = 0;
_$jscoverage["person.js"][9] = 0;

_$jscoverage["person.js"].source = [
  "",
  "function Person(name){",
  "this.name = name;",
  "}",
  "",
  "Person.prototype.greeting = function(){",
  "console.log("hello", this.name);",
  "}",
  "",
  "module.exports = Person;"
];

_$jscoverage["person.js"][1]++;
function Person(name){
  _$jscoverage["person.js"][2]++;
  this.name = name;
}

_$jscoverage["person.js"][5]++;
Person.prototype.greeting = function(){
  _$jscoverage["person.js"][6]++;
  console.log("hello", this.name);
}

_$jscoverage["person.js"][9]++;
module.exports = Person;

上面這個修改看上去有點嚇人,只是短短的幾行代碼的 js 文件要被改成這樣,如果內容多一些,靠手工改肯定不行的。不過別擔心,這只是示范,這么做確實有效,現在我們可以看到 coverage.lcov 文件中有內容了:

SF:person.js
DA:1,1
DA:2,1
DA:5,1
DA:6,0
DA:9,1
end_of_record

注意 DA:6,0 說明第 6 行代碼我們沒有測試到,原始文件的第 6 行是:

console.log("hello", this.name);

我們看到它確實沒有被測試到,因為它在 .greeting 方法里,而我們根本就沒有測試這個方法。好吧,現在我們添加一下測試用例:

var Person = require("../src/person");
var chai = require("chai");

describe("test", function(){
  var expect = chai.expect;

  it("person name", function(){
    var akira = new Person("akira");

    expect(akira.name).to.equal("akira");
  });

  it("greeting", function(){
    var akira = new Person("akira");

    akira.greeting();
  });
});

在上面的代碼里,我們添加了一個測試用例,在這個測試用例中,我們只是調用了一下 .greeting( ) 方法,我們現在再測一次:

$ mocha --reporter=mocha-lcov-reporter > coverage.lcov

這次生成的代碼覆蓋率報告如下:

hello akira
SF:person.js
DA:1,1
DA:2,2
DA:5,1
DA:6,1
DA:9,1
end_of_record

我們看到,除了原始代碼的第二行被執行了 2 次,其余各行都被執行了一次。第二行為什么是 2 次呢?因為第二行是:

  this.name = name;

這行代碼在構造函數內部,由于我們兩個 case 分別構造了一個對象,因此這段代碼就被執行了 2 次。

用 jscover 生成 lcov data

上面的例子是我們 手寫 的,這么做在實際項目中當然不可行。一般情況下,我們采用一些工具來生成 lcov data,這里用 jscover 作為例子。

我們先來安裝 jscover:

$ npm install jscover --save-dev

我們將 person.js 的代碼還原成原始版本:

function Person(name){
  this.name = name;
}

Person.prototype.greeting = function(){
  console.log("hello", this.name);
}

module.exports = Person;

現在我們使用 jscover 來“編譯” person.js 代碼:

$ node_modules/jscover/bin/jscover src app

這樣我們將 src/person.js 編譯成了 app/person.js,它看起來像下面這個樣子:

//app/person.js
/* ****** automatically generated by jscover - do not edit ******/
if (typeof _$jscoverage === "undefined") { _$jscoverage = {}; }
/* ****** end - do not edit ******/
//...

if (! this._$jscoverage) {
  this._$jscoverage = {};
  this._$jscoverage.branchData = {};
}
if (! _$jscoverage["person.js"]) {
  _$jscoverage["person.js"] = [];
  _$jscoverage["person.js"][1] = 0;
  _$jscoverage["person.js"][2] = 0;
  _$jscoverage["person.js"][5] = 0;
  _$jscoverage["person.js"][6] = 0;
  _$jscoverage["person.js"][9] = 0;
}
_$jscoverage["person.js"].source = ["function Person(name){"," this.name = name;","}","","Person.prototype.greeting = function(){"," console.log("hello", this.name);","}","","module.exports = Person;"];
_$jscoverage["person.js"][1]++;
function Person(name) {
  _$jscoverage["person.js"][2]++;
  this.name = name;
}
_$jscoverage["person.js"][5]++;
Person.prototype.greeting = function() {
  _$jscoverage["person.js"][6]++;
  console.log("hello", this.name);
};
_$jscoverage["person.js"][9]++;
module.exports = Person;

這樣,我們就可以同樣用 Mocha 來生成 lcov 了,只需要更改模塊加載:

var Person = require("../app/person");

得到我們的 lcov data 文件:

hello akira
SF:person.js
DA:1,1
DA:2,2
DA:5,1
DA:6,1
DA:9,1
end_of_record

注意到控制臺的輸出也進入到 lcov data 文件中來了,這是因為 mocha-lcov-reporter 默認輸出到控制臺,我們實際上只是把控制臺輸出完全寫到文件中來。在一般情況下,實際項目中不會有 console.log,所以我們可以忽略它。如果需要處理的話,也很簡單,我們將文件生成完成之后,把 SF: 與 end_of_record 配對之外的所有的內容刪去即可。

使用 ES6 與 Babel

我們現在繼續實驗,剛才 person.js 采用的是標準的 ES5 寫法,我們現在將它改成 ES6 的版本:

"use strcit";

class Person{
  constructor(name){
    this.name = name;
  }
  greeting(){
    console.log("hello", this.name);
  }
}

module.exports = Person;

我們用 Babel 來編譯一下:

$ babel --presets es2015 src --out-dir app

然后我們再使用 jscover 來生成代碼覆蓋報告:

$ node_modules/jscover/bin/jscover app app
$ mocha --reporter=mocha-lcov-reporter > coverage.lcov
hello akira
SF:person.js
DA:1,1
DA:2,1
DA:4,1
DA:6,1
DA:8,1
DA:9,1
DA:10,2
DA:12,2
DA:15,1
DA:18,1
DA:22,1
DA:25,1
end_of_record

麻煩來了,因為我們處理的是 Babel 編譯過的代碼,因此它不能如實反映出代碼原始行號和測試覆蓋率。那么我們能不能在 Babel 編譯之前先用 jscover 處理它呢?

$ node_modules/jscover/bin/jscover src app

很遺憾,jscover 會報錯,因為它不認為 ES6 語法是合法的:

Exception in thread "main" org.mozilla.javascript.EvaluatorException: missing ; before statement (person.js#3)
    at org.mozilla.javascript.DefaultErrorReporter.runtimeError(DefaultErrorReporter.java:77)
    at org.mozilla.javascript.DefaultErrorReporter.error(DefaultErrorReporter.java:64)
……

那么為了解決這個問題,我們可能得換一個更新的工具來代替 jscover,這個工具需要支持 ES6 語法。但是,我們好像還有一個辦法—— 我們為何不直接使用 Babel?

AST 與 Babel 插件

我們可以直接用 Babel 開發一個插件來直接編譯代碼準備生成 lcov data。這么做有以下幾個好處:

  • 可以與 Babel 的 ES6 編譯過程直接合并在一起,不用分兩次編譯
  • lcov data 的數據直接關聯原始文件,行號和內容信息不會丟失,覆蓋率也能準確計算
  • 不用擔心語法問題,只要 Babel 能處理的語法都能正常運行,也可以和其他插件組合使用。

抽象語法樹(AST)

計算機相關專業的同學應該對語法樹的概念并不陌生,對非計算機專業的同學,這里舉一個簡單的例子稍微解釋一下。

考慮表達式 x = (1 + 2) * 3 ,計算機語言處理的時候將它“展開”成一個樹狀結構:

上面是一個簡單的表達式語法樹,注意到它的節點有不同的類型,圓形表示數值常量,圓角矩形表示操作符,菱形表示變量符號。

這就是基本原理,程序的所有的部分都可以表達為樹的一部分,AST 更復雜一些,它的樹節點類型更多,每個樹結構結點中包含更多的信息,但基本結構也大概類似于下面這樣:

Babel 與 AST

Babel 6 擁有非常強大的插件 API,通過它,我們可以簡單直接地操作文件的 AST。Babel 官方文檔給出開發 Babel 插件的 簡單模板

export default function ({types: t}) {
  return {
    visitor: {
      Identifier(path) {
        let name = path.node.name;
        // reverse the name: JavaScript -> tpircSavaJ
        path.node.name = name.split("").reverse().join("");
      }
    }
  };
}

更加詳細的文檔在: babel-handbook (包含中文版)。

設計插件:transform-coverage

現在,我們可以著手來進行插件的設計與開發。在深入進行之前,希望對 AST 和 Babel 插件進一步了解的同學,可以花一點時間瀏覽一下: Babel 插件手冊 以及 babel-types 。如果不想深入了解也沒關系,因為這個插件并不會用到太復雜的功能。

根據前面我們通過生成 lcov data 所了解的原理,我們的 transform-coverage 需要在代碼的每一條 語句 之前插入一個形式如 _$jscoverage[文件名][行號]++ 的 計數代碼 。同時,我們需要在代碼文件的最前面加上 _$jscoverage 初始化的相關代碼。

所以,我們可以來設計插件的基本結構:

module.exports = function(babel) {
  var t = babel.types;

  var covVisitor = {
    Statement: {
      exit: function(path){
        //插入計數代碼到每一條語句(Statement)前面
      }
    },
    Program: {
      enter: function(path, state){
        //初始化
      },
      exit: function(path, state){
        //添加代碼到文件最前面
      }
    }
  };

  return {visitor: covVisitor};
};

以上就是插件的基本邏輯結構,這里只用到 StatementProgram 兩個 visitor,它們分別表示程序的 根節點語句節點

這里解釋一下 Babel 插件的 visitor 機制,每一個 visitor 屬性對應一個 AST 節點類型,我們可以認為 Babel 插件 遍歷當前 AST 上每一個對應類型的節點 (這有點像是 CSS 選擇器,可以這么理解),因此:

Statement: {
    exit: function(){ 

    }
}

后序遍歷當前 AST 中每一個類型為 Statement 的節點。

這里需要注意每一類節點可以有 enter 和 exit 兩個遍歷時機,分別對應前序遍歷和后續遍歷,前序遍歷指的是當程序進入這個節點的時候對節點做處理,后續遍歷則是當程序退出這個節點的時候對節點做處理。前序和后續兩種方式,在節點有嵌套的時候會影響到節點處理的順序:

if(a > 0){
    b++;
    return;
}

產生的 AST:

{
  Statement:{
      enter: function(path){
          console.log(path.node.type);
      }
  }
}

前序遍歷依次輸出:IfStatement、ExpressionStatement、BlockStatment、ExpressionStatement、ReturnStatement

{
  Statement:{
      exit: function(path){
          console.log(path.node.type);
      }
  }
}

后續遍歷依次輸出:ExpressionStatement、ExpressionStatement、ReturnStatement、BlockStatement、IfStatement

Program 是一個文件的 AST 的根節點,我們在 Program 的 enter 中完成一些內容的初始化,比如拿到當前的文件名(插入檢查覆蓋的代碼的時候要用),初始化一個 Set,用來收集每個 Statement 所在的行號:

module.exports = function(babel) {
  var t = babel.types,
      coverageSet,
      filename;

  var covVisitor = {
    Statement: {
      exit: function(path){
        //插入計數代碼到每一條語句(Statement)前面
      }
    },
    Program: {
      enter: function(path, state){
        //初始化
        filename = state.file.opts.filename.replace(/^[^\/]*\//,"");
        coverageSet = new Set();
      },
      exit: function(path, state){
        //添加代碼到文件最前面
      }
    }
  };

  return {visitor: covVisitor};
};

文件名 filename 可以通過 state.file.opts.filename 拿到,這個 filename 生成計數語句的時候要用到。需要注意的是我們要把 filename 最外層的目錄去除(那一層目錄在編譯的時候改變了),以使得文件相對路徑正確。

這里的 coverageSet 為什么用 Set 而不用一個 Array 是因為與 jscover 一致,我們僅為一行生成一個計數代碼,如果該行有多個語句,我們應該忽略多余的語句,在這里使用 Set 判斷起來更加高效率。我們看一下具體的 Statement 實現:

處理 Statement

Statement: {
  exit: function(path){
    //插入計數代碼到每一條語句(Statement)前面
    var loc = path.node.loc;

    //不用處理 BlockStatement
    if(loc && path.node.type !== "BlockStatement"){
      var line = loc.start.line;

      //有行號的代碼、并且當前行沒有被處理過
      if(line && !coverageSet.has(line)){

        //構造計數語句
        var node = t.expressionStatement(t.updateExpression(
          "++",
          t.memberExpression(
            t.memberExpression(
              t.identifier(jscover), 
              t.stringLiteral(filename), true
            ),
            t.numericLiteral(line), true
          )
        ));

        //記錄當前處理的行號
        coverageSet.add(line);

        //將計數語句插入到當前節點的前面
        path.insertBefore(node);        
      }
    }
  }
}

以上代碼比較簡單,最主要的內容是構造計數語句和將語句插入到當前節點之前。

構造計數語句是通過 babel-types 的方法進行的,這里沒有什么特別有難度的東西,唯一需要注意的是注意它們的嵌套結構,寫起來略微繁瑣一些而已。

插入計數語句更簡單了, path.insertBefore(node) 即可。

處理前置的代碼

最后我們需要創建前面初始化 _$jscoverage 的代碼,這里有幾個步驟要做。我們將它放在 Program 的 exit 中,因為我們需要先拿到 coverageSet,知道有多少行代碼被我們處理并添加了計數,我們要初始化這些計數器:

Program: {
  enter: function(path, state){
    //filename = state.file.opts.sourceFileName;
    filename = state.file.opts.filename.replace(/^[^\/]*\//,"");
    coverageSet = new Set();
  },
  exit: function(path, state){
    ……

    //獲得代碼中的第一條語句
    var body = path.get("body")[0];

    //如果代碼為空的,不做處理,返回
    if(!body) return;

    ……     

    //初始化每個計數器
    var lines = Array.from(coverageSet).sort((a,b)=>a-b);

    for(var i = 0; i < lines.length; i++){
      var node = t.expressionStatement(t.assignmentExpression(
        "=",
        t.memberExpression(
          t.memberExpression(
            t.identifier(jscover), 
            t.stringLiteral(filename),
            true
          ), 
          t.numericLiteral(lines[i]),
          true
        ),
        t.numericLiteral(0)
      ));
      body.insertBefore(node);
    }

    ……
  }
}

剩下的代碼包括初始化 _$jscoverage 、 _$jscoverage[filename] 以及 _$jscoverage[filename].source :

Program: {
  enter: function(path, state){
    //filename = state.file.opts.sourceFileName;
    filename = state.file.opts.filename.replace(/^[^\/]*\//,"");
    coverageSet = new Set();
  },
  exit: function(path, state){
    //獲得原始文件的代碼
    var codeSet = state.file.code.split(/\n/g);

    //獲得代碼中的第一條語句
    var body = path.get("body")[0];

    //如果代碼為空的,不做處理,返回
    if(!body) return;

    //初始化 _$jscoverage
    var node = t.expressionStatement(t.assignmentExpression(
      "=",
      t.memberExpression(
        t.identifier("global"),
        t.identifier(jscover)
      ),
      t.logicalExpression(
        "||",
        t.memberExpression(
          t.identifier("global"),
          t.identifier(jscover)
        ),
        t.objectExpression([])
      )
    ));

    body.insertBefore(node);

    //初始化計數器列表
    var node = t.expressionStatement(t.assignmentExpression(
      "=",
      t.memberExpression(
        t.identifier(jscover), 
        t.stringLiteral(filename),
        true
      ),
      t.arrayExpression([])
    ));

    body.insertBefore(node);      

    //初始化每個計數器
    var lines = Array.from(coverageSet).sort((a,b)=>a-b);

    for(var i = 0; i < lines.length; i++){
      var node = t.expressionStatement(t.assignmentExpression(
        "=",
        t.memberExpression(
          t.memberExpression(
            t.identifier(jscover), 
            t.stringLiteral(filename),
            true
          ), 
          t.numericLiteral(lines[i]),
          true
        ),
        t.numericLiteral(0)
      ));
      body.insertBefore(node);
    }

    //初始化 _$jscoverage[filename].source
    for(var i = 0; i < codeSet.length; i++){
      codeSet[i] = t.stringLiteral(codeSet[i] || "");
    }

    var node = t.expressionStatement(t.assignmentExpression(
      "=",
      t.memberExpression(
        t.memberExpression(
          t.identifier(jscover), 
          t.stringLiteral(filename),
          true
        ), 
        t.identifier("source")
      ),
      t.arrayExpression(codeSet)
    ));

    body.insertBefore(node);  
  }
}

最終,這個 Babel 插件的全部代碼如下:

module.exports = function(babel) {
  var t = babel.types,
      coverageSet,
      filename;

  const jscover = "_$jscoverage";

  var covVisitor = {
    Statement: {
      exit: function(path){
        //insert `_$jscoverage[{filename}][{line}]++` before each statement
        var loc = path.node.loc;

        //ignore BlockStatement
        if(loc && path.node.type !== "BlockStatement"){

          var line = loc.start.line;

          if(line && !coverageSet.has(line)){
            var node = t.expressionStatement(t.updateExpression(
              "++",
              t.memberExpression(
                t.memberExpression(
                  t.identifier(jscover), 
                  t.stringLiteral(filename), true
                ),
                t.numericLiteral(line), true
              )
            ));

            coverageSet.add(line);

            path.insertBefore(node);

            //path.traverse(covVisitor);
          }
        }
      }
    },
    Program: {
      enter: function(path, state){
        //filename = state.file.opts.sourceFileName;
        filename = state.file.opts.filename.replace(/^[^\/]*\//,"");
        coverageSet = new Set();
      },
      exit: function(path, state){
        var codeSet = state.file.code.split(/\n/g);

        var body = path.get("body")[0];

        //exit if file is empty
        if(!body) return;

        //global._$jscoverage = global._$jscoverage || {}
        var node = t.expressionStatement(t.assignmentExpression(
          "=",
          t.memberExpression(
            t.identifier("global"),
            t.identifier(jscover)
          ),
          t.logicalExpression(
            "||",
            t.memberExpression(
              t.identifier("global"),
              t.identifier(jscover)
            ),
            t.objectExpression([])
          )
        ));

        body.insertBefore(node);

        //_$jscoverage[{$filename}] = [];
        var node = t.expressionStatement(t.assignmentExpression(
          "=",
          t.memberExpression(
            t.identifier(jscover), 
            t.stringLiteral(filename),
            true
          ),
          t.arrayExpression([])
        ));

        body.insertBefore(node);      

        /** _$jscoverage[{$filename}][{$line}] = 0; ... */
        var lines = Array.from(coverageSet).sort((a,b)=>a-b);

        for(var i = 0; i < lines.length; i++){
          var node = t.expressionStatement(t.assignmentExpression(
            "=",
            t.memberExpression(
              t.memberExpression(
                t.identifier(jscover), 
                t.stringLiteral(filename),
                true
              ), 
              t.numericLiteral(lines[i]),
              true
            ),
            t.numericLiteral(0)
          ));
          body.insertBefore(node);
        }

        //_$jscoverage[{$filename}].source = [...codes]
        for(var i = 0; i < codeSet.length; i++){
          codeSet[i] = t.stringLiteral(codeSet[i] || "");
        }

        var node = t.expressionStatement(t.assignmentExpression(
          "=",
          t.memberExpression(
            t.memberExpression(
              t.identifier(jscover), 
              t.stringLiteral(filename),
              true
            ), 
            t.identifier("source")
          ),
          t.arrayExpression(codeSet)
        ));

        body.insertBefore(node);  
      }
    }
  };

  return {visitor: covVisitor};
};

如何使用

我將插件發布到 npm 上,你可以安裝然后通過 babel 命令來使用:

安裝 babel-cli 和 插件

$ npm install babel-cli --save-dev
$ npm install babel-preset-es2015 --save-dev
$ npm install babel-plugin-transform-coverage --save-dev

運行插件:

$ babel --presets es2015 --plugins transform-coverage src --out-dir app

以上代碼將 ES6 編譯成 ES5 并添加代碼覆蓋計數。

生成代碼覆蓋率報告:

hello akira
SF:person.js
DA:4,1
DA:5,2
DA:8,1
DA:12,1
end_of_record

結論

由于 jscover 不支持生成 ES6 的代碼覆蓋計數,我寫了一個插件 babel-plugin-transform-coverage ( GitHub 倉庫地址 ),用它可以在編譯 ES6 代碼的同時生成需要的 lcov 代碼覆蓋計數。我們可以將它用在項目的測試腳本中:

關于代碼覆蓋率和開發 Babel 插件有任何問題,歡迎討論。

 

來自: https://www.h5jun.com/post/code-coverage-with-babel-plugin.html

 

 本文由用戶 DelbertKibb 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!