软件国际化必然需要翻译文件。虽然我也很想用类似 locize.com 之类的服务管理翻译,这样 i18next-locize-backend 直接游戏结束

可惜用不得,只有一个甚至早期管理还很乱的 excel 文件,所以需要解决「如何把它变成 json」的问题

TL;DR

  • 插件类型应用必然使用抽象工厂,早在设计 FEMessage/upload-to-ali 服务端就这么用
  • 将文件对应为统一结构,以扩展的形式输出对应要求格式文件
  • 使用 calamine 解析 xls(x) 文件
  • 使用 rayon 直接让列表多线程

原始需求

因为确实需求很简单:将一个有规则的 excel 文件按照 i18next 可用的格式转成 json 文件。第一行是标题,除了第一列是 key,第二列开始是每个语言(例如简体中文、美式英语…)

所以这里大致有两个问题:

  1. 获取传入参数
  2. 解析 excel 文件

第一个还行,可以使用 clap。但这里不是一个非常复杂的 cli 应用,避免麻烦,直接读取 env::args() 分段即可

而第二个也直接找现成的解析库。今非昔比, rust 生态已经很好了。所以这里选择已经在 README 里暴打其他语言同行遥遥领先的 calamine(但不支持 write),大概是这样用的

use calamine::{Reader, Xlsx, open_workbook};

fn main() {
  let mut excel: Xlsx<_> = open_workbook("file.xlsx").unwrap();
  if let Some(Ok(r)) = excel.worksheet_range("Sheet1") {
      for row in r.rows() {
          println!("row={:?}, row[0]={:?}", row, row[0]);
      }
  }
}

搞的很快啊,POC 一下子就弄完了。但是!本体 Qt 程序也有一样的需求,毕竟一心同体

虽说 QTranslatorQtLinguist 很强,但对我们来说只解决把 tag 取出来提供到 excel,转成 .ts 文件还是需要一个程序完成

那就格局大一点,excel to any,万一哪天需要 xml(来自后来人的说明:Qt 组直接以最简单最直接最不需要考虑产品化的思路干出一个序列/反序列全套程序,所以这个用不上了)

如今实现

所以核心思路演变为:

  • 将每一组翻译变成一个通用结构
  • 以扩展的形式读取这个通用结构列表,写出扩展想实现的输出

非得拽一下,就是 parser -> interpreter

所以问题拆解成两个实现:

  • 一个通用的 converter
  • 若干个 extensions (e.g. i18next-extension)

Converter

开始解决第一个问题:excel -> 「AST」

刚刚有提到这个文件的格式:

第一列是 key,第二列开始是每个语言对应的翻译

这个时候我们可以用一个结构体描述

struct LangPosition {
  name: String,
  position: usize
}

开始构建工作列表。假设这里有 6 种语言,那么就可以包装成 [LangPosition; 6]

但是对于程序执行中不可能知道具体数量,所以构建一个 Vec<LangPosition>,通过扫标题来获取「有多少语言」。类似这样

let header = range.rows().next().ok_or(Error::Msg("Empty sheet"))?;
let mut lang_list = Vec::<LanguagePosition>::new();


for (i, cell) in header.iter().enumerate() {
    if let Some(language) = cell.get_string() {
      // 把语言和列索引加入 HashSet
      lang_list.push(LanguagePosition {
          language: language.to_string(),
          position: i,
      })
    }
}

现在已经知道了每个语言的对应关系,当需要生成某个语言的翻译文件,只需要让 Extension 处理这个 LanguagePosition 即可

但并不是,这里只是有某个语言和它的位置,现在需要做的是把 key 和某个语言的 value 对应起来并变成一个列表,所以:

struct Atom {
  // key
  key: String,
  // 与之对应的 i18n value
  value: String,
}

接下来要遍历 Vec<LanguagePosition>,并且「按照单个语言为单位,通过 Extension 执行输出」。这里会用到一个 crate:rayon

rayon 是一个并行计算库,即可以使遍历这种事以多线程的方式运行,且无数据竞争。这对这个需求来说非常好 —— 每个语言的处理其实都很独立

所以这里要做的就是:

use rayon::prelude::*;

lang_list.par_iter().for_each();

按照 row[0] 是对应的 key,而 row[position] 则是对应 language 的翻译,就可以很方便给每种语言生成 Vec<Atom>

接着顺便将这个列表丢给 Convertergenerate 方法,generate 方法会调用对应的 Extension

所以应该补充一下,Converter 的结构:

struct Converter {
  processor: Option<Box<dyn Extension>>,
}

ok,来活了!为什么 Option

其实这里要的很简单:初始化 Converter 的时候可以先不提供,由 converter.register 注册 Extension 并形成链式调用出去

这样做的目的是,当以后会有多个 Extension 想并行处理,则直接改成 processors 并且 register 可以变成以一种 processors.push 的形式

所以初始化 Converter 的时候,processor 可以是 None,并且当 Extension 是 None 时也可以直接处理异常,甚至是啥都不做,提醒一下然后直接退出

但是 dyn Extension

dyn / impl

dyn 指动态分发,在运行时确定真正的返回类型。而 impl 与之对应即静态分发,编译期决定返回类型

换句话说,当要实现多态时,事实上是很难甚至是没办法知道「到底实现 Trait 的是谁」,只有真正运行起来才知道

但不管运不运行,只要是「鸭子模型」就都无所谓。而在程序之间传递的变量甚至是返回值,其实是可以知道「这个 Struct 就是实现了这个 Trait 甚至要求实现」,所以:

fn register(self, ext: impl Extension + 'static) {}

而且,dyn,dynamic,意味着编译时也无法确定 size,所以只能进堆,所以需要 Box,在 2018 之前处理这种情况是 Box<Trait>,在 2018 之后就变成了 Box<dyn Trait>

为什么 2018?因为这俩是在 2018 Edition 新加的

Extension

一切准备好之后,意味着单个工作流内,会有一个Vec<Atom>等着挨处理,这个时候 Extension 只要要求实现 transform 就好了,或者说至少实现 transform

trait Extension: Sync {
  // 要求是 String 方便下一步写入文件
  fn transform(self, data: Vec<Atom>) -> String
}

另外再实现 ToString,这一步是为了用户交互时可以 print 出当前正在使用什么扩展

其实应该实现 Display的,但是实现 Display 需要处理所有权的问题。例如我把 Extension 提供给 register 之后,所有权也对应转移。这个时候还要考虑 Copy 或者 Clone 就显得很麻烦

所以:

trait Extension: ToString + Sync {
  // 要求是 String 方便下一步写入文件
  fn transform(self, data: Vec<Atom>) -> String
}

剩下要做的,就是看需求实现对应的 Extension,例如 I18NextExt

struct I18NextExt;

impl Extension for I18NextExt {
  fn transform(self, data: Vec<Atom>) -> String {}
}

实际上在 Extension 和 Language 还做了一些额外的工作,但跟核心思路无关且不太好说,应该就这样差不多了