vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

語言: CN / TW / HK

經典的架構設計可以跨越時間和語言,得以傳承。

—— 題記

一、背景

悟空活動中臺技術文章系列又和大家見面了,天氣漸冷,注意保暖。

在往期的系列技術文章中我們主要集中分享了前端技術的方方面面,如微元件的狀態管理,微元件的跨平臺探索,以及有損佈局,效能優化等等。還未關注到的同學,如果感興趣可以檢視往期文章。

今天的技術主題要有點不一樣,讓我們一起來聊聊悟空活動中臺在應用服務層的一些技術建設。

在悟空活動中臺的技術架構設計中,我們充分擁抱 JavaScript 生態,希望推進 JavaScript 的全棧開發流程,所以在應用層的服務端,我們選擇了 Node 作為 BFF(Backend For Fronted) 層解決方案。

希望藉此來充分發揮JavaScript 的效能,可以更高效、更高質量的完成產品的迭代。

通過實踐發現 JavaScript 全棧的開發流程給我們帶來的好處:

  1. 前後端使用 JavaScript 來構建,使得前後端更加融合且高效。前後端程式碼的複用中部分模組和元件不僅僅可以在前端使用,也可以在後端使用。

  2. 減少了大量的溝通成本。圍繞著產品的需求設計和迭代,前端工程師在前端和後端的開發上無縫的切換,保證業務的快速落地。

  3. 開發者全域性開發的視角。讓前端的工程師有機會從產品、前端、後端的視角去思考問題和技術的創新。

當然 Node 只是服務應用開發的一部分。當我們需要儲存業務資料時,我們還需要一個數據的持久化解決方案。悟空活動中臺選擇成熟又可靠的 MySQL 來作為我們的資料儲存資料庫。那我們就需要思考 Node 和 MySQL 如何搭配才能更好的釋放彼此的能力,接下來讓我們一起走上探索之路。

二、Node 資料持久層現狀與思考

1、純粹的 MySQL 驅動

Node-MySQL是 Node 連線 MySQL的驅動,使用純 JavaScript 開發,避免了底層的模組編譯。讓我們看看如何使用它,首先我們需要安裝這個模組。

示例如下:

npm install mysql # 之前0.9的版本需要這樣安裝 npm install mysqljs/mysql

常規使用過程如下:

var mysql      = require('mysql');
var connection = mysql.createConnection({
  host     : '..', // db host
  user     : '..', // db user
  password : '',   // db password
  database : '..'  // which database
});

connection.connect();

connection.query(
  'SELECT id, name, rank FROM lanaguges', 
  function (error, results, fields) {
    if (error) throw error;
    /**
     * 輸出:
     * [ RowDataPacket {
     *    id: 1,
     *    name: "Java",
     *    rank: 1
     *  },
     *  RowDataPacket {
     *    id: 2,
     *    name: "C",
     *    rank: 2
     *  }
     *]
     *
     *
     */
    console.log('The language rank is: ', results);
});

connection.end();

通過上述的例子,我們對 MySQL 模組的使用方式有個簡單的瞭解,基本的使用方式就是建立連線,執行 SQL 語句,得到結果,關閉連線等。

在實際的專案中我們很少直接使用該模組,一般都會在該模組的基礎上進行封裝,如:

  • 預設使用資料庫連線池的方式來提升效能。
  • 改進callback的回撥函式的風格,遷移到 promise,async/await 更現代化 JavaScript 的非同步處理方案。
  • 使用更加靈活的事務的處理。
  • 針對複雜 SQL 的編寫,通過字串拼接的方式是比較痛苦的,需要更語義化的 SQL 編寫能力。

2、主流的 ORM

目前在資料持久層技術解決方案中 ORM 仍然主流的技術方案,ORM是"物件-關係對映"(Object/Relational Mapping)的縮寫,簡單來說ORM 就是通過例項物件的語法,完成關係型資料庫的操作的技術,如圖-1。

無論是 Java 的 JPA 技術規範以及 Hibernate 等技術實現,或者 Ruby On Rails 的 ActiveRecord,亦或 Django 的 ORM。幾乎每個語言的生態中都有自己的ORM 的技術實現方案。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-1 O/R Mapping

ORM 把資料庫對映成物件:

  • 資料庫的表(table) => 類(class)
  • 記錄(row,行資料)=> 物件(object)
  • 欄位(field)=> 物件的屬性(attribute)

Node 在 ORM 的技術方案上,社群有不同的角度的探索,充分體現了社群的多樣性,比如目前非常流行的 Sequelize。Sequelize 是一個基於 Promise 的 Node.js ORM, 目前支援 PostgreSQL、MySQL、SQLite 以及 SQL-Server。它具有強大的事務支援、關聯關係、預讀、延遲載入、讀取複製等功能。如上述 MySQL 使用的案例,若使用Sequelize ORM方式來實現,程式碼如下:

// 定義ORM的資料與model對映
const Language = sequelize.define('language', {
  // 定義id, int型 && 主鍵
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true
  },
  // 定義name, string型別對映資料庫varchar
  name: {
    type: DataTypes.STRING,
  },
  // 定義rank, string型別對映資料庫varchar
  range: {
    type: DataTypes.INTEGER
  },
}, {
  // 不生成時間戳
  timestamps: false
});

// 查詢所有
const languages = await Language.findAll()

3、未來之星 TypeORM

自從有了 TypeScript 之後,讓我們從另外一個視角去看待前端的工具鏈和生態,TypeScript 的型別體系給了我們更多的想象,程式碼的靜態檢查糾錯、重構、自動提示等。帶著這些新視角出現了社群比較熱捧的 TypeORM。也非常值得我們借鑑學習。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-2 TypeORM

TypeORM 充分結合 TypeScript,提供更好的開發體驗。其目標是始終支援最新的 JavaScript 功能,並提供其他功能來幫助您開發使用資料庫的任何型別的應用程式,從帶有少量表的小型應用程式到具有多個數據庫的大型企業應用程式。

與現有的所有其他 JavaScript ORM 不同,TypeORM 支援 Active Record (RubyOnRails 的 ORM 的核心)和 Data Mapper (Django 的 ORM 的核心設計模式)模式,這意味著我們可以以最有效的方式編寫高質量、鬆散耦合、可伸縮、可維護的應用程式。

4、理性思考

在眾所周知軟體開發中,並不存在真正的銀彈方案,ORM 給我們帶來了更快的迭代速度,也還是存在一些不足。體現在:

  • 對於簡單的場景 CRUD 非常快,對於多表和複雜關聯查詢就會有點力不從心。
  • ORM 庫不是輕量級工具,需要花很多精力學習和設定。
  • 對於複雜的查詢,ORM 要麼是無法表達,要麼是效能不如原生的 SQL。
  • ORM 抽象掉了資料庫層,開發者無法瞭解底層的資料庫操作,也無法定製一些特殊的 SQL。
  • 容易產生N+1查詢的問題。

我們開始思考怎麼在 ORM 的基礎上,保留強悍的 SQL 的表達能力呢?最終,我們把目光停留在了 Java 社群非常流行的一款半自動化的 ORM 的框架上面 MyBatis。

三、悟空活動中臺在資料持久層的探索

通過思考,我們迴歸原點重新審視這個問題,我們認為 SQL 是程式和資料庫互動最好的領域語言,簡單易學通用性強且無需迴避 SQL 本身。同時 MyBatis 的架構設計給與我們啟發,在技術上是可以做到保留 SQL 的靈活強大,同時兼顧從 SQL 到物件的靈活對映。

1、什麼是 MyBatis ?

MyBatis 是一款優秀的持久層框架,它支援自定義 SQL、儲存過程以及高階對映。MyBatis 免除了幾乎所有的 JDBC 程式碼以及設定引數和獲取結果集的工作。MyBatis 可以通過簡單的 XML 或註解來配置和對映原始型別、介面和 Java POJO(Plain Old Java Objects,普通老式 Java 物件)為資料庫中的記錄。MyBatis 的最棒的設計就是在物件的對映和原生 SQL 強大之間取得了很好的平衡。

SQL 配置

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.mybatis.example.BlogMapper">
  <select id="selectBlog" resultType="Blog">
    select  name  from blog where id = #{id}
  </select>

SQL 查詢

BlogMapper mapper = session.getMapper(BlogMapper.class);
Blog blog = mapper.selectBlog(101);

於是我們開始構建 Node 的 MyBatis,技術上實現的 Node-MyBatis 具有的特性

  • 簡單易學。程式碼實現小而簡單。沒有任何第三方依賴,易於使用。
  • 靈活。Node-Mybatis 不會對應用程式或者資料庫的現有設計強加任何影響。藉助 ES6 的 string template編寫 SQL,靈活直接。
  • 解除 SQL 與程式程式碼的耦合。

通過提供 DAO 層,將業務邏輯和資料訪問邏輯分離,使系統的設計更清晰,更易維護,更易單元測試。

  • 支援動態 SQL 。避免 SQL 的字串拼接。
  • 防止 SQL 注入。自動對動態引數進行 SQL 防注入。
  • 宣告式事務機制。藉助 decorator 更容易進行事務宣告。
  • 結合 Typescript 的型別。根據資料的表格結構自動生成資料的型別定義檔案,程式碼提升補齊,提升開發體驗。

2、Node-MyBatis 解決方案

在我們業務開發中,我們構建的 SQL 肯定需要根據業務進行判斷和動態拼接,如果每條 SQL 都自己手動的拼接又回到了 MySQL 樸素的模式,一不小心就造成了大量的 SQL 注入等問題,那我們怎麼辦呢?這個時候就需要呼喚出 Node-MyBatis 的動態 SQL 的 uilder 模式了。

(1)SQL-Builder

# 表示式

#:針對動態 SQL中的佔位符,我們最經常碰到的場景就是字串的佔位符,# 後面就是將來動態替換的變數的名稱。如:

SELECT
    id as id,
    book_name as bookName
    publish_time as publishTime
    price as price
  FROM t_books t
  WHERE
    t.id = #data.id AND t.book_name = #data.bookName

 -- 該 SQL 通過 Node-MyBatis 底層的 SQL Compile 解析之後,生成的 SQL如下,
 -- data 引數為: {id: '11236562', bookName: 'JavaScript紅皮書' }

 SELECT
    id as id,
    book_name as bookName
    publish_time as publishTime
    price as price
  FROM t_books t
  WHERE
    t.id = '11236562' AND t.book_name = 'JavaScript紅皮書'

$ 表示式

$: 動態資料的佔位符,該佔位符會在我們的 sql template 編譯後將變數的值動態插入 SQL ,如下:

SELECT 
  id, name, email 
FROM t_user t
WHERE t.state=$data.state AND t.type in ($data.types)

-- 該 SQL 通過 Node-MyBatis 底層的 SQL Compile 解析之後,生成的 SQL如下
-- data 引數為: {state: 1, types: [1,2,3]}

SELECT 
  id, name, email 
FROM t_user t
WHERE t.state=0 AND t.type in (1,2,3)

<%%> 程式碼塊

模板也是語言,那就是圖靈完備的,迴圈、分支結構都是必不可少的。我們需要提供動態的程式設計的能力來應對更加複雜的 SQL 場景,那如何進行程式碼塊的標記呢?悟空採用類似 EJS 模板的語法特徵 <%%> 進行程式碼標記,並且來降低了 SQL 模版學習的難度。下面演示在 SQL 模板中的使用方法。

-- 迴圈
SELECT
    t1.plugin_id as pluginId,
    t1.en_name  as pluginEnName,
    t1.version as version,
    t1.state as state
 FROM test_npm_list  t1
    WHERE t1.state = '0'
  <% for (let [name, version] of data.list ) { %>
     AND t1.en_name = #name AND t1.version=#version
  <% } %>

-- 分支判斷
SELECT 
   id,
   name, 
   age 
FROM users
WHERE name like #data.name
<% if(data.age > 10) {%>
AND age = $data.age
<% } %>

那如何實現上述的功能呢?

我們通過藉助 ES6 的 String Template 可以實現一個非常精簡的模板系統。下面我們來通過模板字串輸出模板結果的案例。

let template = `
<ul>
  <% for(let i=0; i < data.users.length; i++) { %>
    <li><%= data.users[i] %></li>
  <% } %>
</ul>
`;

上面程式碼在模板字串之中,放置了一個常規模板。該模板使用   <%...%\> 放置 JavaScript 程式碼,使用 <%= ... %> 輸出 JavaScript 表示式。怎麼編譯這個模板字串呢?思路是將其轉換為 JavaScript 表示式字串,目標就是轉化為下述字串。

print('<ul>');
for(let i = 0; i < data.users.length; i++) {
  print('<li>');
  print(data.users[i]);
  print('</li>');
};
print('</ul>');

第一:採用了正則表示式進行匹配轉化。

let evalExpr = /<%=(.+?)%>/g;
let expr = /<%([\s\S]+?)%>/g;

template = template
  .replace(evalExpr, '`); \n  print( $1 ); \n  echo(`')
  .replace(expr, '`); \n $1 \n  print(`');

template = 'print(`' + template + '`);';
console.log(template);

// 輸出

echo(`
<ul>
  `);
  for(let i=0; i < data.supplies.length; i++) {
  echo(`
    <li>`);
  echo(  data.supplies[i]  );
  echo(`</li>
  `);
  }
  echo(`
</ul>
`);

第二:將 template 正則封裝在一個函式裡面返回。這樣就實現了模板編譯的能力,完整程式碼如下:

function compile(template){
  const evalExpr = /<%=(.+?)%>/g;
  const expr = /<%([\s\S]+?)%>/g;

  template = template
    .replace(evalExpr, '`); \n  print( $1 ); \n  echo(`')
    .replace(expr, '`); \n $1 \n  print(`');

  template = 'print(`' + template + '`);';

  let script =
  `(function parse(data){
    let output = "";

    function print(html){
      output += html;
    }

    ${ template }

    return output;
  })`;

  return script;
}

第三:通過 compile 函式,我們獲取到了一個 SQL Builder的 高階函式,傳遞引數,即可獲取最終的 SQL 模板字串。

let parse = eval(compile(template));
parse({ users: [ "Green", "John", "Lee" ] });
//   <ul>
//     <li>Green</li>
//     <li>John</li>
//     <li>Lee</li>
//   </ul>

根據這種模板的思路,我們設計自己的 sqlCompile 來生成 SQL 的程式碼。

sqlCompile(template) {
    template =
        'print(`' +
        template
            // 解析#動態表示式
            .replace(/#([\w\.]{0,})(\W)/g, '`); \n  print_str( $1 ); \n  print(`$2')
            // 解析$動態表示式
            .replace(/\$([\w\.]{0,})(\W)/g, '`); \n  print( $1 ); \n  print(`$2')
            // 解析<%%>動態語句
            .replace(/<%([\s\S]+?)%>/g, '`); \n $1 \n  print(`') +
        '`);'
    return `(function parse(data,connection){
      let output = "";
      function print(str){
        output += str;
      }
      function print_str(str){
       output += "\'" + str + "\'";
      }
      ${template}
      return output.replace(/[\\r\\n]/g,"");
    })`
  }

(2)SQL 防注入

SQL 支援拼接就可能存在 SQL 的注入可能性,Java 中 MyBatis  $ 動態表示式的使用也是有注入風險的,因為 $ 可以置換變數不會被包裹字元引號,社群也不建議使用 $ 符號來拼接 SQL。對於 Node-MyBatis 來說,因為保留了 $ 的能力,所以需要處理 SQL 注入的風險。參考 MyBatis 的 Node-MyBatis 工具用法也比較簡單,示例如下:

// data = {name: 1}
`db.name = #data.name` // => 字元替換,會被轉義成  db.name = "1"
`db.name = $data.name` // => 完整替換,會被轉義成  db.name =  1

注入場景

// SQL 模板
`SELECT * from t_user WHERE username = $data.name and paasword = $data.passwd`
// data 資料為 {username: "'admin' or 1 = 1 --'", passwd: ""}
// 這樣通過 SQL註釋構造 形成了SQL的注入
`SELECT * FROM members WHERE username = 'admin' or 1 = 1 -- AND password = ''`

// SQL 模板
`SELECT * from $data.table`
// data 資料為 {table: "user;drop table user"}
// 這樣通過 SQL註釋構造 形成了SQL的注入
`SELECT * from user;drop table user`

針對常見的拼接 SQL 的場景,我們就不一一敘述了。下面將從常見的不可避免的拼接常見入手,和大家講解 Node-Mybatis 的規避方案。該方案使用 MySQL 內建的 escape 方法或 SQL 關鍵字攔截方法進行引數傳值規避。

escape轉義,使用 $ 的進行傳值,模板底層會先走 escape 方法進行轉義,我們用一個包含不同的資料型別的資料進行 escape 能力檢測,如:

const arr = escape([1,"a",true,false,null,undefined,new Date()]);

// 輸出
( 1,'a', true, false, NULL, NULL, '2019-12-13 16:19:17.947')

關鍵字攔截,在 SQL 需要使用到資料庫關鍵字,如表名、列名和函式關鍵字 where、 sum、count 、max 、 order by 、 group by 等。若直接拼裝 SQL 語句會有比較明顯的 SQL 注入隱患。因此要約束 $ 的符號的使用值範圍。特殊業務場景,如動態排序、動態查詢、動態分組、動態條件判斷等,需要開發人員前置列舉判斷可能出現的確定值再傳入SQL。Node-MyBatis 中預設攔截了高風險的 $ 入參關鍵字。

if(tag === '$'){
  if(/where|select|sleep|benchmark/gi.test(str)){
    throw new Error('$ value not allowed include where、select、sleep、benchmark keyword !')
  }
  //...
}

配置攔截,我們為了控制 SQL 的注入風險,在 SQL 查詢時預設不支援多條語句的執行。MySQL 底層驅動也有相同的選項,預設關閉。在 MySQL 驅動的文件中提供了詳細的解釋如下:

Connection options - 連線屬性

multipleStatements: 

  • Allow multiple mysql statements per query. Be careful with this, it could increase the scope of SQL injection attacks. (Default: false)

  • 每個查詢允許多個mysql語句。 請注意這一點,它可能會增加SQL注入***的範圍。 (預設值:false)

Node-MyBatis 中預設規避了多行執行語句的配置與 $ 共同使用的場景。

if(tag === '$'){
  if(this.pool.config.connectionConfig.multipleStatements){
    throw new Error('$ and multipleStatements mode not allowed to be used at the same time !')
  }
  //...
}

SQL 注入檢測

sqlmap 是一個開源的***測試工具,可以用來進行自動化檢測,利用 SQL 注入漏洞,獲取資料庫伺服器的許可權。它具有功能強大的檢測引擎,針對各種不同型別資料庫的***測試的功能選項,包括獲取資料庫中儲存的資料,訪問作業系統檔案甚至可以通過外帶資料連線的方式執行作業系統命令。sqlmap 支援 MySQL, Oracle, PostgreSQL, Microsoft SQL Server, Microsoft Access, IBM DB2, SQLite, Firebird, Sybase 和 SAP MaxDB 等資料庫的各種安全漏洞檢測。

sqlmap 支援五種不同的注入模式:

  • 基於布林的盲注 即可以根據返回頁面判斷條件真假的注入;
  • 基於時間的盲注 即不能根據頁面返回內容判斷任何資訊,用條件語句檢視時間延遲語句是否執行(即頁面返回時間是否增加)來判斷;
  • 基於報錯注入 即頁面會返回錯誤資訊,或者把注入的語句的結果直接返回在頁面中;
  • 聯合查詢注入 可以使用union的情況下的注入;
  •  堆查詢注入可以同時執行多條語句的執行時的注入。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-3 - SQLmap的使用

安裝&使用

//安裝方法
git clone --depth 1 https://github.com/sqlmapproject/sqlmap.git sqlmap-dev

//使用方法
sqlmap -u 'some url' --flush-session --batch --cookie="some cookie"

常用命令引數

  • -u 設定想要驗證的網站url
  • --flush-session 清除過去的歷史記錄
  • --batch 批量驗證注入
  • --cookie如果需要登入 設定cookie值

明確 sqlmap  使用方法後,我們在實際專案打包過程中可以基於 sqlmap 構建我們的自定義化測試指令碼,在提交程式碼之後,通過 GitLab 的整合工具自動觸發進行工程的驗證。

(3)宣告式事務

在 Node 和資料庫的互動上,針對更新的 SQL 場景,我們需要對事務進行管理,手動管理事務比較費時費力,Node-MyBatis 提供了更好的事務管理機制,提供了宣告式的事務管理能力,將我們從複雜的事務處理中解脫出來,獲取連線、關閉連線、事務提交、回滾、異常處理等這些操作都將自動處理。

宣告式事務管理使用了 AOP 實現的,本質就是在目標方法執行前後進行攔截。在目標方法執行前加入或建立一個事務,在執行方法執行後,根據實際情況選擇提交或是回滾事務。不需要在業務邏輯程式碼中編寫事務相關程式碼,只需要在配置檔案配置或使用註解(@Transaction),這種方式沒有侵入性。

在程式碼的實現上,我們使用 ES7 規範中裝飾器的規範,來實現對目標類,方法,屬性的修飾。裝飾器的使用非常簡單,其本質上就是一個函式包裝。下面我們封裝一個簡單的 log 裝飾器函式。

裝飾類

function log(target, name, descriptor) {
  console.log(target)
  console.log(name)
  console.log(descriptor)
}

@log
class User {
  walk() {
    console.log('I am walking')
  }
}

const u = new User()
u.walk()

裝飾方法

function log(target, name, descriptor) {
  console.log(target)
  console.log(name)
  console.log(descriptor)
}

class Test {
  @log // 裝飾類方法的裝飾器
  run() {
    console.log('hello world')
  }
}

const t = new Test()
t.run()

裝飾器函式有三個引數,其含義在裝飾不同屬性時表現也不用。在裝飾類的時候,第一個引數表示類的函式本身。之前 log 輸出如下:

[Function: User]
undefined
undefined
I am walking

在裝飾類方法的時候,第一個引數表示類的原型( prototype ), 第二個引數表示方法名, 第三個引數表示被裝飾引數的屬性。之前 log 輸出如下:

Test { run: [Function] }
run
{
  value: [Function],
  writable: true,
  enumerable: true,
  configurable: true
}
hello world

第三個 describe 引數內有如下屬性:

  1. configurable - 控制是不是能刪、能修改descriptor本身。
  2. writable - 控制是不是能修改值。
  3. enumerable - 控制是不是能枚舉出屬性。
  4. value - 控制對應的值,方法只是一個value是函式的屬性。
  5. get和set - 控制訪問的讀和寫邏輯。

實現機制

關於 ES7 的裝飾器一些強大的特性和用法可以參考 TC39 的提案,這裡就不累述。來看看我們 @Transaction 的實現:

// 封裝高階函式
function Transaction() {
  // 返回代理方法
  return (target, propetyKey, descriptor) => {
    // 獲取當前的代理方法
    let original = descriptor.value;
    // 攔截擴充套件方法
    descriptor.value = async function (...args) {
     try {
       // 獲取底層mysql的事務作用域
        await this.ctx.app.mysql.beginTransactionScope(async (conn) => {
          // 繫結資料庫連線
          this.ctx.conn = conn
          // 執行方法
          await original.apply(this, [...args]);
      }, this.ctx);
     } catch (error) {
       // 錯誤處理...
      this.ctx.body = {error}
     }
    };
  };
}

在 Transaction 的裝飾器中,我們使用底層 egg-mysql 物件擴充套件的 beginTransactionScope 自動控制,帶作用域的事務 。

API:beginTransactionScope(scope, ctx)

  • scope:  一個 generatorFunction,它將執行此事務的所有SQL。
  • ctx:  當前請求的上下文物件,它將確保即使在巢狀的情況下事務,一個請求中同時只有一個活動事務。 
const result = yield app.mysql.beginTransactionScope(function* (conn) {
  // 不需要手動處理事務開啟和回滾
  yield conn.insert(table, row1);
  yield conn.update(table, row2);
  return { success: true };
}, ctx); // ctx 執行上下文

結合 Midway 的使用

import { Context, controller, get, inject, provide } from "midway";

@provide()
@controller("/api/user")
export class UserController {

  @get("/destroy")
  @Transaction()
  async destroy(): Promise<void> {
    const { id } = this.ctx.query;
    const user = await this.ctx.service.user.deleteUserById({ id });
    // 如果發生失敗,上述的資料庫操作自動回滾
    const user2 = await this.ctx.service.user.deleteUserById2({ id });
    this.ctx.body = { success: true, data: [user,user2] };
  }
}

(4)更多特性迭代中

資料快取,為了提升資料的查詢的效率,我們正在迭代開發 Node-MyBatis 的快取機制,減少對資料庫資料查詢的壓力,提升整體資料查詢的效率。

MyBatis 提供了一級快取,和二級快取,我們在架構上也進行了參考,架構圖如下圖-4。

一級快取是 SqlSession 級別的快取,在同一個 SqlSession 中兩次執行相同的 SQL 語句,第一次執行完畢會將資料庫中查詢的資料寫到快取(記憶體),第二次會從快取中獲取資料將不再從資料庫查詢,從而提高查詢效率。當一個 SqlSession 結束後該 SqlSession 中的一級快取也就不存在了。不同的 SqlSession 之間的快取資料區域是互相不影響。

二級快取是 Mapper 級別的快取,多個 SqlSession 去操作同一個 Mapper 的 SQL 語句得到資料會存在二級快取區域,多個 SqlSession 可以共用二級快取,二級快取是跨SqlSession的。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-4 快取架構圖

自定義方法和標籤,在 SQL 模版中,我們通過 #、$、<%%> 來實現 SQL 的動態構建,不過在專案實戰中我們發現很多重複的一些SQL 拼接場景,針對這些場景我們正在開發在 SQL 模板中支援自定義的方法和標籤,直接內建在模板中使用提升開發效率,並且提供外掛機制,方便開發者開發自己的自定義方法和標籤。下面我們看看通過自定義方法和標籤對 SQL 構建的一些小例子。

資料插入

目前的資料插入方式,保持了 native SQL 的方式,但是,當資料庫的欄位特別多的時候,一個個去列出插入的欄位是比較累的一件事情。特別是當我們規範強調 SQL 插入時必須指定插入的列名,避免資料插入不一致。

INSERT INTO 
  test_user 
(name, job, email, age, edu)
 VALUES 
  (#data.name, #data.job, #data.email, #data.age, #data.edu)

Node-MyBatis 內建方法 - quickInsert()

-- user = {
--  name: 'test', 
--  job: 'Programmer', 
--  email: '[email protected]', 
--  age: 25, 
--  edu: 'Undergraduate'
-- }

-- sql builder
INSERT INTO test_user <% quickInsert(data.user) %>

-- 通過 SQL compiler 自動輸出

INSERT INTO 
  test_user (name, job, email, age, edu) 
  VALUES('test', 'Programmer', '[email protected]', 25, 'Undergraduate')

-- userList =   [
--  {name: 'test', job: 'Programmer', email: '[email protected]',  age: 25, edu: 'Undergraduate'}, 
--  {name: 'test2', job: 'Programmer', email: '[email protected]',  age: 30, edu: 'Undergraduate'}
-- ]

-- 批量插入
INSERT INTO test_user <% quickInsert(data.userList)%>

-- 通過 SQL compiler 自動輸出

INSERT INTO 
  test_user (name, job, email, age, edu) 
  VALUES 
    ('test', 'Programmer', '[email protected]', 25, 'Undergraduate'),
    ('test2', 'Programmer', '[email protected]', 30, 'Undergraduate')

Node-MyBatis 內建標籤 - <Insert />

-- user = {
--  name: 'test', 
--  job: 'Programmer', 
--  email: '[email protected]', 
--  age: 25, 
--  edu: 'Undergraduate'
-- }

-- sql builder
<Insert table="test_user" values={data.user}></Insert>

-- 通過 SQL compiler 自動輸出
INSERT INTO 
  test_user (name, job, email, age, edu) 
  VALUES('test', 'Programmer', '[email protected]', 25, 'Undergraduate')

-- userList =   [
--  {name: 'test', job: 'Programmer', email: '[email protected]',  age: 25, edu: 'Undergraduate'}, 
--  {name: 'test2', job: 'Programmer', email: '[email protected]',  age: 30, edu: 'Undergraduate'}
--]

-- sql builder
<Insert table="test_user" values={data.userList}></Insert>

--通過 SQL compiler 自動輸出
INSERT INTO 
  test_users (name, job, email, age, edu) 
  VALUES 
    ('test', 'Programmer', '[email protected]', 25, 'Undergraduate'),
    ('test2', 'Programmer', '[email protected]', 30, 'Undergraduate')

目前 Node-MyBatis 基於 Midway 的外掛規範內建在專案中,目前正在抽離獨立模組形成獨立的解決方案,然後針對 Midway 進行適配對接,提升方案的獨立性。

3、Node-MyBatis 實戰

(1)API

/**
 * 查詢符合所有的條件的資料庫記錄
 * @param sql: string sql字串
 * @param params 傳遞給sql字串動態變數的物件
 */
query(sql, params = {})

/**
 * 查詢符合條件的資料庫一條記錄
 * @param sql: string sql字串
 * @param params 傳遞給sql字串動態變數的物件
 */
queryOne(sql, params = {})

/**
 * 插入或更新資料庫記錄
 * @param sql: string sql字串
 * @param params 傳遞給sql字串動態變數的物件
 */
exec(sql, params = {})

(2)專案結構

因為我們選擇使用 Midway 作為我們的 BFF 的 Node 框架, 所以我們的目錄結構遵循標準的 Midway 的結構。

.
├── controller                # 入口 controller 層
│   ├── base.ts               # controller 公共基類
│   ├── table.ts
│   └── user.ts
├── extend                    # 對 midway 的擴充套件
│   ├── codes
│   │   └── index.ts
│   ├── context.ts
│   ├── enums                 # 列舉值
│   │   ├── index.ts
│   │   └── user.ts
│   ├── env                   # 擴充套件環境
│   │   ├── index.ts
│   │   ├── local.ts
│   │   ├── prev.ts
│   │   ├── prod.ts
│   │   └── test.ts
│   ├── helper.ts             # 工具方法
│   └── nodebatis             # nodebatis 核心程式碼
│       ├── decorator         # 宣告式事務封裝
│       ├── plugin            # 自定義工具方法
│       ├── config.ts         # 核心配置項
│       └── index.ts
├── middleware                # 中介軟體層
│   └── error_handler.ts      # 擴充套件錯誤處理
├── public
└── service                  # 業務 service 層
    ├── Mapping              # node-mybatis的 mapping層
    │   ├── TableMapping.ts
    │   └── UserMapping.ts
    ├── table.ts             # table service 和 db相關呼叫 TableMapping
    └── user.ts              # user service 和 db相關呼叫 UserMapping

(3)業務場景

根據使用者 id 查詢使用者資訊,當 Node 服務收到使用者資訊的查詢請求,根據 URL 的規則路由將請求分派到 UserController 的 getUserById 方法進行處理,getUserById 方法通過對 UserService 進行呼叫完成相關資料的獲取,UserService 通過 Node-MyBatis 完成對資料庫使用者資訊的查詢。

Controller層

//controller/UserController.ts
import {  controller, get, provide } from 'midway';
import BaseController from './base'

@provide()
@controller('/api/user')
export class UserController extends BaseController {
  /**
   * 根據使用者id查詢所有使用者資訊
   */
  @get('/getUserById')
  async getUserById() {
    const { userId } = this.ctx.query;
    let userInfo:IUser = await this.ctx.service.user.getUserById({userId})
    this.success(userInfo)
  }
}

Service層

// service/UserService.ts
import { provide } from 'midway';
import { Service } from 'egg';
import UserMapping from './mapping/UserMapping';

@provide()
export default class UserService extends Service {
  getUserById(params: {userId: number}): Promise<{id: number, name: string, age: number}> {
     return this.ctx.helper.queryOne(UserMapping.findById, params);
  }
}

DAO層

// service/mapping/UserMapping.ts
export default {
  findById: `
    SELECT 
      id,
      name,
      age
    FROM users t1
    WHERE
      t1.id=#data.userId
  `
}

4、工程化體系

(1)型別體系

在 Node 服務的開發中,我們需要更多的工程化的能力,如程式碼的提示和自動補全、程式碼的檢查、重構等,所以我們選擇 TypeScript 作為我們的開發語言,同時 Midway 也提供了很多的對於 Typescript 的支撐。

我們希望我們的 Node-MyBatis 也能插上型別的翅膀,可以根據查詢的資料能夠自動檢查糾錯補齊。於是就產生了我們 tts (table to typescript system) 解決方案, 可以根據資料庫的元資料自動生成 TypeScript 的型別定義檔案。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-5 資料庫表結構

通過 tts -t test_user 自動生成表單的型別定義檔案,如下:

export interface ITestUser {
  /**
   * 使用者id
   */
  id: number

  /**
   * 使用者名稱
   */
  name: string

  /**
   * 使用者狀態
   */
  state: string

  /**
   * 使用者郵箱
   */
  email: string

  /**
   * 使用者年齡
   */
  age: string
}

這樣在開發中就可以使用到這個型別檔案,給我們的日常開發帶來一些便利。再結合上TypeScript的高階型別容器如Pick、Partial、Record、Omit等可以根據查詢的欄位進行復雜型別的適配。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-6 tts 型別檔案的使用

(2)LSP

VSCode 基本成為前端開發編輯器的第一選擇,另外通過 VSCode 架構中的 LSP(語言服務協議)可以完成很多 IDE 的功能,為我們的開發提供更智慧的幫助。

比如我們在使用 Node-MyBatis 中需要編寫大量的 SQL 字串,對於 VSCode 來說,這就是一個普通的 JavaScript 的字串,沒有任何特殊之處。

但是我們期待能夠走得更遠,比如能自動識別 SQL 的關鍵字,語法高亮,並且實現 SQL 的自動美化。通過開發 VSCode 外掛,對 SQL 的語法特徵智慧分析,可以做到如下效果,實現 SQL 程式碼高亮和格式化,後續還會支援 SQL 的自動補齊。

vivo悟空活動中臺-打造 Nodejs 版本的MyBatis

圖-7 SQL 字串模板的高亮和格式化

另外,因為支援了自定義模板和自定義方法之後,編寫 SQL 的效率得到了提升,對於 SQL 生成就變得不再直觀,需要在執行期才知道 SQL 的字串內容。怎麼解決這個痛點,其實也可以通過 LSP 解決這個問題,做到這效率和可維護性的平衡,通過開發  VSCode 的外掛, 智慧分析 SQL 模板結構實時懸浮提示生成的 SQL 。

四、總結

文章到了這裡,已經進入結尾,感謝您的關注和陪伴。本文我們一起回顧了悟空活動中臺 Node 服務資料層持久化解決方案設計上的一些思考和探索,我們希望保留 SQL 的簡單通用強大,又能保證極致的開發體驗,希望通過 Node-MyBatis 的設計兌現了我們的思考。

作者: vivo 悟空中臺研發團隊

往期閱讀: