软件国际化必然需要翻译文件。虽然我也很想用类似 locize.com
之类的服务管理翻译,这样 i18next-locize-backend
直接游戏结束
可惜用不得,只有一个甚至早期管理还很乱的 excel 文件,所以需要解决「如何把它变成 json」的问题
TL;DR
- 插件类型应用必然使用抽象工厂,早在设计 FEMessage/upload-to-ali 服务端就这么用
- 将文件对应为统一结构,以扩展的形式输出对应要求格式文件
- 使用
calamine
解析xls(x)
文件 - 使用
rayon
直接让列表多线程
原始需求
因为确实需求很简单:将一个有规则的 excel 文件按照 i18next 可用的格式转成 json 文件。第一行是标题,除了第一列是 key,第二列开始是每个语言(例如简体中文、美式英语…)
所以这里大致有两个问题:
- 获取传入参数
- 解析 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 程序也有一样的需求,毕竟一心同体
虽说 QTranslator
和 QtLinguist
很强,但对我们来说只解决把 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>
接着顺便将这个列表丢给 Converter
的 generate
方法,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 还做了一些额外的工作,但跟核心思路无关且不太好说,应该就这样差不多了