繁体版网页的实现方案

目前我们仅提供了简体中文版的客户端产品, 但是有许多粤语地区的用户, 比如香港、澳门、台湾、新加坡、以及国外的华人等等,他们的日常文字是繁体中文, 为了扩展这些市场, 我们需要提供一个繁体版的客户端。

客户端内嵌的网页也需要改为繁体版。由于我们这边的网页已经积累了近10年的产品,项目数量太多了,如果全靠手工修改,这个工作量太浩瀚了,必须要想一个成本低廉的方案才行。

目前面临的问题

WEB页面的简繁体切换,目前面临的问题,主要是:

1.量太大:

​ (1)所有嵌入客户端的网页的中文都需要做转换,包括我们自己的以及其他部门的(比如问财、网站资讯等)

​ (2)所有包含文字的图片、视频,都需要重新做一版繁体的(图片中的文字无法通过程序自动转换)

2.程序转换的语义可能有误(这个没验证,只是猜测)

思路

关于中文的切换,目前想到的方案有如下几种:

方案一(半自动):

客户端浏览器内核层面做转换

网页内容的渲染,必定经过浏览器内核的处理,考虑在这个环节劫持网页内容,做简繁体转换,这是成本最低的。目前已经联系客户端的T同事在尝试了,T同事反馈这个要修改内核源码,需要一段时间来验证可行性。

方案二(半自动):

WEB前端调用浏览器API,从渲染环节做处理

流程差不多是这样:

​ 先把页面整体隐藏->前端js获取到页面内容->转换为繁体->渲染页面->显示页面

这个方案会拖慢页面显示速度,页面内容越多,显示速度越慢。普通的页面,基本上会比现在延迟个500毫秒左右显示;类似资讯这样的页面,内容较多的话,可能要数秒。

方案三(全手动):

我们一个一个项目的修改代码,直接提供多语言包,这个工作量浩大,但是可以为今后多语言版(比如英文版)提前做准备。

另外不管哪个方案,都是需要一个项目一个项目测试的,验证无误后才能放出去,开发效率估计会比较低。

建议初期可以只放出一部分功能,服务端做个开关,等某个项目的繁体版网页测试无误,再打开开关放出去。

晚上跟产品讨论了下,预计不久之后还会开发英文版,那么上面的方案一和方案二都只支持繁体版,不具有扩展性了。考虑到这点,我们准备还是采用方案三,自己做多语言包。但是在详细的方案设计上,我们咨询了其他做过繁体版的同事,准备做一点调整,主要的思路是将服务端的请求做一层代理,在代理层进行简繁体转换,这样用户的请求大致流程如下:

用户发起请求->请求中带上lang=tw参数->请求到达统一代理接口->代理接口做简繁体转换->请求到达服务端

这个流程存在一些问题,主要是性能方面的;不过考虑到繁体版用户量很小,且第三方接口转换速度也比较快,因此这个问题关系不大。

方案设计

graph TD
A1(客户端打开网页) --> B1(客户端转换URL)
A2(WEB页面发起Ajax请求) --> B2(JS组件转换请求URL)
B1 --> B(URL转换操作)
B2 --> B(URL转换操作)
B --> C1(改为统一的网关域名)
B --> C2(给url追加上语言参数)
C1 --> D(简繁体转换网关)
C2 --> D(简繁体转换网关)
D --> E(将请求转发到原始域名)
E --> CD{是否为HTML页面}
CD --> |是|D1(将HTML中的静态资源链接改为网关地址)
CD --> |否|F(将请求响应结果从简体转为繁体)
D1 --> F(将请求响应结果从简体转为繁体)
F --> G(返回繁体信息给前端展示)

思路

主要的思路是将服务端(这里指的是我们WEB的服务端)的请求做一层代理,在代理层进行简繁体转换,这样用户的请求大致流程如下:

用户发起请求->前端js组件将请求url转换为我们代理服务器的地址->请求到达代理服务器->代理服务器做简繁体转换->返回给前端繁体内容

这个流程目前最大的问题,就是性能问题,尚在解决中。

需要做的事情

代理转换接口

在RPC的两台服务器(208.21和208.147)上,提供一个代理接口,将所有经过该接口的内容,全部实时转为繁体中文。

思考:静态html(这里指的是类似溯源这种静态页面)到底是通过nginx直接转发到一个繁体的静态文件,还是也实时动态转换?

另外这个代理接口,还要做一个参数处理,将所有繁体参数值自动转为简体中文,因为服务端的数据都是存储的简体中文。

针对图片和视频,需要特殊处理下,图片和视频没有办法通过程序自动转换,只能重新制作一份繁体版的,然后在代理服务器做处理,将图片和视频请求导向繁体版的地址。

注意:我们的代理地址,需要提供多个域名,否则浏览器并行下载资源数量是有限制的,所有请求都走代理地址,会导致用户端的资源加载速度非常慢!

前端dom解析组件

提供一个js组件,可以在dom加载完、页面请求资源之前,将页面上所有的资源地址全部转为繁体版的资源地址,拼装为类似这样的地址:

http://proxy.10jqka.com.cn/app/1/url/http%3a%2f%2fwww.ths123.com%2fjrds%2fiwillbuy%2f/

其中的app参数标志应用类型,不同的语言,app的值不一样,便于今后扩展其他类型的语言;url参数是请求本来的地址经过urlencode处理后的字符串。

这个后面考虑了下,不用前端做,代理服务器识别到是个html,直接替换掉页面中所有的资源地址即可。

前端JS请求拦截组件

提供一个前端Ajax请求组件,所有前端请求都通过这个组件来发起,发起的时候,组件会先尝试读取客户端接口,判断当前是否处于繁体环境(单例模式,只读取一次,然后存储到内存变量中;如果用户手动修改了简繁体模式,则会直接修改掉这个内存变量的数值)。如果发现处于繁体环境,则会自动解析url,将其拼装为上面的代理服务器的地址。

能否客户端在cookie里面带一个特殊标记,注明这是繁体版,这样前端不用做任何修改?不行,将url改为代理地址的新url,这一步还是必须前端做的,因此省不了。

前端数据存取组件

我们最终存储的数据,为了兼容不同客户端,是必须保存为简体的。因此我们还需要一个转换简繁体的数据存取组件,所有的前端读写数据,都通过这个组件来实现。

这个有2个实现方案:

1、前端直接调用js组件转换

2、前端调用服务端接口转换

这个需要测试,在性能和转换准确度上做个对比。

2018.08.27:李璐慧反馈,可以通过反爬的请求劫持组件来实现这个功能,这样就不用前端每个项目都修改了。

因此前端要做的工作就简化为2个部分:

1.普通http请求,通过反爬劫持组件来转换url

2.调用客户端接口发送请求的场景,通过cefapi组件来转换url

不能借助客户端,我们要和客户端解耦

服务端做一个控制应用入口是否开放的开关管理后台

这些修改没那么快,得一个项目一个项目修改和测试,因此需要服务端做开关,控制客户端的入口,改完一个放出去一个。

前端额外工作内容

前端这边需要在上述工作量的基础上增加一些内容:
1.除了ajax的web这块的组件,cefAPI还需要增加返回值繁体解析,读写文件时自动增加繁简体转换
2.建议TZP这边可以把繁简体这个消息挂载到window.cefLanguage上面,不用通过接口获取,异步的获取比较麻烦
3.canvas这一块非DOM变化里面的繁简体转换,需要重新封装一下echarts和highcharts,现在有比较多的项目是图表类型的
4.DOM变化这一块可以考虑芦荟昨天给的MutationObserver实现,性能上会稍微差点
5.还有页面中用到的图片中有部分还是中文字也需要换图片

静态本地资源

主要针对本地生成的html静态文件,比如像类似溯源这种项目。

静态远程资源

特指资源服务器x.thsi.cn上面的静态资源,包括css、js、images等

动态数据接口

待解决问题

OpenCC的性能问题

我们这边会用到大量的实时转换,这个性能怎么样,需要测试下。

可能要考虑用其他语言来做这个服务接口,PHP性能是个问题。

*一些实事类名词不能通过OpenCC转换

比如电影名、书籍名、人名等

CSS转换成繁体后,字体文字是否能正常识别

页面上动态追加的资源怎么处理

比如动态添加到页面上的css、js、图片地址。

图片怎么处理

程序无法自动转换图片中的文字。

视频怎么处理

同图片一样的问题。

衡量性价比,耗费资源太多的功能就先不上了。

参考资料:

Open Chinese Convert
https://github.com/BYVoid/OpenCC

具体操作

安装OpenCC

github下载tar.gz源码包,解压后,直接make && make install即可安装成功

命令行调用

转换文件:

opencc -i -o -c

字符串测试:

echo “鼠标”|opencc -c s2tw

注意:这个有问题,台湾鼠标应该叫“滑鼠”,但是这里转为了“鼠標”,原因是我们选择的配置是“臺灣正體”。如果想要转为台湾常用语,-c参数的值要写成s2twp

附上各个配置文件的说明:

s2t.json Simplified Chinese to Traditional Chinese 簡體到繁體
t2s.json Traditional Chinese to Simplified Chinese 繁體到簡體
s2tw.json Simplified Chinese to Traditional Chinese (Taiwan Standard) 簡體到臺灣正體
tw2s.json Traditional Chinese (Taiwan Standard) to Simplified Chinese 臺灣正體到簡體
s2hk.json Simplified Chinese to Traditional Chinese (Hong Kong Standard) 簡體到香港繁體(香港小學學習字詞表標準)
hk2s.json Traditional Chinese (Hong Kong Standard) to Simplified Chinese 香港繁體(香港小學學習字詞表標準)到簡體
s2twp.json Simplified Chinese to Traditional Chinese (Taiwan Standard) with Taiwanese idiom 簡體到繁體(臺灣正體標準)並轉換爲臺灣常用詞彙
tw2sp.json Traditional Chinese (Taiwan Standard) to Simplified Chinese with Mainland Chinese idiom 繁體(臺灣正體標準)到簡體並轉換爲中國大陸常用詞彙
t2tw.json Traditional Chinese (OpenCC Standard) to Taiwan Standard 繁體(OpenCC 標準)到臺灣正體
t2hk.json Traditional Chinese (OpenCC Standard) to Hong Kong Standard 繁體(OpenCC 標準)到香港繁體(香港小學學習字詞表標準)

脚本

编写一个shell脚本,便于程序中调用该脚本来转换文本:

1
2
3
4
5
#vim /usr/bin/s2twp.sh

#!/bin/sh
#echo $1
echo $1|opencc -c s2twp

语言选择

这个功能对性能有要求,因此尽量轻量化,不用任何框架,追求高并发。

感觉Lua+OpenResty是比较合适的选择,或者Node也行

还是先写个Java程序,测试下性能再说吧

Java程序

这里要注意一个问题:参数是通过命令行传入脚本的,如果参数中有单引号、双引号,会不会导致shell脚本接收到的参数有误?

空格会导致失败

转义换行符和引号,然后在字符串首尾加上引号,简繁体转完,再反转义。

Java写子进程程序的注意事项:

1.如果命令中有空格,一定要拆分为数组再传入

2.输出(标准输出&错误输出)要用单独的线程来处理,否则会卡住

3.处理输出结果的线程里面的数据如何传递到外层?

可以采用观察者模式,在线程类中,将雇主类的引用设置进去。

参考这个文章:https://blog.csdn.net/yumeizui8923/article/details/79409668

4.执行管道命令,需要将命令拆分为3部分,类似这样:

1
2
String[] cmds = {"/bin/sh", "-c", command};
final Process p = Runtime.getRuntime().exec(cmds);

5.执行shell命令,报:Argument list too long,可以看看是否可以将参数拆分为多次执行。

6.输出日志的时候,报java.util.ConcurrentModificationException

原因是我将日志信息存入了一个ArrayList,而ArrayList是非线程安全的。

最终的程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class OpenCC {

private static int words = 1000;


private String result;

public void appendResult(String content) {
this.result += content;
}

public String getResult() {
return this.result;
}

/**
* 执行命令
*
* @return
*/
public String sc2twp() {
//将命令分解为List存储
String command = "/usr/bin/opencc -c s2twp -i /home/zhouchangju/java/home.html";
String[] commandSplit = command.split(" ");
List<String> lcommand = new ArrayList<String>();
for (int i = 0; i < commandSplit.length; i++) {
lcommand.add(commandSplit[i]);
}
final String resultLog = "";
ProcessBuilder processBuilder = new ProcessBuilder(lcommand);
//将标准输出和标准错误流合并
processBuilder.redirectErrorStream(true);
try {
final Process p = processBuilder.start();
InputStream is = p.getInputStream();
BufferedReader bs = new BufferedReader(new InputStreamReader(is));

//------------------------------
//处理InputStream的线程
InputStreamThread ist = new InputStreamThread();
ist.setOpenCC(this);
ist.setInputStream(p.getInputStream());
ist.start();

InputStreamThread est = new InputStreamThread();
est.setOpenCC(this);
est.setInputStream(p.getErrorStream());
est.start();

try {
p.waitFor();
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
return null;
}
if (p.exitValue() != 0) {

//说明命令执行失败
//可以进入到错误处理步骤中
System.out.println("Warning!Execute failed ");
}
} catch (IOException ioException) {
ioException.printStackTrace();
return null;
}
return resultLog;
}


/**
* 将整个文件的内容读入一个字符串变量
*
* @param filePath
* @return
* @throws IOException
*/
public static String read(String filePath) throws IOException {
StringBuilder sb = new StringBuilder();
String str;
BufferedReader br = new BufferedReader(new FileReader(filePath));
while (null != (str = br.readLine())) {
sb.append(str);
}
br.close();
return sb.toString();
}

public static void main(String[] args) {
OpenCC openCC = new OpenCC();
openCC.sc2twp();
String tw = openCC.getResult();
System.out.println(tw);
}
}

/**
* 处理输出流
*/
class InputStreamThread extends Thread {
private OpenCC openCC;

private InputStream inputStream;

public void setOpenCC(OpenCC openCC) {
this.openCC = openCC;
}

public void setInputStream(InputStream inputStream) {
this.inputStream = inputStream;
}

@Override
public void run() {
BufferedReader in = new BufferedReader(new InputStreamReader(this.inputStream));
String line = null;

try {
while ((line = in.readLine()) != null) {
this.openCC.appendResult(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

经测试,这段代码性能极差,和直接命令行执行,相差百倍。主要是wait这部分代码,会耗费接近2秒时间。

猜测是文本长了之后,拼接字符串耗时过长。我用的是字符串拼接,换成StringBuffer,发现性能提升了10倍。

性能问题还是最大的问题,用Java写的程序,转换速度只有命令行的十分之一。程序逻辑就只是简单调用了一下命令行而已。原因待查。

PHP和Java的性能对比,可以看出PHP快得多:

1
2
3
4
5
6
7
8
9
10
11
[root@wc_b2ctest_195 java]# time java OpenCC
我是一名軟體工程師,我喜歡滑鼠鍵盤和伺服器
real 0m0.219s
user 0m0.276s
sys 0m0.053s

[root@wc_b2ctest_195 java]# time php transfer.php
我是一名軟體工程師,我喜歡滑鼠鍵盤和伺服器
real 0m0.078s
user 0m0.043s
sys 0m0.008s

截止目前,用Java还有2个问题待解决:

1、性能问题,Java调用shell命令,性能是命令行执行shell的十分之一 这是误解,实际上相差没那么多

2、长文本的转换问题,现在都是拆分为多个文本,但是拆分后,转换时间也翻了N倍

3、失败的问题,ab测试,有很多请求失败了

写入文件

如果写入文件,又存在并发问题:

给文件加锁,并发数会受到限制,用户的请求只能同步处理;

不给文件加锁,会存在脏数据问题

先将双引号、换行符进行转义,然后再转回来

####逐行转换

每个线程读写一个文件

创建一个线程池,文件名以线程名来命名

内存文件

性能问题

试过Runtime.exec()和ProcessBuilder.start(),创建子进程都很慢。

性能测试

短文本

程序部署在80.195测试服务器上,从80.196发起测试请求,转换如下文本:

我是一名软件工程师,我喜欢鼠标键盘和服务器

PHP

ab -c 10 -n 10000 http://ai.10jqka.com.cn/transfer.php

并发很低,1秒200多

Concurrency Level: 10
Time taken for tests: 45.940 seconds
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 2330000 bytes
HTML transferred: 630000 bytes
Requests per second: 217.67 [#/sec] (mean)
Time per request: 45.940 [ms] (mean)
Time per request: 4.594 [ms] (mean, across all concurrent requests)
Transfer rate: 49.53 [Kbytes/sec] received

ab -c 10 -n 10000 http://ai.10jqka.com.cn/transfer.php

负载飙升很严重,跑了30000个左右的请求,到了100我就停掉了,cpu消耗很高,内存消耗倒是不高

Java

ab -c 10 -n 10000 http://10.10.80.195:10004/opencc/translate

失败率较高,并发量很小,1秒才100多点,比PHP低

Concurrency Level: 10
Time taken for tests: 68.935 seconds
Complete requests: 10000
Failed requests: 333
(Connect: 0, Receive: 0, Length: 333, Exceptions: 0)
Write errors: 0
Total transferred: 1748688 bytes
HTML transferred: 628688 bytes
Requests per second: 145.06 [#/sec] (mean)
Time per request: 68.935 [ms] (mean)
Time per request: 6.893 [ms] (mean, across all concurrent requests)
Transfer rate: 24.77 [Kbytes/sec] received

ab -c 100 -n 100000 http://ai.10jqka.com.cn/opencc/translate

负载稳定上升到大约20-30之间,CPU消耗比PHP低很多,内存消耗也不是很多

QPS变化不是很大,但是Time per request从上一步的68ms上升到了761ms,原因待查

Concurrency Level: 100
Time taken for tests: 761.358 seconds
Complete requests: 100000
Failed requests: 12415
(Connect: 0, Receive: 0, Length: 12415, Exceptions: 0)
Write errors: 0
Total transferred: 22405440 bytes
HTML transferred: 5705440 bytes
Requests per second: 131.34 [#/sec] (mean)
Time per request: 761.358 [ms] (mean)
Time per request: 7.614 [ms] (mean, across all concurrent requests)
Transfer rate: 28.74 [Kbytes/sec] received

GO

2000左右QPS

C-GO,调用C++扩展

Node

Node直接提供了api文件,不用编译。

性能李璐慧测试了下:

1
2
3
4
opencc node中性能感觉不太行 ..
6个字 153.28 [#/sec] (mean)
对比什么都不处理 2452.92 [#/sec] (mean)
优化了下,实例常驻好了很多 ~ 2605.24 [#/sec] (mean)

这个性能怎么这么优秀。。。。。。

技术选型

基于OpenCC的方案

JNI

jopencc

https://github.com/carlostse/jopencc

这个提供了GUI,但是看代码似乎没有做性能方面的考虑。

OpenCC-Java

一个基于OpenCC的Java版本:https://github.com/yichen0831/OpenCC-Java

基于其他语言

Node

支持同步和异步方式。

关于JNI的使用

官方没有提供,需要我们自己编写代码进行编译。

可以参考这个文章:https://blog.csdn.net/promaster/article/details/70318695

QConf也用到了JNI,可以参考下其源码。

也参考这个文章,更贴合实际一些:

https://blog.csdn.net/promaster/article/details/70318695

JNI的文档和常用API函数:

https://en.wikipedia.org/wiki/Java_Native_Interface

https://docs.oracle.com/javase/1.5.0/docs/guide/jni/spec/functions.html

OpenCC的安装

github下载源码包,直接make就行了,会将可执行文件装到/usr/bin/opencc

编译生成的文件信息在build/rel/install_manifest.txt里面

可以看到生成了一个/usr/lib/libopencc.so链接库

QConf如何实现JNI?

首先通过java定义了一些对外暴露的native方法,以getConf()方法为例(Qconf.java):

1
2
3
4
5
6
7
8
9
10
11
12
/**
* get value configure from Qconf by the key and idc<br>
* it will wait for a while if the qconf-agent not get the configure yet, at most 100 * 5 millisecond
*
* @param key the key indicate one configure item
* @param idc server room name, use 'null' if get configure from the one of current client machine
* @return value of the configure, return null if failed
* @exception QconfException
* if any exception of qconf happend during the operation
* @since version 0.3.1
*/
public native static String getConf(String key, String idc) throws QconfException;

然后生成.h头文件(net_qihoo_qconf_Qconf.h):

1
2
3
4
5
6
7
/*
* Class: net_qihoo_qconf_Qconf
* Method: getConf
* Signature: (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_net_qihoo_qconf_Qconf_getConf
(JNIEnv *, jclass, jstring, jstring);

接着是对上面定义的方法的C++实现(java_qconf.cc):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

/*
* Class: net_qihoo_qconf_Qconf
* Method: getConf
* Signature: (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_net_qihoo_qconf_Qconf_getConf
(JNIEnv *env, jclass jc, jstring key, jstring idc)
{
if (NULL == key)
{
print_error_message(env, jc, QCONF_ERR_PARAM);
return NULL;
}
const char *key_c = env->GetStringUTFChars(key, NULL);
if (NULL == key_c)
{
print_error_message(env, jc, QCONF_ERR_PARAM);
return NULL;
}
char value_c[QCONF_JAVA_CONF_BUF_MAX_LEN];
int ret = QCONF_ERR_OTHER;
if (NULL == idc)
{
ret = qconf_get_conf(key_c, value_c, sizeof(value_c), NULL);
}
else
{
const char *idc_c = env->GetStringUTFChars(idc, NULL);
if (NULL == idc_c)
{
print_error_message(env, jc, QCONF_ERR_PARAM);
env->ReleaseStringUTFChars(key, key_c);
return NULL;
}
ret = qconf_get_conf(key_c, value_c, sizeof(value_c), idc_c);
env->ReleaseStringUTFChars(idc, idc_c);
}
env->ReleaseStringUTFChars(key, key_c);

if (QCONF_OK != ret)
{
print_error_message(env, jc, ret);
return NULL;
}
jstring value = env->NewStringUTF(value_c);
return value;
}

注意这个实现文件,需要include两个头文件net_qihoo_qconf_Qconf.h和qconf.h:

1
2
3
#include "net_qihoo_qconf_Qconf.h"
#include <stdio.h>
#include "qconf.h"