如何從業務程式碼中提升技術:使用領域特定語言消除重複程式碼

Phodal發表於2018-06-26

最近的一些日子裡,又陷入了平凡、無聊、繁瑣的業務程式碼開發中,生活變得無比的枯燥。每天面對著大量重複、而又沒有辦法得勝的程式碼,總會陷入憂慮之中。

而在實現幾個重複的業務程式碼時,我發現了一個更好的方式,使用領域特定語言

最初,我是在設計一個工作流的時候,發現自己正在使用 DSL 來解決問題。因為這是一系列重複而又繁瑣的工作,所以便想著抽象出一個服務來專門做這樣的事情。

  • 第一個版本里,我使用了 -> 操作符來實現一個簡單的 DSL:operate -> approve -> done。在使用的時候,我只需要傳相應的資料即可。
  • 第二個版本里,我意識到並不需要這麼複雜,JavaScript object 擁有更強的語言表達能力。我只需要傳遞對應的物件過去即可,再通過 Object.keys 就可以獲取處理的順序。

於是,我就這麼將一個高大上的 DSL,變成了一個資料結構了。我一想好像不太對,JavaScript 的 object 不僅僅只是資料結構,它可以將方法作為物件中的值。隨後,我又找到了之前寫的一個表單驗證的類,也使用了類似的實現。這種動態語言特有的資料結構,也可以視之為一種特定的 DSL。

便想著寫一篇文章來介紹一下業務程式碼中的 DSL。

DSL 簡介

不過,在開始之前,相信有很多人都不知道 DSL 是什麼東西?

DSL,即領域特定語言,它是一種為解決特定領域問題,而對某個特定領域操作和概念進行抽象的語言。

在深入瞭解之前,先讓我們瞭解 DSL 的兩個大的分類:

  • 外部 DSL,即建立一個專用目的的程式語言。諸如用於 BDD 測試的 Cucumber 也是一種外部 DSL,從某種意義上來說,我用於寫作的 markdown 也算是一種 DSL。它們通常都需要語法解析器來進行語法分析,並且通常可以在不同的語言、平臺上實現。
  • 內部 DSL,即:指與專案中使用的通用目的程式語言(Java、C#或Ruby)緊密相關的一類 DSL。它是基於通用程式語言實現的,由它來處理複雜的基礎設施和操作。^DSL

依這種定義而言,使用 JavaScript object 來實現這一類的方式,應該歸類於內部 DSL。在我寫這篇文章的時候,我總算找到了一個相關 “資料結構 DSL” 相關的介紹:

資料結構 DSL 是一種使用程式語言的資料結構構建的 DSL。其核心思想是,使用可用的基本資料結構,例如字串、數字、陣列、物件和函式,並將它們結合起來以建立抽象來處理特定的領域。

而,就實現難度而言:

資料結構 DSL < 內部 DSL < 外部 DSL < 語言工作臺
複製程式碼

這裡的資料結構 DSL,更像是一種內建函式的配置檔案。程式碼,讀的時候遠多寫的時候多。一行配置與十行程式碼相比,自然是一行配置更容易閱讀。所以,使用 object 是一種更容易的選擇。

接著,讓我愉快地展開這些 DSL 的使用歷程吧。

難以構建的外部 DSL

某些外部 DSL,看上去已經可以說是一門程式語言了,它也可以編譯為可執行的程式,也可以是邊執行邊解釋,類似於解釋型語言。不過,它通常是作為程式的一部分存在的,如 Emacs Lisp,可以編譯為程式,但是多數時候是作為 Emacs 的一部分而存在。

這算得上是一種複雜的 DSL,而簡單的外部 DSL,而諸如我們平時開發用的前端模板:

<View style={{ flexDirection: 'row' }}>
  <Icon style={{ marginLeft: 5, marginRight: 5 }} name={'ios-chatboxes-outline'} type={'ionicon'} color={'#333'} />
  <Text>{topic.attributes.commentsCount}</Text>
</View>
複製程式碼

對於這樣一個模板來說,我們要做的就是使用 JavaScript 實現一個解析器。在構建的時候,將其編譯為 JavaScript 程式碼;在執行的時候,再將其轉換為 HTML。

以我幾次、有限的建立 DSL 的經歷來說,諸如:stepping,我覺得外部 DSL 並不容易實現——雖然已經有了 Flex 和 Bison(在 JavaScript 世界裡,有一個名為 Jison 的實現)這樣的工具。其相當於是自己寫一個程式語言,與此同時設計出一個容易使用的語法。

如我之前設計用於 DDD 的 stepping 看上去就像是一個配置檔案,而我是使用 Jison 寫了自己的語法分析:

domain: 庫存子域
  aggregate: 庫存
    event: 庫存已增加
    event: 庫存已恢復
    event: 庫存已扣減
    event: 庫存已鎖定
    command: 編輯庫存
複製程式碼

Whatever,要實現這樣一個 DSL 並不是一件容易的事。就目前而言,使用最廣泛的 DSL,恐怕要數 markdown 了?

當然了,對於大的專案,或者大的組織團隊來說,要實現這樣一個 DSL 並不是問題。它也有利於組織內部的溝通,DSL 在這裡就像是一個領域知識的存在。

而就使用習慣來說,更常見的是內部 DSL。

易於實現的內部 DSL

內部 DSL,通常由程式語言內部來實現,一種常見的實現方式就是:流暢(fluent)介面。如,jQuery 就是這種內部 DSL 的典型的例子。

$('.mydiv')
  .addClass('flash') 
  .draggable()
  .css('color', 'blue')
複製程式碼

內部 DSL 是在一門現成語言內,實現針對領域問題的描述。如上述程式碼中的 jQuery 語法就是專用於 DOM 處理的,它的 API 也就是其最出名的鏈式方法呼叫

如下,也是一種內部 DSL 的實現:

var query =
  SQL('select name, desc from widgets')
   .WHERE('price < ', $(params.max_price), AND,
          'clearance = ', $(params.clearance))
   .ORDERBY('name asc');
複製程式碼

而對於我們實現來說,則可能是:

function SQL (param) {
    this.WHERE = function(){
        return this;
    };
    this.ORDERBY = function(){
        return this;
    };
	return this;
}
複製程式碼

這種 DSL 專門針對的是開發人員的使用,對於複雜、重複應用來說,它特別有幫助。可以設計出專用於業務的 DSL。

可問題來了,在前端領域的業務程式碼裡,要實現這樣一個 DSL 的機會並不大——一個合理的專案來說,複雜的業務邏輯應該由 BFF 層實現,內部 DSL 更常見於框架的 API 設計上。除非,我們在設計一個框架,諸如 Jasmine,這樣的測試框架:

const simDescribe = (desc: any, fn: any) => {
  console.log(desc)
  fn()
}

const simIt = (msg: any, fn: any) => {
  simDescribe('  ' + msg, fn)
}

...

export const SimTest = {
  describe: simDescribe,
  expect: simExpect,
  ...
}
複製程式碼

PS:上述的簡化程式碼見:github.com/phodal/oads…

在業務複雜的情況下,則可以有針對性的設計出這樣的 API。

從外部 DSL 到內部 DSL 工作流

我喜歡 JavaScript、Python 這一類動態語言,是因為其擁有優秀的語言表達力。而 JavaScript 這門語言在一點上,那便更為突出。JSON 和 JavaScript Object 可以幫助我們快速地建立這樣的一個 DSL。

最初,我產生了一個 DSL 的想法是因為:Angular 框架的動畫形式的:void => inactive,或者是 inactive => active 的形式。這讓我聯想到了一個工作流可以這麼設計:

process = 'transact -> approve -> bank';
複製程式碼

對應的,我們只需要寫相應的資料即可:

[{
    name: 'transact',
    icon: 'success'
},{
    name: 'approve',
    icon: 'processing'
},{
    name: 'bank',
    icon: 'todo'
}]
複製程式碼

(PS:現在看來除了幫助我寫文章,它的意義並沒有那麼重要。)

但是這樣的 DSL,並不容易使用。為了使用它,我們需要一個資料,一個流程,兩個引數。而我們面向的是開發人員,越簡單地 API 也就越容易使用。而 JavaScript 裡的 object 正好可以起一個順序的作用,我們保需要使用 Object.keys 就可以獲取到對應的值。其對應的實現也比較簡單(簡化版本):

export function workflowParser(data: any) {
  const keys = Object.keys(data)
  const results = []
  for (let key of keys) {
    let process = data[key] as IWorkflow
    results.push({
      name: process.name,
      status: process.status,
      icon: `icon-${process.status}`
    })
  }

  return results
}
複製程式碼

對應的我們只需要一個引數:

transact: {...},
approve: {...},
bank: {...}
複製程式碼

於是,一個有點複雜的 DSL 就變成了一個 Object。而更像是一個 JSON,隨後我們只需要定義好一系列的流程,然後獲取即可:

<process data={{WorkflowMap.SUCCESS}}></process>
複製程式碼

這樣一來,我們就將複雜度轉移到了元件 process 內部了。

JSON 到資料結構 DSL

與 JSON 相比,JavaScript Object 有一點相當的迷人,即可以支援使用函式。

除了元件上的重用,還有一種常見的例子就是:表單驗證。表單驗證是一種相當繁瑣的工作,我們也可以看到一系列相應的 DSL 實現。如下是一個用於表單驗證的 DSL:

const LoginFormValidateMap = {
  phone: {
    require: true,
    regular: RegexMap.phone
  },
  country: {
    requireBy: 'phone'
  },
  email: {
    requireByNot: {
      country: 'CN'
    }
  }
}
複製程式碼

它與 JSON 形式不同的是,我們可以動態修改物件中的值,傳入函式。其實現與 JSON 的示例來說,也一樣的簡單。我們就只需要遍歷這些值即可:

export function FormValidator(validateMap: any, data: any) {
  let validateKeys = Object.keys(validateMap)
  for (const key of validateKeys) {
    const map = validateMap[key] as IValidate

    if (map.require) {
      if (!data[key]) {
        return {
          key: key,
          error: VALIDATE_ERROR.REQUIRE
        }
      }
    }
    ...
  }
}
複製程式碼

然後,就可以驗證欄位是否有錯:

const data = {
  phone: '1234567980',
  country: 'US',
  email: ''
}

let result = FormValidator(LoginFormValidateMap, data)
複製程式碼

上述的實現是為了解析方便。一個更加 DSL 的實現,應該是:

const methods = [
  ['不能為空', isNotEmpty],
  ['不得長於', isNotLongerThan]
]
複製程式碼

然後,我們只需要對應於我們的錯誤資訊,寫一個 ${key} 不能為空 即可。

結論

如我們所看到的,要實現這樣一個 DSL 並不困難。因為難的並不是去做這樣的設計,而是這種保持設計的思維。隨後,不斷的練習掌握好如何去設計一個 DSL。

當下次我們遇到這樣的場景時,是否會想:有沒有更好的實現方法?

如果有更充裕的時間,我想設計一些更優雅、容易使用的 DSL:github.com/phodal/oads…

相關文章