pqt活动文本提取器重构
errol发表于2023-10-04 12:33:51 | 分类为 瞎聊 | 标签为pqt重构

这两天把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
  • GirlsWatchProcessor
    • StoryResolver
      • Position
    • DaysResolver
      • Position
    • DetectiveResolver
      • Position
  • SimGirlProcessor
    • StoryResolver
      • Position
    • TalkingResolver
      • Position

其中文件解析器中又包含了具体的文本提取规则,它们与处理器一起共同完成提取文本的工作。

# 解析器接口,具体就不再展开了...
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环境,不然无法运行。

image

图1 打包后程序的目录结构

总的来说,这个程序已经基本符合当初的设想了。

image

图2 之前一篇文章中的片段

目前程序的稳定性也还算可以,已经测试了好几个角色,均能得到正确的结果,相信只要数据源文件的结构不发生变动,程序就能正常运行下去。关于这一点,之前也有提到过,就跟web爬虫一样,如果目标网站的html结构有变化,原来的抓取逻辑就不再生效,需要重新编码,这是不可抗拒的因素。

所以我希望它永远都不要变动...

就看接下来重构后的程序能否适用于最新的活动事件了,理论上应该可以。


整个过程还是蛮有趣的,尤其是在思考程序设计的时候,好像有一种无形的东西引领着自己前进:或快或慢,脑里总会浮现某种想法、点子,来指导自己接下来如何编写代码。回想起来,过去也常常出现类似的经历,或许这就是强类型语言的魅力?它会强迫使用者绞尽脑汁并结合面向对象进行思考,否则无法写出简单易用的代码?

所以这是不是证明了系统项目都采用强类型语言进行编写比较好呢?(开玩笑的)

返回