这两天把pqt活动文本提取器进行重构了,理由也很简单,纯粹就是程序不好使...
就跟前面说的那样,程序的启动极其复杂、笨重,必须要人力干预,用肉眼查看、搜索定位“锚点”,还要保证其唯一性,否则根本无法运行,真的给“程序”这个词蒙羞了。
更离谱的是,这次游戏新出的角色活动中,当我花费了大力气在源数据文件中查找到了锚点,并把它们输入程序,然后点击运行的时候,我没能得到想要的结果。
虽然只是这种程度的东西,毕竟还是自己花费了不少时间才产出来的,此刻,我不禁陷入沉思...
回头看了一眼代码,确实能感觉到很烂,根本就没个样,总觉得下一秒就会散架,简陋到爆炸,当时确实没有考虑到程序设计问题,写得很随意,怎么快怎么来,只想着赶紧得到结果。
现在一想到这是一堆啰里八嗦、毫无编程思想可言的东西,就对出现的这种情况感到释怀了,有时甚至觉得它之前能正常运行都是件不可思议的事情...
以上就是本次重构的前提,目前工作已经完成了个七七八八,下面大致介绍一下主要的改动以及思路。
一、改动&思路
相较于先前的版本,本次注重的是代码设计,更多地考虑拓展性、可维护性、易用性。
在代码层面,全面采用面向对象编程思想,并对程序输入进行了配置化,达到了减少冗余和动静分离的效果。
在逻辑判断方面,为程序重新寻找了锚点:起始锚点均采用了角色通用的标识,如角色id、角色名称,而结尾锚点则采用了固定点位,这是可配置化的基础。
此外,还把翻译程序也整合了进来,可以根据需要在配置文件中指定翻译的文本文件。
也就是说改版之后的程序具有两个功能:文本提取和文本翻译。
程序的运行逻辑如下:
1)根据配置文件的活动名称获取对应的活动处理器,并通过反射实例化;
2)运行活动处理器的process(),把结果写入输出文件中;
3)尝试从配置文件中读取要翻译的文件名,如果存在时,则翻译该文件,并把翻译结果写入到当前文件所在的目录。
这个逻辑基于oop的多态特性,处于启动类的main方法中。
// ...
static {
processorClassNameMap.put("dating", "com.errol.pqtworkshop.processor.impl.DatingProcessor");
processorClassNameMap.put("girlswatch", "com.errol.pqtworkshop.processor.impl.GirlsWatchProcessor");
processorClassNameMap.put("simgirl", "com.errol.pqtworkshop.processor.impl.SimGirlProcessor");
}
// ...
public static void main(String[] args) {
# 根据活动名称获取对应的活动处理器全类名
String className = processorClassNameMap.get(activityName);
try {
Class<?> clazz = Class.forName(className);
# 通过反射实例化活动处理器
ActivityProcessor activityProcessor = (ActivityProcessor) clazz.newInstance();
System.out.println(">>> "+ Application.class.getSimpleName() +" started: " + System.currentTimeMillis());
System.out.println(">>>> "+ activityProcessor.getClass().getSimpleName() +" started: " + System.currentTimeMillis());
# 调用process()方法把结果输出到指定的文件中
activityProcessor.process(petName, activityName, sourceFilePath, outputFilePath, petConfig);
System.out.println(">>>> "+ activityProcessor.getClass().getSimpleName() +" ended: " + System.currentTimeMillis());
String translatingFileName = petConfig.get("translating-file");
String[] translatingFileNames = translatingFileName == null || translatingFileName.trim().isEmpty() ? new String[]{} : translatingFileName.split(",");
if (translatingFileNames.length > 0) {
for (String fileName : translatingFileNames) {
# 如果需要翻译的文件则使用指定的翻译器翻译文件
activityProcessor.translate(petConfig.get(fileName), petConfig);
}
}
System.out.println(">>> "+ Application.class.getSimpleName() +" finished: " + System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}
DatingProcessor,GirlsWatchProcessor和SimGirlProcessor,分别是对应活动名称的处理器,任何一个都实现了ActivityProcessor接口,这也是它们能向上转型的原因,ActivityProcessor接口主要有两个方法:
1)process()方法用于处理提取目标文本;
2)translate()方法用于翻译指定的文本文件。
public interface ActivityProcessor {
void process(String petName, String activityName, String sourceFilePath, String outputFilePath, Map<String, String> petConfig);
default void translate(String filePath, Map<String, String> petConfig) {
// 翻译逻辑略...
}
}
在具体的ActivityProcessor中,依赖了不同的文件解析器,用于解析数据源文件中的不同位置的文本,每个文件解析器依赖一个“位置(Position)”,Position的数据从角色配置文件中读取,它们的关系如下:
- DatingProcessor
- ChapterResolver
- Position
- ChapterResolver
- GirlsWatchProcessor
- StoryResolver
- Position
- DaysResolver
- Position
- DetectiveResolver
- Position
- StoryResolver
- SimGirlProcessor
- StoryResolver
- Position
- TalkingResolver
- Position
- StoryResolver
其中文件解析器中又包含了具体的文本提取规则,它们与处理器一起共同完成提取文本的工作。
# 解析器接口,具体就不再展开了...
public interface FileResolver extends Resolver<String, StringBuilder> {
StringBuilder resolve(String filePath);
}
二、项目结构
在介绍了主要的改动和设计思路后,再来了解项目的构成。(其实主要是配置文件)
除了filter和handler两个包是翻译器(translator)专用的之外,其他的包&文件,活动处理器(ActivityProcessor)均有直接或间接使用到。
└── main
├── java
│ └── com
│ └── errol
│ └── pqtworkshop
│ ├── Application.java
│ ├── decider # 锚点定位判断接口
│ │ └── Decider.java
│ ├── entity
│ │ └── Position.java
│ ├── filter # 文本过滤接口
│ │ ├── TextFilter.java
│ │ └── impl # 接口实现类
│ │ └── CommonTextFilter.java
│ ├── handler # 文本处理接口
│ │ ├── PreHandler.java
│ │ ├── TextHandler.java
│ │ └── impl
│ │ └── CommonTextHandler.java
│ ├── processor # 活动处理接口
│ │ ├── ActivityProcessor.java
│ │ └── impl
│ │ ├── DatingProcessor.java
│ │ ├── GirlsWatchProcessor.java
│ │ └── SimGirlProcessor.java
│ ├── resolver # 文件解析接口
│ │ ├── AbsResolver.java
│ │ ├── FileResolver.java
│ │ ├── Resolver.java
│ │ └── impl
│ │ ├── ChapterResolver.java
│ │ ├── DaysResolver.java
│ │ ├── DetectiveResolver.java
│ │ ├── StoryResolver.java
│ │ └── TalkingResolver.java
│ ├── translator # 翻译接口
│ │ ├── AbsTranslator.java
│ │ ├── ActivityTranslator.java
│ │ └── impl
│ │ └── BaiduTranslator.java
│ └── util # 工具类
│ ├── CommonUtil.java
│ ├── HttpUtil.java
│ └── Md5Util.java
└── resources
├── META-INF
│ └── MANIFEST.MF
├── application.properties
├── dating # 活动1配置文件
│ ├── EXAMPLE.txt # 配置文件示例
│ ├── Katherine.txt
│ └── SummerLadies.txt
├── girlswatch # 活动2配置文件
│ ├── EXAMPLE.txt
│ ├── Kaia.txt
│ └── Violet.txt
├── simgirl # 活动3配置文件
│ ├── Alizee.txt
│ └── EXAMPLE.txt
└── source-file # 数据源文件
└── 20230927-config.data
1、配置文件
下面主配置文件的含义是,提取名为“Violet”角色的“girlswatch”活动的文本,同时,还应该存在“/[活动]/[角色].txt”的角色配置文件,以当前的配置文件为例,则为“/girlswatch/Violet.txt”,该配置文件会在启动类处读取,格式按示例文件创建即可。
# 角色名称
pet-name=Violet
# 活动名称【以及dating、simgirl】
activity-name=girlswatch
# 数据源路径
source-file=pqt-workshop/src/main/resources/source-file/20230927-config.data
# 输出文件路径【文件格式为'角色'-'活动'-of.txt,'output-file'用于存储活动文本,'output-file2'用户存储答案(如果活动有问答的话)】
output-file=pqt-workshop/output/%s-%s-of.txt
#output-file2=pqt-workshop/output/%s-%s-of-2.txt
# 需要翻译的文件
translating-file=output-file
# 翻译接口实现类【目前只有百度翻译,有道翻译挂了】
translator-class-name=com.errol.pqtworkshop.translator.impl.BaiduTranslator
2、角色配置文件
该文件中的数据是提供给文件解析器使用的,用于判断从何处开始、结束提取文本。
dating活动需要修改的地方比较少,仅需改动一处;其次是simgirl,需要改动两处;最后是girlwatch,需要改动三处。标有'固定'注释的属性不需要改动。
1)dating活动
# 输入[第一章的标题]
chapterPosition-starting=[第一章的标题]
# 固定
chapterPosition-ending=next_idending_waypoint
2)girlwatch活动
# 修改PET_NAME,如mina【删除'[]'】
storyPosition.starting=[PET_NAME] LG AVG
# 固定
storyPosition.ending=timeslot_detail,girl_watch_message_detail_settings
# 修改PET_ID,如1090【可以到Android/data/com.ignite.qt/files/Assets/character】目录对照查看
detectivePosition.starting=hint_spinelens[PET_ID]_bg1_item1_hint.png
# 固定
detectivePosition.ending=Day4.unity3d
# 修改PET_ID
daysPosition.starting=girl_watch_message_detail_settings,LensAvg[PET_ID]
# 固定
daysPosition.ending=Day 4,ending_message_dataending_waypoint
3)simgirl活动
# 修改PET_NAME
storyPosition.starting=[PET_NAME] MD AVG
# 固定
storyPosition.ending=quiz_dating_level_settings,topic
# 输入[第一句对白]
talkingPosition.starting=[第一句对白]
# 固定
talkingPosition.ending=timeslot_type,timeslot_detail
玩家用户需要做的是,正确地填写配置信息,然后运行程序,程序会根据这两种配置文件来输出不同的结果。
三、小结
程序在重构之后,已经从原来的“脚本代码”升级为“小程序”了,面向对象、接口、抽象、配置、反射等概念贯穿于整个项目,把变化与不变抽取分离,使得程序具有一定的可维护性与拓展性,用法也比之前简单得多。
打包丢给别人使用也是可以的,除了目录文件的路径之外,其他没有变化。当然,肯定还需要jre环境,不然无法运行。
图1 打包后程序的目录结构
总的来说,这个程序已经基本符合当初的设想了。
图2 之前一篇文章中的片段
目前程序的稳定性也还算可以,已经测试了好几个角色,均能得到正确的结果,相信只要数据源文件的结构不发生变动,程序就能正常运行下去。关于这一点,之前也有提到过,就跟web爬虫一样,如果目标网站的html结构有变化,原来的抓取逻辑就不再生效,需要重新编码,这是不可抗拒的因素。
所以我希望它永远都不要变动...
就看接下来重构后的程序能否适用于最新的活动事件了,理论上应该可以。
整个过程还是蛮有趣的,尤其是在思考程序设计的时候,好像有一种无形的东西引领着自己前进:或快或慢,脑里总会浮现某种想法、点子,来指导自己接下来如何编写代码。回想起来,过去也常常出现类似的经历,或许这就是强类型语言的魅力?它会强迫使用者绞尽脑汁并结合面向对象进行思考,否则无法写出简单易用的代码?
所以这是不是证明了系统项目都采用强类型语言进行编写比较好呢?(开玩笑的)