Categories
程式開發

有贊零售跨平台打印庫方案


一、背景

打印是商家在日常經營中不可缺失的行為。打印從實際業務中劃分可以分為:小票打印、標籤打印、電子麵單打印等細分業務。小票打印在實際場景中又可以擴展出:購物小票、退貨小票、換貨小票、揀貨小票、發貨小票、交班小票、核銷小票、取件小票、存件小票等等;這些小票對應著商家交易履約中的各個環節。在 JS 打印庫出來之前,有贊零售已經實現了小票的原生打印庫,但在實踐遇到了不少痛點。引用之前說的三大痛點:

  1. 每個端各自實現一套打印流程,方案不統一。導致每次修改都會三端修改,而且 iOS 和 Android 必須依賴發版才可上線,不具有動態性,而且研發效率比較低。
  2. 打印小票的業務場景比較多,每個業務都自己實現模板封裝及打印邏輯,模板及邏輯不統一,維護成本大。
  3. 多種小票設備的適配,對於每個端來說都要適配一遍。

因此原生的打印庫不能滿足快速發展的打印需求,急需一套能跨平台通用的打印庫。

二、挑戰

  • 打印庫能夠跨端運行
  • 一套能夠描繪小票的模板
  • 不同小票打印機的指令解析

三、跨端語言選擇

經過調研,iOS、Android、Java 都有 JavaScript 運行環境庫。 iOS 使用 JavaScriptCore 框架,Android 使用 J2V8 框架,Java 中 JDK8 自帶 Nashorn 引擎。後續有贊零售 PC 收銀採用的是 Electron 框架,自帶 V8 執行環境。綜上所述,JavaScript 這門語言成了跨平台的首選項。

四、打印庫的業務邊界

正常的打印流程如下:

  1. 業務觸發打印需求
  2. SDK 容器接收訂單數據與模板數據
  3. 將訂單數據與模板數據融合得到融合數據
  4. 融合數據翻譯成對應打印機指令
  5. 客戶端傳送打印機指令給打印機
  6. 打印機接收指令完成打印操作

其中步驟 3 與步驟 4 的功能就是打印庫所負責的功能。訂單數據再抽象就是業務數據,從而可以得到以下公式:

  • 模板數據 + 業務數據 = 融合數據
  • 融合數據 + 打印機信息 = 指令數據

模板數據 = 包含佔位符的模板
業務數據 = 需要填入模板裡的數據
融合數據 = 佔位符已被填充的模板

五、打印庫的設計

根據業務邊界,我們可以將打印庫進行分層:

  • 模板渲染層:業務數據與模板的拼接融合
  • 翻譯層:將融合數據解析為打印指令

六、模板設計

6.1 模板元素

要設計一套模板語言,首先要確認模板元素有幾種。我們從實際的一張全功能的小票入手進行拆解。以下為常見的一張小票示例:

有贊零售跨平台打印庫方案 1

分析以上小票我們可以整理出一張完整小票包含以下內容:

  • 元素
    1. 文本
    2. 圖片
    3. 二維碼
    4. 條形碼
    5. 換行
  • 佈局
    1. 單行單列
    2. 一行多列
  • 排版
    1. 居左
    2. 局中
    3. 居右

6.2 模板語言的設計

打印庫的模板語言在 V1 版本的是 JSON ,而在 V2 版本的里替換成了 HTML 。以下是 V1 模板語言與 V2 模板語言的對比:

一行右对齐的中等字号的有赞

V1 模板:

[
  {
    "content": "有赞",
    "contentType": "text",
    "textAlign": "right",
    "fontSize": "middle",
    "pagerWeight": 1
  }
]

V2 模板:

有赞

V1 模板採取 JSON 的背景考慮在於模板直接寫成 JSON ,對後續的翻譯層的代碼邏輯友好,能夠直接一對一的進行翻譯。在初期小票業務不復雜的情況下,JSON 能夠較好地承載這塊業務。後續隨著小票業務的發展,小票內容複雜度提高,JSON 作為模板語言的缺陷也暴露了出來,舉個例子:以下是發貨小票商品詳情的效果圖:

有贊零售跨平台打印庫方案 2

V1 模板(JSON)的寫法是這樣的:

[
{{#each itemList}}
[
    {
        "content": "{{titleSkuDesc}}",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 4
    },
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    }
],[
    {
        "content": "{{toFixed (divide unitPrice 100) 2}}",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{quantityDesc}}",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{toFixed (divide itemAmount 100) 2}}",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }
]
{{#each priceDiffInfoList}}{{#if @first}},[
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "实发重量",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "应退差价",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }]
[
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "实发重量",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "应退差价",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }
]
{{/if}}
,[
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{divide realWeight 1000}}",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "-{{toFixed (divide diffAmount 100) 2}}",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }
]
{{/each}}{{#if @last}}{{else}}
,{
    "content": "",
    "contentType": "newline"
},
{{/if}}{{/each}}
]

從上面的例子可以看出 JSON + Handlebars语法 作為模板語言:

  1. 可讀性低
  2. 維護成本高

所以在打印庫 V2 版本設計的時候決定將模板語言進行替換,經過多方面調研,最終選定了 HTML 作為 V2 版本的模板語言。HTML 作為模板語言不僅解決了 JSON 模板語言兩個缺陷,同時提供了後續的可擴展性。還是以上面發貨小票的為例:

V2 模板(HTML)寫法:

{{each itemInfoList}}

{{ $value.titleSkuDesc }}

{{$value.unitPrice}} {{$value.quantityDesc}} {{$value.itemAmount}}

实发重量 应退差价

{{each $value.priceDiffInfoList}}

{{$value.realWeight}} {{$value.diffAmount}}

{{/each}} {{if $index!=itemInfoList.length-1}}
{{/if}} {{/each}

另一大原因是原有的JSON 模板只能描繪小票這種自上而下的一維信息,而標籤打印,杯貼打印等其它打印都是基於坐標的二維打印,原有的模板無法支撐相關的業務,而採用HTML 之後,借助CSS 的能力,我們能夠輕鬆地描繪小票、杯貼、價簽、條碼的打印需求。

6.3 模板引擎

在實際小票打印中,一套小票模板樣式是固定的,但是裡面的實際內容是可變的,所以我們需要使用模板引擎來實現相關的替換工作。打印庫 V1 版本中模板引擎為 Handlebars,而在打印庫 V2 版本里我們替換成 art-template 這款模板引擎。對比 V1 的模板引擎,它擁有以下特性:

  • 擁有接近 JavaScript 渲染極限的的性能 在線渲染速度測試

    有贊零售跨平台打印庫方案 3

  • 調試友好:語法、運行時錯誤日誌精確到模板所在行

  • 支持原生語法和標準語法,更強的表達能力。

在 V1 的模板引擎中,要實現判斷值是否存在,需要註冊一個 Helper 方法,才能使用相關能力,而在 V2 的模板引擎中天然支持。

{{if user}}
  

{{user.name}}

{{/if}}

配合模板引擎,我們可以得到第一個公式:

模板數據 + 業務數據 = 融合數據

模板數據

储值编号:{{orderNo}}

業務數據

{
  orderNo: "E1278909900990"
}

融合數據

储值编号:E1278909900990

完整的 HTML 模板

储值编号:{{orderNo}}

储值时间:{{createTime | formatDate}}

操作人员:{{operator}}


活动名称 {{ruleName}}

支付方式: {{payDesc}}

储值金额: {{rechargeAmount}}

账户余额: {{balance}}


会员:{{buyerName}}

电话:{{mobile}}

www.youzan.com

扫码关注店铺公众号


本次储值赠送白金卡,5张10元优惠券

至此一套描繪打印模板的模板語言已經設計完成。

七、翻譯層

模板渲染層幫助我們實現了對打印業務的描繪,打印模板語言與打印機型號協議無關,只與打印業務的類型(小票、標籤)有關。而到了翻譯層,這一層負責將模板翻譯成打印機指令。而要實現相關能力,我們需要對打印機協議有進一步了解。打印機協議從業務形態上分可以分為兩大類:票據(小票)打印機與標籤打印機。

7.1 票據打印機協議

目前市面上票據(小票)打印機協議可以分為以下二種。

  1. ESC/POS 協議
  2. 基於 ESC/POS 封裝的上層協議

目前市面上的 99% 的票據打印機都支持 ESC/POS 協議,是票據打印機的事實標準。而第二種基本都是為了方便開發者使用的二次包裝,多存在於雲打印機廠商。故我們如果能夠實現 模板ESC/POS 指令的功能,我們可以做到快速對接大部分票據打印機。而針對第二種情況,打印庫提供單獨的適配,

ESC/POS 協議

該打印控制命令(WPSON StandardCode for Printer)是 EPSON 公司自己制定的針式打印機的標準化指令集,現在已成為針式打印機控制語言事實上的工業標準。 ESC/POS 打印命令集是 ESC 打印控制命令的簡化版本,現在大多數票據打印都採用 ESC/POS 指令集。

7.2 標籤打印機協議

目前市面上標籤打印機協議沒有類似 ESC/POS 的通用協議,根據打印庫對接的幾款標籤打印機來看,打印機廠商的提供的協議文檔都是對底層協議進行了封裝。該協議的特點在於,每一個元素都需要提供 x, y 的坐標以進行定位。這邊打印庫則提供了 Point 坐標打印協議進行映射標籤打印機協議。

7.3 HTML 到 ESC/POS 協議指令示範

HTML:

有赞

等於

一行右对齐的中等字号的有赞

等於

右对齐指令 + 中等字号指令 + 文本16进制编码 + 打印指令

打印機指令:

1B6102 + 1D2111     + D3D0 + D4DE + 0A
右对齐  + 加宽加粗两倍 + 有   +  赞   + 打印并换行

以上為 HTML 到 ESC/POS 指令的解析過程。不同於 v1 的 JSON 模板能夠方便實現1對1的映射。 v2 的 HTML 模板轉化到指令中間,需要解析成 AST 以方便我們進行翻譯,因為我們需要一個解析 HTML 的庫。

7.4 HTML 解析庫

要完成 HTML 模板到打印機指令的過程,我們需要類似於 Babylon 的處理工具。經過調研與比對,這裡選擇了 unified 這個庫。unified 是一個用於處理帶有語法樹的文本並在它們之間進行轉換。選擇這個庫的原因在於它的生態比較豐富,提供的插件也能較好的滿足我們打印庫的需求。最終我們的處理流程圖如下:

有贊零售跨平台打印庫方案 4

rehype.js 是針對 HTML 語言的處理庫,通過它我們能夠實現對模板的壓縮,格式化處理。我們利用它的 Parser 進行 AST 的構建,而 Compiler 則需要我們自己去編寫。

7.5 編譯器

Compiler 編譯器中,我們實現 抽象语法树打印机指令。大體上流程如下:

有贊零售跨平台打印庫方案 5

編寫一個 Compiler ,首先需要對語法樹進行解析,語法樹的數據結構標準可以從 HTML 語法樹格式這裡查詢。通過解析語法樹,我們解析出模板裡對應的文本、圖片、條形碼、二維碼等元素。然後我們在代碼中實現對應元素到打印機指令的翻譯,最終生成完整的打印指令輸出。

在打印庫中,針對不同打印機協議編寫對應的 Compiler 實現 AST 到不同打印指令的輸出。這樣完成了輸入同一份模板與打印機信息,輸出相對應的打印機指令。

7.6 實例

模板:

有你有赞

輸出 ESC/POS 協議

1C43001B61001B21001D2100D2BBB6FEC8FDCBC4CEE5C1F9C6DFB0CBBEC5CAAE2020202020202020202020200A1D564200

某 A 雲打印機協議

有你有赞

某 B 雲打印機協議


  
    
  

八、典型難點

在開發打印庫過程中,實際會遇上不少難題。接下來我會介紹兩個典型難題:圖片與小票排版問題

8.1 圖片問題

圖片是小票中的重要元素,在之前文章中介紹過打印庫本身不處理圖片,交於外部處理。原因是打印庫運行在模擬的 JS 運行環境庫中,沒有能力處理圖片。

下面是一張圖片模板示例:

有贊零售跨平台打印庫方案 6

我們要翻譯一張圖片要經過以下步驟:

  1. 下載圖片
  2. 圖片灰度二值化處理
  3. 翻譯打印機指令

步驟一,依賴網絡連接進行下載圖片。步驟二,JavaScript 需要依賴 Canvas 這個對象進行處理。而在 iOS、 Android、Java 的 JavaScript 運行環境庫中沒有提供這兩個能力,這也必然導致了打印庫在處理圖片中需要交與外部調用者完成步驟一和步驟二。

部分自定義協議的打印機自身會處理步驟一與步驟二,打印庫就可以直接翻譯到對應協議。

為什麼圖片需要進行灰度二值化處理?

因為對於票據打印機來說,圖片像素點只有打與不打,所以不支持灰度與彩色圖片。而我們的圖片大多數都是灰度或者彩色圖片,因此我們需要進行二值化處理。在 ESC/POS 協議中,打印圖片的指令如下:

有贊零售跨平台打印庫方案 7

其中 d1~dk就是圖片的數據塊,並且值只有 01,1表示打印該點,0為不打印該點。

圖片二值化方案:這部分內容可以參考我們另一篇文章 有贊零售小票打印圖片二值化方案

8.2 一行多列排版問題

票據打印機原生不支持一行多列的排版,我們需要自己處理一行多列的排版問題。舉個例子。如下圖:

有贊零售跨平台打印庫方案 8

對於打印機來說,這裡只有兩行數據。如果我們這邊不對小票排版進行優化的話,輸出實際結果大概如下:

品名单价数量金额
商品名称(规格)¥5.002份¥10.00

所以一行多列的排版需要打印庫實現。這裡可以通過塞入空格進行排版填充。那麼理論上應該塞入多少空格呢,不同紙張類型(58/80mm)大小也是不一樣的?這裡有一份數據:

58mm能夠打印32個英文字符,16個中文字符
80mm能夠打印48個英文字符,24個中文字符

根據以上數據,我們可以正確的插入空格保證排版。

品名            单价    数量     金额
商品名称(规格)¥5.00    2份  ¥10.00

還有一種情況,單列塞不下對應內容,比如 80mm 紙張能正常排滿的小票,在 58mm 的紙則顯示不正常。如下:

品名   单价    数量     金额
商品名称(规格)¥5.00    2
份  ¥10.00

分析原因本質在於,品名這一列只佔據了 25% 的空間,在商品名稱過長的時候,擠壓了後續的空間。所以針對這種情況,我們需要進行內容切割。最終排版調整為:

品名   单价    数量     金额
商品名 ¥5.00  2份  ¥10.00
称(规
格)

九、總結與展望

目前在有贊零售中,PC 客戶端、Java 端、iOS 端、Android 端都已經完成該打印庫的接入,100% 的小票都經過 JS 打印庫輸出到打印機,已經穩定運行2年有餘。價籤條碼、杯貼打印也統一接入了 JS 打印庫,同時支撐了有贊零售自定義價簽、自定義小票等一系列複雜的商家需求。在未來的規劃裡,有贊零售打印庫將會對目前業務實踐中的痛點進行解決。

  1. 搭建 Node 打印服務,對外提供相關打印接口,降低業務方的接入成本。
  2. 統一有贊打印標準,方便 ISV 進行接入有贊打印,利用生態的能力支持更多品牌的打印機。

本文轉載自公眾號有贊coder(ID:youzan_coder)。

原文鏈接

https://mp.weixin.qq.com/s?__biz=MzAxOTY5MDMxNA==&mid=2455760720&idx=1&sn=2fa8788325eea78a38e368e967bc10f7&chksm=8c686975bb1fe0633a53c8081f17ac28d9f92fac7f8fdc2b7e8ff1e271a7383963a4eb6fb854&scene=27#wechat_redirect