huayandong

跑课项目重构;

#获取课程的听力时长和短句数
##接口
遍历文件夹下所有的excel,获得课的短句数,视频的长度,音频的长度:
访问例子:192.168.0.111:8090/single/config/number
访问方式:GET
访问参数:无
根据数据表中的课程id,来获得课中的短句数和听力时长:
访问例子:192.168.0.111:8090/single/config/number/two
访问方式:GET
访问参数:无
通过excel的id得到课中"单词释意"的值
访问例子:192.168.0.111:8090/single//run/words/{lessonId}
访问方式:GET
访问参数:lessonId 课程的id,String类型
1.短句数是excel表中“图片英文”的字段数:在解析excel时,遍历excel中的每一个sheet页和每一个sheet页下面的所有包含“图片英文”的字段,为了避免计算重复的短句数,在得到所有的“图片英文”的值后,将这些值存放到HashSet集合中,最后得到集合的大小为该课程的短句数;
//获取excel中短句数的方法
public static Integer count(Map<String, Table<Integer, String, String>> map, File file, String catalogId) {
int count = 0;
Set<String> djSet = new HashSet<>();
for (Map.Entry<String, Table<Integer, String, String>> entity : map.entrySet()) {
Table<Integer, String, String> table = entity.getValue();
Map<Integer, String> djColumn = table.column("图片英文");
for (Map.Entry<Integer, String> djEntity : djColumn.entrySet()) {
String djName = djEntity.getValue();
djSet.add(djName);
}
}
count = djSet.size();
if (count == 0) {
logger.error("课中没有短句! 课的名字为:[{}], 课的id是:[{}], 课中短句书为:[{}]", file.getName(), catalogId, count);
} else {
logger.info("课中有短句!课的名字为:[{}], 课的id是:[{}], 课中短句书为:[{}]", file.getName(), catalogId, count);
}
return count;
}
2.课程中的听力时长为excel表中视频的长度+音频的长度:在VideoDuration类countVideoDuration方法中,遍历excel表中所有的sheet页和sheet页下所有的包含“视频”的字段,得到map集合,遍历该集合,得到每个视频的名称,通过调用rms资源管理平台提供的接口,定义FileEntity对象,给对象赋值资源类型restType、资源搜索目录dataMayKey、资源名称resName、资源前缀prefix,调用接口中的downLoadResource方法,得到FileEntity对象,并获得视频时长,将所有的视频时长加在一起,就可以得到该excel的视频总时长;
//跑书的时候计算出书中所有音频的时长
public static Long countAudioDuration(Map<String, Table<Integer, String, String>> bookMap) {
Long totalDuration = 0L;
for (Map.Entry<String, Table<Integer, String, String>> entity : bookMap.entrySet()) {
Table<Integer, String, String> table = entity.getValue(); //遍历sheet页,得到每页的table
Set<String> columnKeySet = table.columnKeySet(); //table中,获得列的集合
for (String column : columnKeySet) { //遍历列的集合,得到每一个列的名称
if (column.contains("音频")) {
Map<Integer, String> columnMap = table.column(column);
for (Map.Entry<Integer, String> entries : columnMap.entrySet()) {
String audioName = entries.getValue();
FileEntity fileEntity = new FileEntity(); //创建FileEntity对象
fileEntity.setResType("audio"); //设置资源类型
fileEntity.setDataMapKey("default"); //设置资源映射
fileEntity.setResName(audioName); //设置资源前缀
fileEntity.setPrefix("audio"); // 设置前缀
FileEntity audioFileEntity = rmiClient.downLoadResource(fileEntity);
if (audioFileEntity != null) {
String audioDuration = audioFileEntity.getDuration();
//计算音频的总时长
if (StringUtils.isNotBlank(audioDuration)) {
Long audioLong = Long.valueOf(audioDuration);
totalDuration += audioLong;
} else {
totalDuration += 0L;
}
} else {
logger.error("资源管理平台上没有找到[{}]的音频", audioName);
}
}
}
}
}
logger.info("音频总时长为:[{}]", totalDuration);
return totalDuration;
}
3.类似,在AudioDuration类中countAudioDuration方法,在遍历excel表的时候,获得表中所有sheet页中包含“音频”字段的map集合,遍历集合,获得每个音频的name值,调用rms资源管理项目中提供的接口,获得每个音频的时长,将所有的音频时长统计加在一起,就可以得到该excel的音频总时长;
4.将得到的音频总时长和视频总时长相加,得到的就是该课的听力时长;
5.配置第二数据源,将课程的听力时长和课程的短句数保存到数据库中,在向数据库保存的操作中,由于需要保存的数据库与jpa框架配置的数据库不是同一个数据库,在向数据库保存的时需要使用第二数据源完成数据的更新和保存操作;在cn.boxfish.onekey.count.jpa.DataSourceConfig类中,通过注解的方式定义第一数据源“primaryDataSource”和第二数据源“secondaryDataSource”,在spring boot的配置文件中,指定两个数据源连接不同的数据库;
//定义多数据源
@Configuration
@EnableConfigurationProperties
@EnableAutoConfiguration
public class DataSourceConfig {
@Bean(name = "primaryDataSource")
@Primary
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean(name = "secondaryDataSource")
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return DataSourceBuilder.create().build();
}
}
//在配置文件中,为不同的数据源指定不同的数据库
spring:
profiles: development
datasource:
primary:
url: jdbc:mysql://192.168.0.100:3306/bebase?useUnicode=true&characterEncoding=utf8
username: bebase
password: boxfish
secondary:
url: jdbc:mysql://192.168.0.100:3306/statistic?useUnicode=true&characterEncoding=utf8
username: bebase
password: boxfish
在CountDao类中完成对数据的更新和保存,在使用之前,先指定使用第二数据源,在对数据的操作的时候,不能再使用jpa框架提供的方法,在对数据的保存中,采用的DBUtils完成对数据库的更新和保存;
//第二数据源的使用
@Named
public class CountDao {
QueryRunner queryRunner = new QueryRunner();
private final Logger logger = LoggerFactory.getLogger(CountDao.class);
@Inject
@Resource(name = "secondaryDataSource")
DataSource dataSource;
//更新数据库中的听力时长和短句数
public void updateLessonVersion(Integer clauseSize, Long listeningDuration, String lessonId) {
try (Connection conn = dataSource.getConnection()) {
String sql = "UPDATE nlp_lesson_version lv SET lv.phrase_count = ? ,lv.listening_duration = ? WHERE lv.lesson_id = ? ";
queryRunner.update(conn, sql, clauseSize, listeningDuration, lessonId);
} catch (SQLException e) {
logger.error("更新数据库出现异常:[{}}", e);
e.printStackTrace();
}
}
//根据课程ID查询对象
public List<LessonVersion> findEntityByLessonId(String lessonId) {
try (Connection conn = dataSource.getConnection()) {
List<LessonVersion> list = new ArrayList<>();
String sql = "SELECT id ,lesson_id AS lessonId ,phrase_count AS clauseCount ,listening_duration listeningDuration FROM nlp_lesson_version WHERE lesson_id = ? ";
Map<Integer, LessonVersion> result = queryRunner.query(conn, sql, new BeanMapHandler<Integer, LessonVersion>(LessonVersion.class), lessonId);
for (Map.Entry<Integer, LessonVersion> entity : result.entrySet()) {
LessonVersion lessonVersion = entity.getValue();
if (lessonVersion != null) {
list.add(lessonVersion);
}
}
return list;
} catch (SQLException e) {
logger.error("查询数据库出现异常:[{}}", e);
e.printStackTrace();
}
return null;
}
}
6.在跑课的代码中,每跑一课需要计算出课程的短句数和听力时长,在跑课代码中,调用获得短句数、音频时长和视频时长的方法,将获得的短句数和听力时长传入到nlp接口中,增加nlp接口中的参数,调用nlp接口中的方法,完成数据的保存操作;
//跑课时获得听力时长的短句书,调用nlp保存
//获得课的短句数
Integer phraseCount = CalculCount.count(workbook, excel.toFile(), index.getId());
//获得课中音频时长
Long audioDuration = AudioDuration.countAudioDuration(workbook);
//获得课中视频时长
Long videoDuration = VideoDuration.countVideoDuration(workbook);
//听力时长
Long listeningDuration = audioDuration + videoDuration;
// 校验课程是否已经跑目录
checkCourse(index.getId());
log.info("NLP数据获取-学生版: id:[{}]", index.getId());
try {
nlpHandler.saveNlpArticle(index.getId(), "student",phraseCount,listeningDuration);
} catch (Exception e) {
log.error("NLP数据获取出错-学生版: id:[{}],错误:{{}}", index.getId(), e);
}
//nlp接口中增加听力时长和短句数的参数
public void saveNlpArticle(String lessonId, String type,Integer phraseCount,Long listeningDuration){
restTemplate.postForEntity(
nlpUrl.replace("::lessonId",lessonId)
.replace("::type",type)
.replace("::phraseCount",String.valueOf(phraseCount))
.replace("::listeningDuration",String.valueOf(listeningDuration)),
null,Object.class);
}
7.提供"单词释义"接口,根据课程id得到课程中所有"单词释义"的值,在类cn.boxfish.onekey.count.CountController方法下
//获得单词释义的所有值
public static Set<String> getValues(Map<String, Table<Integer, String, String>> map, File file) {
Set<String> valueSet = new HashSet<>();
for (Map.Entry<String, Table<Integer, String, String>> entity : map.entrySet()) {
Table<Integer, String, String> table = entity.getValue();
Map<Integer, String> column = table.column("单词释义");
//判断excel中是否有"单词释义"
if (column.isEmpty()) {
logger.error("课[{}][{}]sheet页没有单词释义!", file.getName(),entity.getKey());
return valueSet;
}
for (Map.Entry<Integer, String> entry : column.entrySet()) {
valueSet.add(entry.getValue());
}
logger.info("课[{}][{}]sheet页有单词释义!", file.getName(),entity.getKey());
}
return valueSet;
}
... ...
#逻辑拆解
1.在跑课项目的老师版和学生版模块中,之前的代码中存在有大量的逻辑判断部分,如在学生版的JobHandler.java和老师版的ExerciseManager.java两个类中,存在大量的if-else逻辑判断,在代码重构的初期工作中,将JobHandler与ExerciseManager中的if-esle逻辑判断拆分出来,将每个逻辑判断的条件体封装到类中,并存放在项目的cn.boxfish.onekey.helper文件夹中,为了区分老师模块与学生模块,将老师模块逻辑判断的类存放到teacher文件夹下,将学生模块逻辑判断拆解的类存放到student文件夹下;
//老师模块初步拆解后的代码:
for (Map.Entry<String, Table<Integer, String, String>> entry : map.entrySet()) {
String key = entry.getKey().trim();
Table<Integer, String, String> table = entry.getValue();
if (key.equals("图片")) {
TPTeachHelper.build(table,key,exercises,logger);
} else if (key.equals("文字")) {
WZTeachHelper.build(table,key,exercises);
} else if (key.equals("释义")) {
SYTeachHelper.build(table,key,exercises);
} else if (key.equals("阅读")) {
YDTeachHelper.build(table,key,exercises);
} else if (key.equals("综合学习")) {
ZHXXTeachHelper.build(table,key,exercises,isPriExtend,priExtendMap,extendInfo);
} else if (key.equals("同义词")) {
TYCTeachHelper.build(table,key,exercises);
} else if (key.equals("托福口语")) {
TFKYTeachHelper.build(table,key,exercises);
} else if (key.equals("托福写作")) {
TFXZTeachHelper.build(table,key,exercises);
} else {
logger.debug("key : " + key);
}
}
2.将逻辑判断移动到类中;在代码的执行中,会判断条件是否成立,然后进入到类中执行条件体,在重构中需要将条件判断移动到条件体中;使用链式模式,在链中传入逻辑判断的条件。当条件成立的时候就会执行该条件体;
//判断条件放到类中,变为链式模式
public class TFKYTeachHelper extends SheetCommand {
public TFKYTeachHelper() {
setSheetName("托福口语");
}
public void doBuild(Context context) {
for (Integer r : context.getTable().rowKeySet()) {
Map<String, String> row = context.getTable().row(r);
if (StringUtils.isNotBlank(row.get("题目"))
|| StringUtils.isNotBlank(row.get("主题句"))) {
OralCover oral = new OralCover(row);
ChecksumUtils.checksum(oral, row);
KnowledgeUtils.setCode(oral, context.getKey(), row);
// AuditUtils.auditKey(oral, row);
context.getExercises().add(oral);
} else if (StringUtils.isNotBlank(row.get("核心词"))
|| StringUtils.isNotBlank(row.get("简单句"))) {
OralContext oral = new OralContext(row);
ChecksumUtils.checksum(oral, row);
KnowledgeUtils.setCode(oral, context.getKey(), row);
// AuditUtils.auditKey(oral, row);
context.getExercises().add(oral);
}
}
}
}
在进行逻辑拆分的时候,不同的逻辑拆分需要判断是否需要执行;前期采用的方法是使用标识符判断的方式,在上下文中设置Flag值默认为false,如果需要执行,在进行逻辑拆分的时候,通过判断falg的值来判断是都需要执行这个逻辑模块;在后期的代码重构中,使用chain链的方式来判断该逻辑模块是否需要执行;
3.拆解后,每个逻辑模块上任然有大量的逻辑判断,按照逻辑拆解的思想,每个类按照模块细分还能进行细化拆解;
4.将拆解完成的逻辑分支合并到develop分支;
#按照跑课模板对每个模块进行拆解
1.完成对跑课逻辑的拆解后,拆解后的逻辑中任然存在一个或多个跑课模块的判断,需要对跑课中的逻辑进行二次拆解,将老师版和学生版第一次拆解得到的helper下的所有类再次进行拆解;将老师版的模块拆后得到的类放在cn.boxfish.onekey.helper.teacher.helper文件夹下,将学生版的模块拆后得到的类放在cn.boxfish.onekey.helper.student.helper文件夹下;
//学生版口语考试模块进一步拆解
public class OralTestHelper {
private static final String SHEET_NAME = "口语考试";
public static void build(Context context) {
if (!context.isFlag()) {
if (context.getKey().equals(SHEET_NAME)) {
doBuild(context);
context.setFlag(true);
}
}
}
//口语考试
public static void doBuild(Context context) {
for (Integer row : context.getTable().rowKeySet()) {
Map<String, String> map = context.getTable().row(row);
Context oralContext = new Context();
oralContext.setMap(map);
oralContext.setContext(context);
SpHasShortDialogueHelper.build(oralContext);
SpHasReadAloudHelper.build(oralContext);
SpHasCompositionHelper.build(oralContext);
SpHasCoverHelper.build(oralContext);
SpHasNot.build(oralContext);
}
}
}
2.在对模块进行二次拆解的时候,需要判断每个模块是否需要进入执行,在初期的时候,代码的重构需要使用设置标识符flag,初始值为false;对于互斥的逻辑判断,在执行该模块的代码之前先判断是否flag的值是否为false,如果为false就执行该模块的逻辑,执行完成后设置flag为true;在后来的代码重构中,拆解的时候让该类继承ModelCommand类,使用无参构造的方式调用ModelCommand中的方法,使用闭包的方式将上下文对象传入进去,得到的结果是该类的执行条件的结果值;
//对学生模块的“APP学生版”中模块拆解,其中的一个模块
public class APPIsCoverNew extends ModelCommand {
public APPIsCoverNew() {
setCallback(context -> CoverNew.isCoverNew(context.getMap()));
}
public void doBuild(Context context) {
CoverNew obj = new CoverNew(context.getIndex(), context.getMap());
ChecksumUtils.checksum(obj, context.getMap());
KnowledgeUtils.setCode(obj, context.getSheetName(), context.getMap());
context.getIndex().addCourses(obj);
}
}
3.在完成二次拆解后,第一次拆解中各个逻辑模块中就会出现大量的重读代码,将相同的代码抽取到sheetCommand中,将链作为SheetCommand的属性,提取到sheetCommand中;
//拆解完成APP学生版模块
public class StudentHelper extends SheetCommand {
public StudentHelper() {
setSheetName("APP学生版");
addCommand(new APPHasGrammar());
addCommand(new APPIsGrammarSum());
addCommand(new APPIsBigCover());
addCommand(new APPIsAppreciation());
addCommand(new APPIsEquals());
addCommand(new APPIsExpress());
addCommand(new APPIsOpenQuestion());
addCommand(new APPIsNotBlank());
addCommand(new APPIsSceneVideo());
addCommand(new APPIsNotBlankVideo());
addCommand(new APPIsNotBlankAudio());
addCommand(new APPIsNotBlankWZ());
addCommand(new APPIsNotBlankTPO());
addCommand(new APPIsCloseTest());
addCommand(new APPIsScene());
addCommand(new APPIsCoverNew());
addCommand(new APPIsLoadingPage());
addCommand(new APPIsNothing());
}
}
#将变量抽取到上下文对象中
1.在逻辑的拆分的时候,每个逻辑模块都传入了多个参数,为了统一各个模块,将每个类中方法的参数传入到上下文对象中;在项目的cn.boxfish.onekey.helper.context文件夹下创建上下文类Context,将老师模块与学生模块中方法的参数设置为上下文属性,在传递参数的时候,只需要初始化Context对象然后将参数存入上下文对象中,在调用模块中的方法的时候,参数只需要传入context对象;
2.在设置上下文对象的属性时,需要注意,不是所有的参数都需要存放在上下文对象中,有些参数是需要从另一个参数中得到的,可以直接从另一个参数中获取即可;
3.修改初始化上下文的位置,上下文对象在一次跑课中,只使用一次,在初始化的时候,应该是在跑课开始的时候创建上下文对象,不能在全局位置创建上下文对象;
#调整跑课代码顺序
1.定义抽象类cn.boxfish.single.export.AbstractExportService,实现ApplicationEventPublisherAware接口,将学生模块与老师模块公共的功能代码移动到该类中;初始化赋值、提供excel解析完成的主数据、扩展数据等;
2.整理学生版跑课与老师版跑课代码:将学生版跑课代码中初始化链和初始化上下文的代码放在cn.boxfish.single.export.ExportStudentService类中;将老师版中初始化链和上下文的代码放在ExportTeacherService中,将具体的细节交给子类实现;
3.整理nlp相关的代码:将调用nlp接口保存数据的方法和获得听力时长和短句数的方法保存到cn.boxfish.onekey.nlp.NlpHandler中,给跑课代码调用
/
/与nlp相关的代码放在NlpHandler类中
@Value("${nlp.article.url}")
private String nlpUrl;
private AsyncRestTemplate restTemplate = new AsyncRestTemplate();
public void saveNlpArticle(String lessonId, String type, Integer phraseCount, Long listeningDuration) {
restTemplate.postForEntity(
nlpUrl.replace("::lessonId", lessonId)
.replace("::type", type)
.replace("::phraseCount", String.valueOf(phraseCount))
.replace("::listeningDuration", String.valueOf(listeningDuration)),
null, Object.class);
}
public void handle(Context context) {
Map<String, Table<Integer, String, String>> sheetMap = context.getSheetMap();
File file = context.getFile().toFile();
String projectId = context.getProjectId();
String type = context.getType();
//获得课的短句数
Integer phraseCount = CalculCount.count(sheetMap, file, projectId);
//获得课中音频时长
Long audioDuration = AudioDuration.countAudioDuration(sheetMap);
//获得课中视频时长
Long videoDuration = VideoDuration.countVideoDuration(sheetMap);
//听力时长
Long listeningDuration = audioDuration + videoDuration;
logger.info("NLP数据获取-{}: id:[{}]", type, projectId);
try {
this.saveNlpArticle(projectId, type, phraseCount, listeningDuration);
} catch (Exception e) {
logger.error("NLP数据获取出错-{}: id:[{}],错误:{{}}", type, projectId, e);
}
}
... ...