國際化翻譯平臺文檔解析 2-0 技術原理解析
字節跳動國際化翻譯平臺爲業務提供高效專業的【平臺 + 服務】本地化一站式解決方案,簡化本地化管理流程,提高多語言內容管理效率,助力產品出海。除文案管理外,國際化翻譯平臺 也提供文案、文檔、視頻等多模態翻譯服務。目前不僅爲公司內部大部分業務線提供服務,還通過火山引擎爲外部客戶提供服務,詳見 https://www.volcengine.com/product/i18ntranslate。
一、背景
隨着業務快速發展,國際化翻譯平臺文檔翻譯從最初僅支持 word 文檔,到目前支持 8 種文檔類型,其中核心 Node.js SDK(以下稱爲 “文檔解析 1.0”)變得越來越難以維護,究其根本,文檔解析 1.0 對於每種文檔類型的解析、還原、機器翻譯、字數計算等能力,都單獨維護一套邏輯,其優勢是在文檔解析開發的 0-1 階段可以快速完成每種格式的能力建設,但隨着後期優化需求以及新文檔格式的增加,這種獨立式架構的弊端會愈發凸顯——改動某個邏輯需要同時修改 N 套代碼、新接入一種文檔格式需要重新編寫幾乎完全重複的業務邏輯。爲了更好的支持文檔翻譯業務的高效維護與未來更多文檔格式擴展的需求,有必要對現有架構進行一次徹底的重構升級。
二、重構收益
-
SDK 核心邏輯代碼量減少 70%+(6000+ 行 -> 2000 行)
-
受益於新架構的高可維護性與可擴展性:
a. lark 文檔 2.0 還原效率提升 10 倍以上
b. 使用 SDK 的 Node BFF 項目文檔解析相關邏輯代碼量降低 80%+
c. 實現了基於 TS Decorator 與 FaaS 的 SDK 數據可視化
我們可以看到重構收益還是比較明顯的,那麼文檔解析 2.0 的新架構具體是怎麼樣,以及是如何實現的呢?接下來的技術原理部分會進行詳細解析。
三、技術原理
3.1 架構設計
讓我們回到文檔解析與還原的本質,解析的本質是將文檔對應格式的文件轉化爲一套目標 DSL,而還原的本質則是將目標 DSL 轉化爲文件 DSL 然後生成文件。也就是說,所有類型文檔的解析與還原流程都可以抽象爲如下過程:
因此,可以將解析與還原抽象爲文檔的底層能力。在國際化翻譯平臺的業務場景下,文檔經過解析之後需要轉化爲句段(Segments,如果有樣式信息,則存儲在每個 segment 的標籤中),對於 segments 化之後的數據,有分句(根據標點符號進行句段劃分)、機器翻譯、統計字數、合併句段等需求。對於這些功能層面的需求,參考 TCP/IP 四層模型的話,可以類比爲最上層的應用層。
這樣我們便有了底層解析與還原能力層,以及上層的應用層。但光有這兩層架構還無法達到我們的最終目標,爲什麼呢?因爲對於每種不同的文檔類型,其解析後的數據結構都是各不相同的,而不同的數據結構就需要應用層的每個功能針對不同的數據結構做適配,隨着不同文檔數據結構的增多,在應用層做適配的複雜度也會呈指數級上升。如何解決這個問題呢?一個經典設計模式——Adapter(適配器)模式可以給我們答案。我們可以在底層與應用層中間,加入一個 Adapter 層,將每種文檔的解析結果統一爲一致的 DSL。
如此一來,國際化翻譯平臺文檔解析 2.0 的三層架構便呼之欲出:
其中底層 parser 層負責所有文檔的解析與還原,最上層 feature 應用層負責統一的能力暴露,中間層 adapter 則負責將 parser 層的解析與還原結果適配爲統一 DSL。
3.2 分層實現
解析完總體架構之後,我們可以繼續往下深入,看看各層的具體實現。
其中 Parser 層設計如下:
class SomeTypeParser {
constructor(config) {}
@type2bridge
parse() {}
@bridge2type
restore() {}
}
細心的讀者可能發現了 parser 的實現藉助了 ts decorator,這部分會在 3.3 SDK 數據可視化部分詳解。這裏可以先略過這部分。
Feature
這裏需要前置說明一下 CAT 的含義,CAT 的意思是計算機輔助翻譯(Computer aided translation,CAT),也是國際化翻譯平臺文檔解析的目標,國際化翻譯平臺經過文檔解析生成 segments,翻譯人員在由 segments 所構成的 CAT 編輯器中進行翻譯操作。
國際化翻譯平臺 CAT 編輯器目前如下圖所示:
Feature 層設計如下:
class CAT {
// 文檔解析,返回結果就是segments
adaptCAT(type, config) {}
// 機器翻譯
adaptCATWithMT() {}
// 字數統計
countWords() {}
// 生成文檔
genDoc() {}
// 自定義解析器
apply(type, parser) {}
}
Adapter
Adapter 層主要是以下兩個方法,分別是文檔數據轉中間層數據,與中間層數據轉文檔數據。
export const type2bridge = () => {
}
export const bridge2type = () => {
}
統一 DSL
{
blockId: string
elements: {
type: string // 文本:text, 其他:other,對應着單、雙標籤
textRun?: {
style: Object // 樣式
content: string // 文本
}
location: {
start: number
end: number
}
}[]
}
以上便是國際化翻譯平臺文檔解析 2.0 架構的設計過程與分層實現。接下來對 SDK 中 decorator 的使用做更詳細的解析,有了它的幫助,SDK 底層的解析、還原與數據可視化的具體實現變得更加簡潔。
3.3 Decorator 的使用與 SDK 數據可視化
Decorator 可以用來增強類中的方法,使其具備額外的功能,提供增強後的接口。
以國際化翻譯平臺 TXT 文檔 parser 層的具體實現爲例,
export default class TxtParser {
@txt2bridge
async parse(token: string | Buffer) {
const buffer = path2buffer(token)
return [buffer.toString()]
}
@bridge2txt
async restore(blocks: string[], _raw: string) {
return { buffer: Buffer.from(blocks.join()) }
}
}
我們可以看到兩個裝飾器方法txt2bridge
與bridge2txt
,實現如下:
export const txt2bridge = proxyForReturnValue<string[], TxtParser>(function (blocks) {
const bridgeBlocks = blocks.map((block, index) => {
const bridgeBlock: Bridge.Block = {
style: {},
blockId: index + '',
elements: [
{
type: 'text',
textRun: {
content: block,
style: {}
},
location: {
start: 0,
end: block.length
}
}
]
}
return bridgeBlock
})
return { raw: JSON.stringify(blocks), blocks: bridgeBlocks }
})
export const bridge2txt = proxyForParam<string[], TxtParser>(function (blocks, _raw) {
return blocks.map(block => {
return block.elements.map(ele => ele.textRun.content).join()
})
})
我們可以觀察到這兩個裝飾器並不是直接實現的,是經過proxyForParam
與proxyForReturnValue
而間接實現,這裏又涉及到了 proxy 設計模式的使用,用來對返回值與函數參數做一次轉化。這兩個代理方法的具體實現如下:
Proxy 被用於做訪問控制(access control),對本身想要訪問的對象做轉化,並且對外提供相同的接口。
export function proxyForReturnValue<T, THIS, C = { [key: string]: any }>(
proxy: (this: THIS, data: T, config?: C) => Bridge.Data | Promise<Bridge.Data>
) {
return function (
_target: any,
_propertyName: string,
descriptor: TypedPropertyDescriptor<Function>
) {
const method = descriptor.value
descriptor.value = async function (token: string, config?: C) {
const data = await method.call(this, token, config)
return await proxy.call(this, data, config)
}
}
}
export function proxyForParam<T, THIS, C = { [key: string]: any }>(
proxy: (this: THIS, blocks: Bridge.Block[], raw: string | Buffer, config?: C) => T | Promise<T>
) {
return function (
_target: any,
_propertyName: string,
descriptor: TypedPropertyDescriptor<Function>
) {
const method = descriptor.value
descriptor.value = async function (blocks: Bridge.Block[], raw: string | Buffer, config?: C) {
return await method.call(this, await proxy.call(this, blocks, raw, config), raw, config)
}
}
}
通過上述實現,我們可以看到 Decorator 真正起作用的語句(descriptor.value = () => {}
)在這兩個 proxy 方法內實現。通過 proxy 設計模式的使用,我們可以將 Decorator 的實現與業務邏輯進行解耦,更加便於後續維護。
通過 Decorator,我們可以對方法做增強處理,使之具備更多的能力,因此,解析與還原過程中的數據收集也可以通過 Decorator 來實現。在 TxtParser 中,我們只需要增加一個trace
裝飾器即可:
export default class TxtParser {
@trace('txt', 'parse')
@txt2bridge
async parse(token: string | Buffer) {
const buffer = path2buffer(token)
return [buffer.toString()]
}
@trace('txt', 'restore')
@bridge2txt
async restore(blocks: string[], _raw: string) {
return { buffer: Buffer.from(blocks.join()) }
}
}
trace
裝飾器實現如下:
export function trace ( docType: DocType, operateType: 'parse' | 'restore') {
return function (
_target: any,
_propertyName: string,
descriptor: TypedPropertyDescriptor<Function>
) {
// prepare
const method = descriptor.value
descriptor.value = async function (...args) {
try {
// ...
const res = await method.apply ( this, args)
// 信息收集,例如操作時間、CPU、內存等消耗情況
// 通過FaaS上報文檔操作過程中的統計信息
return res
} catch (error) {
// 錯誤處理,通過FaaS上報錯誤信息
}
}
}
}
我們在裝飾器中進行信息收集,並把收集到的數據上報到 FaaS 平臺之後,我們就可以開發一個運營後臺來將這些數據進行可視化展現,這也是國際化翻譯平臺的文檔分析後臺的由來。
3.4 Larkdocx 批量更新關鍵實現
最後可以再分享一下國際化翻譯平臺文檔解析 2.0 中 Larkdocx 批量更新實現的一個關鍵。起初,國際化翻譯平臺對飛書文檔 2.0 的還原速度非常慢,對於大型文檔來說,平臺幾乎無法正常導出,且飛書開放平臺有同篇文檔 QPS <= 3 的限制,如何提升還原速度,並滿足 QPS 限制成爲了又一個核心問題。
之前飛書文檔 2.0 還原慢的主要原因在於每個 Block 的還原都需要調用一次 updateBlock[1],對於大文檔來說,block 總數有上千個,更新請求可達上千次,自然很慢。經過調研,飛書開放平臺在不久之前開放了批量更新塊接口 [2],正好可以解決請求過多的問題。另外,由於飛書開放平臺有同篇文檔同一接口 QPS <= 3 的限制,爲了同時實現批量更新分塊與限流,需要開發一個專用的限流調度器,在傳統限流調度器的基礎上需要增加錯誤重試的功能,具體實現如下:
async function concurrentFetch(poolLimit, iterable, iteratorFn) {
const result = [];
const retry = [];
const executing = new Set();
for (const item of iterable) {
const p = Promise.resolve().then(() => iteratorFn(item));
result.push(p);
executing.add(p);
const clean = () => executing.delete(p);
p.then((r) =>
r ? retry.push(Promise.resolve().then(() => iteratorFn(r))) : clean())
.catch(clean);
if (executing.size >= poolLimit) {
await Promise.race(executing);
}
}
return Promise.all(result).then(() => Promise.all(retry));
}
// Usage
async batchUpdateBlock(blocks: Lark.Block[], documentId: string) {
const blockUpdates = blocks.map(block => this.getUpdateForBlock(block)).filter(Boolean)
const chunkedBlockUpdates = chunk(blockUpdates, MAX_BATCH_SIZE)
const batchUpdateWithErrorHandle = async chunk => {
return await this.lark
.batchUpdateBlockForDocx(documentId, {
requests : chunk
})
.then(resp => {
// 如果遇到無權限報錯,需要進行降級處理
if (resp?.data.code === LARK_BLOCK_UPDATE_ERROR_CODE.ForBidden) {
return this.downgradeUpdatesForBlocks(chunk)
}
// ...其餘兜底處理操作
return null
})
}
// 3的QPS限制,用batchUpdateWithErrorHandle中的then條件判斷決定是否降級處理與重試
await concurrentFetch(3, chunkedBlockUpdates, batchUpdateWithErrorHandle)
}
四、總結
通過對國際化翻譯平臺文檔解析當前所面臨的問題分析,將 SDK 中解析與還原、各種翻譯能力還原到本質,最終抽象出一套適配當前需求與未來發展的三層架構——文檔解析 2.0。其中的具體實現使用了 Decorator 裝飾器、Adapter 與 Proxy 設計模式、限流調度器等等,從中我們也可以看出平時似乎不太常用的語法、設計模式與熱門面試題等等其實都有它們各自發揮作用的場合,這也是爲什麼我們要更加註重平時基礎知識積累的原因——所有強大的上層實現,都離不開底層更強大的基礎知識。
參考資料
[1]
updateBlock: https://open.feishu.cn/document/ukTMukTMukTM/uUDN04SN0QjL1QDN/document-docx/docx-v1/document-block/patch
[2]
批量更新塊接口: https://open.feishu.cn/document/ukTMukTMukTM/uUDN04SN0QjL1QDN/document-docx/docx-v1/document-block/batch_update
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/KHEBKiJYqPEzI21frY6R5w