麻雀虽小,五脏俱全
该项目已部署上线:http://calculator.wushf.top/
并通过Gitee Go
流水线实现持续部署。
需求分析
- 表达式求值
- 支持加减乘除四则运算、支持高精度
- 获取日志
Api文档定义
前后端分离,人不分离
通过Apifox
定义接口细节,协调前后端开发工作。
软件架构设计
Spring-MVC
把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。
Controller
起到不同层面间的组织作用,用于控制应用程序的流程。它处理事件并作出响应。本项目共定义了一个controller
:
CalculateController
:接收/calculate
的请求,返回JSON
Model
由实体Bean
来实现,包括Pojo
、Mapping
和Service
对象,在源代码/pojo
目录下:
/pojo
CalculateLogPojo
:ORM
技术,对象关系映射自数据库表项calculate_log
。CalculateRequestPojo
:DTO
数据传输对象,封装发送到/calculate
的POST
请求。QueryPojo
:DTO
数据传输对象,封装发送到/calculate
的GET
请求。ResultPojo<T>
:DTO
数据传输对象,封装全局的请求返回对象。
/mapping
LogMapper
:持久层实现,负责面向本地文件的日志读写。LocalLogMapper
:持久层实现,负责面向关系型数据MySql
的日志读写。
/service
CalculateService
:计算服务,提供静态方法用于实现表达式求值LogService
:日志服务,用于调用持久层方法,实现日志记录QueryService
:日志服务,用于调用持久层方法,实现日志读取
View
层通过Vue
实现,前后端分离。
Vue-MVVM
Model–view–viewmodel:使用命令Command控制,使用Binding来绑定相关数据
Model
在script
标签下实现。
View
视图层在template
和style
标签下实现。
本项目需求较简单,主要代码在App.vue
中完成。
Vue端详细设计
声名响应式对象
history
:日志列表expression
:输入的表达式res
:运算结果queryFrom
:查询操作的query
参数,取值file
或sql
,表示从文件中读或数据库中读queryPage
:查询操作的query
参数,值类型为数字,表示查询第几页,默认为0
querySize
:查询操作的query
参数,值类型为数字,每页的大小,默认为10
查询历史记录
该操作的query
参数从全局的响应式对象中获取。
input
标签可能会将用户输入的数据类型改为string
,需要判断是否为数字,如果类型是字符串,通过正则表达式判断是否为整数字符串。
const getHistory = () => {
if (queryFrom.value != "sql" && queryFrom.value != 'file') {
Notification.error('sql or file');
return;
} else if ((typeof queryPage.value === 'number' || /^\d+$/.test(queryPage.value)) && queryPage.value % 1 === 0 && queryPage.value >= 0
&& (typeof querySize.value === 'number' || /^\d+$/.test(querySize.value)) && querySize.value % 1 === 0 && querySize.value > 0) {
axios({
method: "get",
url: baseUrl + "/calculate",
params: {
from: queryFrom.value,
page: queryPage.value,
size: querySize.value
}
}).then(({data}) => {
history.splice(0, history.length, ...data?.data);
})
} else {
Notification.error('别搞');
}
}
当用户输入内容非法时,通过arco.design
提供的通知组件,告知用户错误原因。
发送计算请求
需要对用户输入的字符串进行预处理:
- 如果最后一个字符是运算符而非数字,那么递归地删除最后一个运算符字符
const calculate = () => {
expression.value.replace(/([^+\-*v]|^)-/g, '$1+-');
let expressionLength = expression.value.length;
let lastChar = expressionLength > 0 ? expression.value[expressionLength - 1] : '';
let prefixExpression = expressionLength == 0 ? expression.value.substring(0, expression.value.length - 1) : "";
while (operatorSet.indexOf(lastChar) != -1) {
expression.value = prefixExpression;
expressionLength = expression.value.length;
lastChar = expressionLength > 0 ? expression.value[expressionLength - 1] : '';
prefixExpression = expressionLength == 0 ? expression.value.substring(0, expression.value.length - 1) : "";
}
axios({
method: "post",
url: baseUrl + "/calculate",
data: {
expression: expression.value
}
}).then(({data}) => {
res.value = data?.data;
}).catch(() => {
res.value = "Error"
})
}
输入操作
输入操作可能的输入比较多,包括数字、运算符、小数点.
等,需要分别判断。
对输入事件只定义一个input
函数,具体的内容通过函数传参输入,提高可重用性。
通过将编译器指定为typescript
:<script setup lang="ts">
,实现类型判断、语法检查等操作。确保数字字符在输入时,也是string
类型,而非number
。
const pointFlag = ref(false);
const input = (cmd: string) => {
expression.value = String(expression.value);
const expressionLength = expression.value.length;
const lastChar = expressionLength > 0 ? expression.value[expressionLength - 1] : '';
const prefixExpression = expressionLength > 1 ? expression.value.substring(0, expressionLength - 1) : "";
if (res.value == "Calculating") {
return;
} else if (cmd == "C") {
expression.value = res.value;
res.value = "";
} else if (cmd == "<-") {
expression.value = prefixExpression;
} else if (operatorSet.indexOf(cmd) != -1) {
if (cmd != '-' && expressionLength == 0) {
return;
} else if (operatorSet.indexOf(lastChar) != -1 || lastChar == '.') {
expression.value = prefixExpression + cmd;
} else {
expression.value = expression.value + cmd;
pointFlag.value = false;
}
} else if (cmd == '=') {
calculate();
res.value = "Calculating"
pointFlag.value = false;
} else if (cmd == '.') {
if (lastChar != '.' && !pointFlag.value && operatorSet.indexOf(lastChar) == -1) {
expression.value = expression.value + cmd;
pointFlag.value = true;
}
} else {
expression.value = expression.value + cmd;
}
}
当连续输入多个运算符时,需要判断运算符是否合法,比如,连续输入+
和-
时,应将上一步输入的+
修改为-
。
一个数字应当只有一个小数点,即两个运算符之间最多有一个。相关边界条件的处理操作,均在input
中实现。
响应式字体
通过JS
查询文本父元素的宽高,动态赋值,并添加监听器,在每次窗口大小变化时,更新文字大小。
const updateFontSize = () => {
const root = document.documentElement;
const controlDom = document.getElementById("control");
const buttons = controlDom.querySelector("button");
const buttonsHeight = buttons.clientHeight * 0.3;
root.style.setProperty("--font-size-button", buttonsHeight + 'px');
const screenDom = document.getElementById("screen");
const screenHeight = screenDom.clientHeight;
const expressionHeight = screenHeight * 0.2;
root.style.setProperty("--font-size-expression", expressionHeight + 'px');
const resHeight = screenHeight * 0.4;
root.style.setProperty("--font-size-res", resHeight + 'px');
}
window.addEventListener("resize", updateFontSize);
更新文字大小操作,通过对CSS
变量赋值实现,在需要使用响应式字体的地方,将font-size
取值为CSS
变量。
表达式溢出滚动
用户可能输入高精度表达式,溢出窗格时,需允许横向滚动。浏览器默认的横向滚动是shift + wheel
。
通过CSS
和JS
实现鼠标滚轮控制表达式位置。
onMounted(() => {
updateFontSize();
const expressions = document.querySelectorAll('.expression, .res');
expressions.forEach(expression => {
expression.addEventListener("wheel", (event: WheelEvent) => {
if (event.deltaY) {
event.preventDefault();
expression.scrollLeft += Number(event.deltaY);
}
})
})
})
改操作通过对表达式DOM
添加监听器实现,需要在Vue
生命周期在Mounted
时执行,通过组合式的onMounted
方法,添加回调函数。
CSS细节处理
在实现业务逻辑之外,还为按钮、自定义组件添加了动画效果,在hover
、active
等状态下均有不同的显示效果,保证用户视觉体验。
Spring端详细设计
CalculateController
用于响应发送到/calculate
下的GET
和POST
请求。根据请求内容,调用不同的Service
对象:
QueryService
:负责查询本地日志文件或数据库,通过@Autowired
注解自动注入CalculateService
:负责表达式求值,由静态的工具方法实现。
对HTTP请求的参数进行封装:
@RequestBody
用于映射请求体到对象CalculateRequestPojo
中@ModelAttribute
用于映射Query
参数到QueryPojo
中。@RequestParam
会将Query
参数逐个映射到同名的函数入参中,不能直接映射到QueryPojo
中。
@RestController
@RequestMapping("/calculate")
public class CalculateController {
@Autowired
QueryService queryService;
@LogAnnotation
@PostMapping
public ResultPojo<Double> calculate(@RequestBody CalculateRequestPojo calculateRequestPojo) {
double res = CalculateService.calculate(calculateRequestPojo.getExpression());
return ResultPojo.success(res);
}
@GetMapping
public ResultPojo<List<CalculateLogPojo>> query(@ModelAttribute QueryPojo queryPojo) {
if (queryPojo == null) return ResultPojo.success();
return queryService.query(queryPojo);
}
}
表达式求值
计算服务工具类
该工具类内实现了表达式求值的具体算法。
对外暴露calculate
方法供调用,其他private
私有变量和方法用于封装可重用的计算过程,简化代码设计,由类内成员函数调用。
synchronized
关键字保证多线程环境下线程间数据的隔离。
使用Java
自带的BigDecimal
高精度类,简化高精度计算任务的代码设计。
public class CalculateService {
private static final Map<Character, Integer> priority = new HashMap<>();
private static StringBuilder stringBuilder = new StringBuilder();
private static Stack<BigDecimal> num = new Stack<>();
private static Stack<Character> op = new Stack<>();
static {
priority.put('(', 0);
priority.put(')', 0);
priority.put('+', 1);
priority.put('*', 2);
priority.put('/', 2);
priority.put('-', 3);
}
private static void evaluate() {
BigDecimal a = new BigDecimal(0);
BigDecimal b = new BigDecimal(0);
BigDecimal x = new BigDecimal(0);
if (!num.empty()) a = num.pop();
char c = op.pop();
if (c == '-') {
x = b.subtract(a);
} else {
if (!num.empty()) b = num.pop();
if (c == '+') x = b.add(a);
else if (c == '*') x = b.multiply(a);
else if (c == '/') x = b.divide(a, 6, RoundingMode.HALF_UP);
}
num.push(x);
}
private static void flashStringBuilder() {
if (stringBuilder == null || stringBuilder.isEmpty()) return;
BigDecimal currentNum = BigDecimalParser.parse(stringBuilder.toString());
num.push(currentNum);
stringBuilder.setLength(0);
}
public static synchronized double calculate(String expression) {
num.clear();
op.clear();
for (Character c : expression.toCharArray()) {
if (Character.isDigit(c) || c == '.') {
stringBuilder.append(c);
} else {
flashStringBuilder();
if (c == '(') {
op.push(c);
} else if (c == ')') {
while (op.peek() != '(') evaluate();
op.pop();
} else {
while (!op.isEmpty() && priority.get(op.peek()) >= priority.get(c)) evaluate();
op.push(c);
}
}
}
if (!stringBuilder.isEmpty()) flashStringBuilder();
while (!op.isEmpty()) evaluate();
return num.pop().doubleValue();
}
}
并发测试
由于精力有限,只设计了14
条测试样例,涵盖:整数、浮点数、高精度、正负数、有括号等情况下的四则运算。
测试结果
本地日志读写
日志写入本地文件
写入本地日志操作,通过获取当前日期,创建并按时间命名日志文件,如果已经存在同日期的日志文件,那么在该文件后追加新的日志。
以JSON
格式写入文件,方便读出、方便在程序外通过文件阅读工具阅读。
@Override
public ResultPojo<String> save(Object object) {
String date = calendar.get(Calendar.YEAR) + String.format("%02d%02d", calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DAY_OF_MONTH));
File file = new File("./calculator/" + date + ".log");
try {
if (!file.getParentFile().exists()) file.getParentFile().mkdirs();
if (!file.exists()) file.createNewFile();
} catch (IOException e) {
throw new RuntimeException(e);
}
try (FileOutputStream fileOutputStream = new FileOutputStream(file, true);) {
String json = gson.toJson(object);
fileOutputStream.write((json + "\n").getBytes());
} catch (IOException e) {
throw new RuntimeException(e);
}
return ResultPojo.success();
}
从本地文件读出日志
日志按时间分文件存储,如果知道了具体的时间,那么可以通过读取以该日期格式化后命名的日志文件,获取当天的日志信息。
由于日志的追加方式是在文末,因此最新的日志记录是在文件末尾而非开头。
在接口定义上,允许求具体某一天的日志。
如果不指定日期,那么将合法的日期文件,按离当前时间的远近排序,递归地查询离当前时间较近的日志信息。
在接口文档中定义size
参数,为查询的日志数。
- 如果当前日志文件的条数不足
size
,那么就递归查询离改日志文件较近的日期的日志文件。直到累计的日志条数达到size
,或者无日志文件可读。 - 如果当前日志文件的条数超过
size
,那么可以通过滑动窗口的方式读取,滑动窗口的宽度为要查询的记录数。通过滑动窗口可以避免在内存受限条件下,直接读取大文件导致内存不足的潜在问题。
private ResultPojo<List<CalculateLogPojo>> queryFileByDate(QueryPojo queryPojo) {
String filePath = "./calculator/" + queryPojo.getDate() + ".log";
List<CalculateLogPojo> list = new LinkedList<>();
File file = new File(filePath);
if (!file.isFile()) return ResultPojo.success(list);
int page = queryPojo.getPage() == null ? 0 : queryPojo.getPage();
int size = queryPojo.getSize() == null || queryPojo.getSize() == 0 ? 10 : queryPojo.getSize();
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
String line = "";
int sizeLimit = (page + 1) * size;
for (int i = 0; (line = bufferedReader.readLine()) != null; i++) {
CalculateLogPojo calculateLogPojo = gson.fromJson(line, CalculateLogPojo.class);
list.add(calculateLogPojo);
if (list.size() > sizeLimit) list.removeFirst();
}
list = list.subList(0, max(list.size() - page * size, 0));
} catch (IOException e) {
throw new RuntimeException(e);
}
return ResultPojo.success(list.reversed());
}
@Override
public ResultPojo<List<CalculateLogPojo>> query(QueryPojo queryPojo) {
if (queryPojo.getDate() != null) {
return queryFileByDate(queryPojo);
}
File file = new File("./calculator/");
if (!file.isDirectory()) return ResultPojo.success();
List<Integer> logFilesName = new LinkedList<>();
for (File logFile : Objects.requireNonNull(file.listFiles())) {
if (Pattern.matches(regex, logFile.getName()) && logFile.isFile()) {
String stringName = logFile.getName();
stringName = stringName.substring(0, stringName.lastIndexOf("."));
Integer name = Integer.parseInt(stringName);
logFilesName.add(name);
}
}
logFilesName.sort(Comparator.reverseOrder());
List<CalculateLogPojo> list = new LinkedList<>();
for (Integer i : logFilesName) {
queryPojo.setDate(i);
ResultPojo<List<CalculateLogPojo>> resultPojo = queryFileByDate(queryPojo);
int need = queryPojo.getSize() - resultPojo.getData().size();
list.addAll(resultPojo.getData());
if (need > 0) {
queryPojo.setSize(need);
} else {
break;
}
}
return ResultPojo.success(list);
}
数据库日志读写
https://baomidou.com/getting-started/
数据库日志读写通过MyBatis-Plus
实现,通过按照文档要求,定义LogMapper
、LogService
实现插件预定义的CRUD
增删查改操作。
日志写入数据库
在LogService
中,重载了save
方法,在记录日志到数据库的同时记录到本地文件。
从数据库中读出日志
分页查询操作需要按MyBatis-Plus
文档要求配置插件:https://baomidou.com/plugins/pagination/
查询操作的配置信息,在QueryService
中实现:
@Service
public class QueryService {
@Autowired
LogMapper logMapper;
@Autowired
LocalLogMapper localLogMapper;
public ResultPojo<List<CalculateLogPojo>> query(QueryPojo query) {
String from = query.getFrom();
if (from == null || from.equals("sql")) {
QueryWrapper<CalculateLogPojo> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("date");
if (query.getDate() != null) queryWrapper.eq("date", query.getDate());
int queryPage = query.getPage() == null ? 0 : query.getPage();
int querySize = query.getSize() == null ? 10 : query.getSize();
Page<CalculateLogPojo> page = new Page<>(queryPage, querySize);
logMapper.selectPage(page, queryWrapper);
List<CalculateLogPojo> list = page.getRecords();
return ResultPojo.success(list);
}
return localLogMapper.query(query);
}
}
默认页数、页面大小可以写入配置文件,由于精力有限,在本项目中暂未实现。
AOP实现日志记录
创建LogAnnotation
注解,将其添加到需要记录日志的地方。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LogAnnotation {
}
整个LogAop
类是一个切面。它被@Aspect
注解标注,表示这是一个切面类。
定义切入点表达式@Around("@annotation(top.wushf.calculator.annotation.LogAnnotation)")
,任何被@LogAnnotation
注解标注的方法都会触发recordLog
方法的执行。
recordLog
是一个环绕通知Around advice
,当匹配的切入点方法被调用时,这个方法会在目标方法执行前后运行,执行日志记录逻辑。
@Aspect
@Component
public class LogAop {
@Autowired
private LogService logService;
@Around("@annotation(top.wushf.calculator.annotation.LogAnnotation)")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
long beginTime = System.currentTimeMillis();
ResultPojo resultPojo = (ResultPojo) joinPoint.proceed();
long endTime = System.currentTimeMillis();
long duration = endTime - beginTime;
CalculateRequestPojo calculateRequestPojo = (CalculateRequestPojo) args[0];
String expresstion = calculateRequestPojo.getExpression();
double res = (double) resultPojo.getData();
CalculateLogPojo calculateLogPojo = new CalculateLogPojo(null, new Timestamp(System.currentTimeMillis()), expresstion, res, duration);
logService.save(calculateLogPojo);
return resultPojo;
}
}
在目标方法执行前后获取系统时间,做差求出计算用时。
通过joinPoint.getArgs()
获得传入的参数,该方法的参数为DTO
对象CalculateRequestPojo
。
通过接收joinPoint.proceed()
的返回指得到运算结果,运算结果封装在全局的返回结果类ResultPojo<T>
中。
执行LogService
的save
方法,将信息写入日志。
跨域访问
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS
在本地Debug
的过程中,发现在Apifox
中可以正常响应请求,在Vue
的浏览器环境下就报错。原因是出现了跨域访问问题。
由于源Origin
的端口是Vue3
默认的5137
,请求的端口的spring
默认的8080
,端口不一致,浏览器会发送CORS
预检请求。预检请求会向该URL
发送OPTIONS
请求,响应中会携带有关跨域访问的信息。
如果要允许跨域访问,可以尝试在Controller
上添加@CrossOrigin
注解。但亲测,只在GET
方法上成功生效,POST
仍然访问失败。
本项目使用的解决方案是,通过配置WebMvcConfigurer
,实现跨域资源访问。
package top.wushf.calculator.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/calculate").allowedOriginPatterns("*").allowCredentials(true).allowedHeaders("*").allowedMethods("*").maxAge(3600);
}
}
DevOps开发实践
持续交付CD
本项目的前后端均托管在Gitee
仓库,通过部署流水线,实现持续集成、持续部署。
配置文件分离
由于Spring配置文件会指定数据库用户名密码等敏感信息,在本项目中选择将其排除在版本控制之外。
在部署脚本中,通过主函数参数指定外部配置文件。
PID=$(ps -ef | grep calculator-0.0.1-SNAPSHOT.jar | grep -v grep | awk '{print $2}')
# 如果找到进程,则杀死该进程
if [ -n "$PID" ]; then
echo "找到进程 ID: $PID,正在杀死该进程..."
kill -9 $PID
echo "进程已被杀死。"
else
echo "没有找到正在运行的calculator-0.0.1-SNAPSHOT.jar的进程。"
fi
# 重新启动程序
echo "正在重新启动calculator-0.0.1-SNAPSHOT.jar..."
nohup java -jar /home/admin/calculator-server/target/calculator-0.0.1-SNAPSHOT.jar --spring.config.location=/root/calculator-server.yml > /home/admin/calculator-server/calculator.log 2>&1 &
echo "程序已重新启动。"
软件设计模式在本项目中的应用
Command命令
尝试以对象来代表实际行动。命令对象可以把行动
action
及其参数封装起来
input = (cmd: string) =>{}
根据输入的字符,执行不同的动作,而不必为每一种输入定义单独的函数。
RESTful Api
REST
中使用HTTP
动词来表示对资源的操作,这与命令模式相似,通过不同的命令执行不同的操作。
REST
表现层状态转换,该设计风格体现在本项目Api
文档的定义中。
本项目围绕计算需求,对于URL
:/calculate
- 求值请求通过
POST
方法推送表达式 - 日志请求通过
GET
方法获取,并通过设置Query
参数实现自定义查询
DAO数据访问对象
将数据访问逻辑从业务逻辑中分离出来,并将数据访问操作封装在一个专用的类中
为数据库表、HTTP
请求等封装数据访问对象:
Proxy代理
代理者可以作任何东西的接口:网络连接、存储器中的大对象、文件或其它昂贵或无法复制的资源。
在Spring AOP
中,通过代理模式创建目标对象的代理对象。这个代理对象控制对目标对象的访问,允许在方法调用之前和之后插入额外的行为。
Strategy策略
将每个算法封装起来,使它们可以互换使用
Spring端持久层有本地文件实现和数据库实现。在读日志时,对于“有指定日期”和“无指定日期”、“从数据库”和“从本地文件”,分别封装具体的方法,在QueryService
中,有策略类负责调度,根据QueryPojo
,执行不同的策略。
由于本项目较为简单,并没有为每个算法定义单独的类,而是封装在具有相近语义的类下。