⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7416267311848816675 「简简单单」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

传统查询逻辑的问题

在代码评审的过程中,发现有些查询逻辑可读性太差,但是一时又没找到好的方法。复杂的查询往往需要查询大量的信息。例如下面的例子,获取测验的正确率和参与率。还有测验每道题目分别的正确率和参与率。

老师可以在课堂 (Classroom) 上发起测验 (Exam),一个测验对应多道题目 (Question),学生 (Student) 可以对题目进行回答 (StudentQuestionAnswer)

public class ExamInfoQuestionQueryService {

private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;

/**
* 获取测验和题目的正确率和参与率
*
*/
public ExamDto getQuestionByExamId(String examId) {

Exam exam = examMapper.selectById(examId);

// 获取所有题目
List<Question> questions = questionMapper.selectByExamId(examId);

List<String> examQuestionIds = questions.stream().map(Question::getId).collect(Collectors.toList());

// 获取所有学生的答题情况
List<StudentQuestionAnswer> studentQuestionAnswers = studentQuestionAnswerMapper.selectByExamQuestionIds(examQuestionIds);

Map<String, List<StudentQuestionAnswer>> questionMap = studentQuestionAnswers.stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));

// 获取所有学生, 为了获取学生数
List<Student> students = studentMapper.selectByClassroom(exam.getClassroomId());

ExamDto examDto = new ExamDto();
// 计算测验正确率
examDto.setCorrectRate(calculateCorrectCount(studentQuestionAnswers));
// 计算测验参与度
examDto.setSubmittedRate(calculateSubmittedRate(studentQuestionAnswers,students));

List<QuestionDto> questionDtos = questions.stream().map(question -> {

QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
List<StudentQuestionAnswer> questionAnswers = questionMap.getOrDefault(question.getId(), Collections.emptyList());

questionDto.setCorrectRate(calculateCorrectCount(questionAnswers));
// 计算题目参与率
questionDto.setSubmittedRate(calculateSubmittedRate(questionAnswers, students));

return questionDto;

}).collect(Collectors.toList());

examDto.setQuestions(questionDtos);
return examDto;
}

private double calculateSubmittedRate(List<StudentQuestionAnswer> questionAnswers, List<Student> students) {
return 1.0 * questionAnswers.size() / students.size();
}

private long calculateCorrectCount(List<StudentQuestionAnswer> questionAnswers) {
long correctCount = questionAnswers.stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();

return correctCount / questionAnswers.size();
}
}

上面方法的特点是需要聚合不同的数据。为了性能考虑使用了大量的 Map, 导致可维护性下降。可维护性差体现在2点:

1.变量和引用离得太远了。因为所有数据都要在组装前里面准备好,所以无法用到的时候再去获取。

2.依靠中间变量传递,难以复用。查询对象的依赖关系如下,因为相互依赖,为了性能考虑会提前算出值传进去。

例如测验的正确率和参与度,这两个指标表面没什么联系,因为底层查询公用了 studentQuestionAnswer,所以 studentQuestionAnswer 需要提前计算了,作为参数分别传到对应的计算方法里面。

如果涉及到更多的复用变量,方法将有更多的参数。理想情况下计算测验正确率,传一个 examId 就可以了,如果是为了复用,定义成下面这种会更好一些,因为只要知道 examId 就足以可以计算出测验的正确率,这个定义复用性是最好的,虽然性能可能也是最差的,因为很多中间变量又要重新计算。

private double calculateExamCorrectRate(String examId);

更优雅的编写方式

我认为更优雅的实现方式就是去掉临时变量。在<重构> 里面也有提到通过方法替代变量的形式,目标就是让逻辑更加直观,暂时不考虑性能问题。理想的实现方式如下:

public class ExamInfoQuestionQueryService2 {

private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;

/**
* 获取测验和题目的正确率和参与率
*/
public ExamDto getQuestionByExamId(String examId) {

ExamDto examDto = new ExamDto();

examDto.setCorrectRate(calculateExamCorrectRate(examId));

examDto.setSubmittedRate(calculateExamSubmittedRate(examId));

examDto.setQuestions(getExamQuestions(examId).stream().map(question -> {

QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
questionDto.setCorrectRate(calculateQuestionCorrectRate(examId, question));
// 计算题目参与率
questionDto.setSubmittedRate(calculateQuestionSubmittedRate(examId, question));
return questionDto;
}).collect(Collectors.toList()));
return examDto;
}

private double calculateExamSubmittedRate(String examId) {
return 1.0 * getStudentQuestionAnswers(examId).size() / getStudents(examId).size();
}

private long calculateExamCorrectRate(String examId) {
return getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size();
}

private long getCorrectStudentAnswerCount(String examId) {
return getStudentQuestionAnswers(examId).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}

private List<Student> getStudents(String examId) {
return studentMapper.selectByClassroom(getExam(examId).getClassroomId());
}

private Exam getExam(String examId) {
return examMapper.selectById(examId);
}

private List<StudentQuestionAnswer> getStudentQuestionAnswers(String examId) {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestions(examId)
.stream()
.map(Question::getId)
.collect(Collectors.toList()));
}

private double calculateQuestionSubmittedRate(String examId, Question question) {
return 1.0 * getStudentQuestionAnswerPerQuestion(examId).apply(question).size() / getStudents(examId).size();
}


private long calculateQuestionCorrectRate(String examId, Question question) {
return getCorrectCountPerQuestion(examId, question) / getAnswerCount(examId, question);
}

private int getAnswerCount(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).size();
}

private long getCorrectCountPerQuestion(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}

private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {

Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream()
.collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));

return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}


private List<Question> getExamQuestions(String examId) {
return questionMapper.selectByExamId(examId);
}

}

去掉临时变量之后前面提到的2个问题都得以解决,不存在临时变量,需要的时候用get方法替代即可。通过函数组合的方式,没有了中间变量,方法的定义复用性更好。例如增加一个单独查询测验正确率的方法,可以直接调用 calculateCorrectRate(examId) 即可,不用再传一堆变量。

虽然方法多了很多,但每个方法都单一职责,实际上用这种方式注释都可以不用加了,因为每个方法定义都表明了方法的作用。因为方法会比较细,因此针对复杂的查询最好还是单独一个类。

如何解决性能问题

方法重复调用会带来性能问题,如何解决性能问题? 我们观察calculateExamSubmittedRate 和 calculateExamCorrectRate 共同调用了 getStudentQuestionAnswers 方法,函数的入参其实都是一样的。又因为查询是没有副作用的,相同的参数必定返回相同的结果,因此可以缓存下来。具体实现可以加本地缓存,通过线程变量来把函数的值存下来。解决了性能问题。最后再讨论技术实现方案。

那如果参数会变怎么办,例如 getStudentQuestionAnswerPerQuestion 里面的 question。不先通过批量查询的方式就算加缓存也要 n 次查询。这时可以从函数式编程寻找灵感。

查询函数式

用函数替代变量,看到了函数式的影子。在函数式编程里面,函数有几个特点

  • 函数组合: 多个函数可以合并成一个函数。
  • 引用透明: 函数的返回值只取决于输入值,也就是没有副作用。
  • 函数缓存: 函数是闭包,内部可以存数据。
  • 高阶函数: 函数可以作为参数或者返回值传递。
  • 柯里化: 将接收多个参数的函数转换成一系列嵌套的单个参数的函数。

函数组合和透明引用是加缓存的基础,如果没有透明引用,是加不了缓存的。

getStudentQuestionAnswerPerQuestion 其实是传了2 个参数,一个是 examId,一个是 question。能否把函数柯里化,改成 getStudentQuestionAnswerPerQuestion(examId)(question) 呢? 好处就是通过 getStudentQuestionAnswerPerQuestion(examId) 提前把所有 question 都查出来,再利用这个函数去筛选出单个 question 的值。

/**
* 函数柯里化
*/
private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {

Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream()
.collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));

return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}


private long getCorrectCountPerQuestion(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}

getStudentQuestionAnswerPerQuestion 计算出所有question 的值,并封装成一个函数返回。因为是闭包,因此 studentQuestionAnswerMap 会被缓存在这个函数里面,当要用到的时候,只要调用 getStudentQuestionAnswerPerQuestion (可以对这个函数的返回值进行缓存 ) 获取函数,再通过 apply 把question 传递进去即可,实际调用就是通过 Map 获取值,并不会每次都去计算 Map 的值,从而解决多次查询的问题。

上层方法如法炮制,最终代码如下

public class ExamInfoQuestionQueryService3 {

private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;

/**
* 获取测验和题目的正确率和参与率
*/
public ExamDto getQuestionByExamId(String examId) {

ExamDto examDto = new ExamDto();

examDto.setCorrectRate(calculateExamCorrectRate(examId));

examDto.setSubmittedRate(calculateExamSubmittedRate(examId));

examDto.setQuestions(getExamQuestions(examId).stream().map(question -> {

QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
questionDto.setCorrectRate(calculateQuestionCorrectRate(examId).apply(question));
// 计算题目参与率
questionDto.setSubmittedRate(calculateQuestionSubmittedRate(examId).apply(question));

return questionDto;
}).collect(Collectors.toList()));
return examDto;
}

private double calculateExamSubmittedRate(String examId) {
return 1.0 * getStudentQuestionAnswers(examId).size() / getStudents(examId).size();
}

private long calculateExamCorrectRate(String examId) {
return getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size();
}

private long getCorrectStudentAnswerCount(String examId) {
return getStudentQuestionAnswers(examId).stream().filter(StudentQuestionAnswer::isCorrect).count();
}

private List<Student> getStudents(String examId) {
return studentMapper.selectByClassroom(getExam(examId).getClassroomId());
}

private Exam getExam(String examId) {
return examMapper.selectById(examId);
}

private List<StudentQuestionAnswer> getStudentQuestionAnswers(String examId) {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestionIds(getExamQuestions(examId)));
}

private static List<String> getExamQuestionIds(List<Question> questions) {
return questions.stream().map(Question::getId).collect(Collectors.toList());
}

private Function<Question, Double> calculateQuestionSubmittedRate(String examId) {
return question -> 1.0 * getStudentQuestionAnswerPerQuestion(examId).apply(question).size() / getStudents(examId).size();
}

private Function<Question, Double> calculateQuestionCorrectRate(String examId) {
return question -> 1.0 * getCorrectCountPerQuestion(examId).apply(question) / getAnswerCount(examId).apply(question);
}

private Function<Question, Integer> getAnswerCount(String examId) {
return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).size();

}

private Function<Question, Long> getCorrectCountPerQuestion(String examId) {
return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).stream().filter(StudentQuestionAnswer::isCorrect).count();
}

private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {

Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));

return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}

private List<Question> getExamQuestions(String examId) {
return questionMapper.selectByExamId(examId);
}

}

使用 kotlin 的话效果更好,因为 kotlin 原生就支持函数式,不像 java 还需要有个 Function。

class ExamInfoQuestionQueryService4 {

private lateinit var questionMapper: ExamQuestionMapper
private lateinit var studentQuestionAnswerMapper: StudentQuestionAnswerMapper
private lateinit var examMapper: ExamMapper
private lateinit var studentMapper: StudentMapper

/**
* 获取测验和题目的正确率和参与率
*/
fun getQuestionByExamId(examId: String): ExamDto {

return ExamDto(
correctRate = calculateExamCorrectRate(examId),
submittedRate = calculateExamSubmittedRate(examId),
questions = getExamQuestions(examId).map { question: Question ->
QuestionDto(
// 计算题目正确率
correctRate = calculateQuestionCorrectRate(examId)(question),
// 计算题目参与率
submittedRate = calculateQuestionSubmittedRate(examId)(question)
)
}
)
}

private fun calculateExamSubmittedRate(examId: String): Double {
return 1.0 * getStudentQuestionAnswers(examId).size / getStudents(examId).size
}

private fun calculateExamCorrectRate(examId: String): Double {
return 1.0 * getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size
}

private fun getCorrectStudentAnswerCount(examId: String): Int {
return getStudentQuestionAnswers(examId).count { obj: StudentQuestionAnswer -> obj.isCorrect }
}

private fun getStudents(examId: String): List<Student> {
return studentMapper.selectByClassroom(getExam(examId).classroomId)
}

private fun getExam(examId: String): Exam {
return examMapper.selectById(examId)
}

private fun getStudentQuestionAnswers(examId: String): List<StudentQuestionAnswer> {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestions(examId).map(Question::getId))
}

private fun calculateQuestionSubmittedRate(examId: String): (Question) -> Double {
return { question: Question ->
1.0 * getStudentQuestionAnswerPerQuestion(examId)(question).size / getStudents(examId).size
}
}

private fun calculateQuestionCorrectRate(examId: String): (Question) -> Double {
return { question: Question ->
1.0 * getCorrectCountPerQuestion(examId)(question) / getAnswerCount(examId)(question)
}
}

private fun getAnswerCount(examId: String): (Question) -> Int {
return { question: Question ->
getStudentQuestionAnswerPerQuestion(examId)(question).size
}
}

private fun getCorrectCountPerQuestion(examId: String): (Question) -> Int {
return { question: Question ->
getStudentQuestionAnswerPerQuestion(examId)(question)
.count { obj: StudentQuestionAnswer -> obj.isCorrect }
}
}

private fun getStudentQuestionAnswerPerQuestion(examId: String): (Question) -> List<StudentQuestionAnswer> {
val studentQuestionAnswerMap = getStudentQuestionAnswers(examId)
.groupBy(StudentQuestionAnswer::getExamQuestionId)

return { question: Question ->
studentQuestionAnswerMap[question.id] ?: emptyList()
}
}

private fun getExamQuestions(examId: String): List<Question> {
return questionMapper.selectByExamId(examId)
}

}

我把上面的实现方式称为函数式查询,利用函数的方式组合和组装查询值。

缓存的实现方式

对方法实现做拦截,且类非接口,最简单的方式就是 cglib 了,cglib 可以通过继承对源方法进行拦截。

通过 BeanPostProcessor 对 bean 进行代理

@Order(Ordered.HIGHEST_PRECEDENCE)
class QueryFunctionalBeanFactory : BeanPostProcessor {

/**
* 此方法可以在其他 bean 注入这个 bean 之前对这个 bean 进行替换
*/
override fun postProcessBeforeInitialization(bean: Any?, beanName: String?): Any? {
if (bean == null) {
return null
}
val targetClass = AopUtils.getTargetClass(bean)

// QueryFunctional 是标识要缓存的类
if (targetClass.isAnnotationPresent(QueryFunctional::class.java)) {
val enhancer = Enhancer()
enhancer.setSuperclass(targetClass)
enhancer.setCallback(object : MethodInterceptor {
override fun intercept(obj: Any, method: Method, args: Array<out Any>, methodProxy: MethodProxy): Any? {
return QueryFunctionalCache.cache(method) {
return@cache methodProxy.invokeSuper(obj, args)
}
}
})
// 创建 cglib 代理
val newBean = enhancer.create()

// 把代理对象设进代理类
for (fileId in bean.javaClass.fields) {
fileId.isAccessible = true
fileId.set(newBean, fileId.get(bean))
}
return newBean
}
return bean

}

override fun postProcessAfterInitialization(bean: Any?, beanName: String?): Any? {
return bean
}
}

缓存的逻辑如下

class QueryFunctionalCache {

companion object {

/**
* 线程缓存缓存方法的返回值
*/
private var cacheDatas = object : ThreadLocal<MutableMap<Method, Any?>>() {
override fun initialValue(): MutableMap<Method,Any?> {
return mutableMapOf()
}
}

fun <R : Any?> cache(method: Method, any: () -> R): R {

var cacheMethodReturnValueMap = cacheDatas.get()

// 因为入参只有一个, 且不会变, 因此只要缓存一份就好
if (cacheMethodReturnValueMap.containsKey(method)) {
return cacheMethodReturnValueMap.get(method) as R
}

val methodReturnValue = any()

cacheMethodReturnValueMap = cacheMethodReturnValueMap ?: mutableMapOf()

cacheMethodReturnValueMap.putAll(mutableMapOf(Pair(method, methodReturnValue)))

return methodReturnValue
}
}
}

上面的代码有个缺陷,同时也函数式查询的一个不足,就是入参必须不变。上面的例子大部分方法都是 examId,每次查询都是不变的,因此每次查询缓存只有一份,加缓存并不需要把入参作为key。只要每次进入查询入口方法的时候缓存,退出的时候删除就可以了。但这也限制了提取的方法的入参都要一样的。

当入口方法参数多的时候,可以提取成一个 dto,后面所有方法都以这一个 dto 为参数进行设计,这样方法更容易组装,因为入参都是同一个 dto,因此想调用什么方法都不用关心参数的问题。但这样函数的复用性就变得很差了,因为 dto 一般不同的查询是不同。

解决方法可以针对通用的查询细化参数,不使用 dto,只要参数是固定的,只缓存一份的规则仍然生效,但里面组合的函数也要改成一样的参数。实际使用中单个查询里面的复用是非常多的,不同查询的复用较少。

总计

函数式查询本质是通过函数替代变量,通过缓存和柯里化解决性能问题。虽然上面的代码经过精简不能用,但组件已经完成,有个项目已用上,效果还不错,把大量的 map 都去掉了,提升了可读性,定位问题的速度也更快了。

文章目录
  1. 1. 传统查询逻辑的问题
  2. 2. 更优雅的编写方式
  3. 3. 如何解决性能问题
  4. 4. 查询函数式
  5. 5. 缓存的实现方式
  6. 6. 总计