一 、背景
前几日投递简历,要按照要求才有面试机会,趁着不忙,也做了个简单demo
1.1 前端演示
二、项目要求
三、项目设计
3.1 数据库设计
3.1.1 需求分析
3.1.1.1 表格页面
3.1.1.2 详情页面
3.1.1.3 城市列表页面
3.1.1.4 分析
主要分析的难点是一个地区包含多个城市,而一个城市又包含多个地址
属于连续的两个一对多
关系
根据以上关系,建立了两张表(理论上建立三张)
3.1.1.4.1 主表area地区表
存储了编号、区域名称、区域下城市的id、创建时间
对应关系如下:
3.1.1.4.2 从表area_detail
存储了区域下的城市、城市下的地址,这儿稍微偷了点懒,把城市下的地址按照数组的形式存入一列,少建立的一张表
3.1.1.4.3 城市表city
这个结构就很简单了,就不细说了
3.1.1.4 注意
连接两张表的是position_id,需要全局唯一,且在执行sql前就指定数值,
这里我使用了redis生成为唯一id:
一共64位(Long),
1位符号位,固定为0
31位存储时间戳,如果一天一个数值,理论上可以存储68年的数据
32位存储自增编号,一天最多可以生成2^32个编号
3.2 Java部分
3.2.1 实体类生成
实体类直接用MybatisPlusX
自动生成就好了
其他类似的模板可以参考:Java SpringBoot模板 - 掘金 (juejin.cn)
主要查询就两张表,比较简单,直接说易错点和难点了
3.2.2 项目易错点
主键无法自增
加载分页插件
修改redis序列化
LocalDataTime 在sql中不能和空比较
数据库编码错误
开启sql日志
前端接收excel
参考地址: juejin.cn/post/725696…
缓存方法失败
minio 端口用错
参考地址: juejin.cn/post/725704…
导入 websocket 后测试类失效
3.2.3 项目亮点
redis缓存常用方法
mongodb 存储查询历史
用户在进行搜索时,会有提示框出现,可避免重复输入
相关逻辑为:
-
提示框最多可存储五条历史搜索记录,
-
若该条记录存在,则更新存储时间为现在
-
若该条记录不存在,分为两种情况:
3.1 若超出五条,则最新搜索的记录会替代最久远的记录
3.2 若没有超出五条,则直接保存
相关代码如下:
@Override
public ResponseResult saveWord(Word word) {
// 获取类别
String keyWord = word.getWord();
if(StringUtils.isBlank(keyWord)){
return ResponseResult.errorResult(HttpCodeEnum.IS_BRANK);
}
Integer kind = word.getKind();
// 封装准备存入的数据
word.setCreatedAt(new Date());
Query query = Query.query(Criteria.where("kind").is(word.getKind().intValue())
.and("word").is(word.getWord()));;
Word one = mongoTemplate.findOne(query, Word.class);
log.info("收到的消息为{}",String.valueOf(one));
// 数据已经存在,只需要更新时间
if(one!=null){
Query query2 = new Query(Criteria.where("kind").is(kind)
.and("word").is(word));
Update update = new Update().set("createdAt",new Date());
mongoTemplate.updateFirst(query2,update,Word.class);
return ResponseResult.okResult(HttpCodeEnum.SUCCESS);
}
// 查询数据库中该种类关键词的数量
Query query1 = Query.query(Criteria.where("kind").is(kind));
query1.with(Sort.by(Sort.Direction.DESC,"createdAt"));
List<Word> list = mongoTemplate.find(query1, Word.class);
// 现在都是数据不存在的情况
if(list==null || list.size()<5){
// 直接保存
mongoTemplate.save(word);
}else {
// 替代最久远的记录
Word last = list.get(list.size() - 1);
mongoTemplate.findAndReplace(Query.query(
Criteria.where("id").is(last.getId())
), word);
}
return ResponseResult.okResult(HttpCodeEnum.SUCCESS);
}
附: mogodb在 Java中基本语法:mongodb基本操作 - 掘金 (juejin.cn)
若对语法感兴趣,可参考:juejin.cn/post/725479…
es 进行地址查询
用户在进行地址搜索时候,会动态出现与输入有关的关键词
高亮设置,相关代码如下:
@Autowired
private RestHighLevelClient restHighLevelClient;
@Override
public ResponseResult findPathList(String path) {
if(StringUtils.isBlank(path)){
return ResponseResult.errorResult(HttpCodeEnum.AREA_EMPTY);
}
//2.设置查询条件
SearchRequest searchRequest = new SearchRequest("area_address");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//布尔查询
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//关键字的分词之后查询
QueryStringQueryBuilder queryStringQueryBuilder =
QueryBuilders
.queryStringQuery(path)
.field("areaAddress");
boolQueryBuilder.must(queryStringQueryBuilder);
//设置高亮 title
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("areaAddress");
highlightBuilder.preTags("<font style='color: red; font-size: inherit;'>");
highlightBuilder.postTags("</font>");
searchSourceBuilder.highlighter(highlightBuilder);
searchSourceBuilder.query(boolQueryBuilder);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
System.out.println(e);
}
//3.结果封装返回
List<Map> list = new ArrayList<>();
SearchHit[] hits = searchResponse.getHits().getHits();
for (SearchHit hit : hits) {
String json = hit.getSourceAsString();
Map map = JSON.parseObject(json, Map.class);
//处理高亮
if(hit.getHighlightFields() != null && hit.getHighlightFields().size() > 0){
Text[] titles = hit.getHighlightFields().get("areaAddress").getFragments();
String title = org.apache.commons.lang3.StringUtils.join(titles);
//高亮标题
map.put("title",title);
}else {
//原始标题
map.put("title",map.get("title"));
}
list.add(map);
}
return ResponseResult.okResult(list);
}
定时更新文档,相关代码如下:
@Autowired
private RestHighLevelClient client;
// 定时更新索引库的内容
@Scheduled(cron = "0 0 0 * * ? ")
public void updateSearchIndex() throws IOException {
List<AddressDoc> allPath = areaDetailMapper.getAllPath();
System.out.println("查询的信息为"+ allPath);
/* 去除重复的地址 */
HashMap<String, Long> map = new HashMap<>();
for (AddressDoc addressDoc : allPath) {
Long id = addressDoc.getId();
String address = addressDoc.getAreaAddress();
List<String> converted = Convert.convert(List.class, address);
for (String path : converted) {
map.put(path,id);
}
}
System.out.println("转化后的数据为:"+map);
/* 序列化为addressDoc格式的数据 */
for(Map.Entry<String,Long> entry:map.entrySet()){
String address = entry.getKey();
Long id = entry.getValue();
List pathList = Convert.convert(List.class, address);
AddressDoc addressDoc = new AddressDoc();
addressDoc.setId(id).setAreaAddress(address);
String jsonStr = JSONUtil.toJsonStr(addressDoc);
System.out.println(jsonStr);
// 准备request对象
IndexRequest request = new IndexRequest("area_address").id(id.toString());
// 准备json对象
request.source(jsonStr, XContentType.JSON);
// 发生请求
client.index(request,RequestOptions.DEFAULT);
}
log.info("定时任务开始执行:{}", new Date());
}
小结:
- 更新文档的操作,理论上应该发送在用户编辑、添加、删除地址内容后,但是偷了点懒,使用了定时任务,每天自动更新一次。
- 从数据库中查询到的地址信息可能存在重复现象,所以在插入es之前,我先按照key为地址,value为编号存入map数组,实现地址去重功能。
es相关操作可参考:juejin.cn/post/724382…
WebSoket 双向连接
刚刚说完es,关键词检索要求用户每次输入后,服务器都需要快速响应,
此时可以使用WebSoket 双向连接:持续的长连接
例如我在前端输入地名后
会自动检索,并将检索后的结果集合传给socket
前端收到socket传递的消息后,会在控制台打印相关信息
关键代码:在上一节es的基础上,直接将数据传递给socket处理
@Override
public ResponseResult findPathList(String path) {
xxxxx
// 第二个参数为用户id,由于没做登录,默认为1
webSocket.sendMessage(list,"1");
return ResponseResult.okResult(HttpCodeEnum.SUCCESS);
}
此外,如果各位git上已经下载了项目,可以访问http://localhost:9876/webSocket.html
利用socket简单做了个即时通讯页面
详细操作可参考:juejin.cn/post/725296…
Apache POI 实现报表导出
在项目要求的功能之外,我还添加了数据报表导出功能
默认会导出7天内数据明细,且数量不能超过20条
相关代码如下:
@Override
public ResponseResult exportData(HttpServletResponse response) {
//1. 查询数据库,获取数据---查询最近7天的数据
LocalDate dateBegin = LocalDate.now().minusDays(7);
LocalDate dateEnd = LocalDate.now().minusDays(1);
SearchDto searchDto = new SearchDto();
searchDto.setFrom(dateBegin.atStartOfDay())
.setTo(dateEnd.atStartOfDay());
ResponseResult result = search(searchDto);
List<AreaResponse> list = (List<AreaResponse>) result.getData();
// 通过POI将数据写入到Excel文件中
InputStream in = this.getClass().getClassLoader().getResourceAsStream("static/报表模板.xlsx");
try {
//基于模板文件创建一个新的Excel文件
XSSFWorkbook excel = new XSSFWorkbook(in);
//获取表格文件的Sheet页
XSSFSheet sheet = excel.getSheet("Sheet1");
//填充数据--时间
sheet.getRow(3).getCell(4).setCellValue("时间:" + dateBegin + "至" + dateEnd);
//填充数据--统计量
sheet.getRow(3).getCell(2).setCellValue(list.size());
int start = 0;
//填充明细数据
for (AreaResponse areaResponse : list) {
if(start>20){
break;
}
//获得某一行
XSSFRow row = sheet.getRow(7 + start++);
// 编号
row.getCell(1).setCellValue(areaResponse.getId());
// 区域名称
row.getCell(2).setCellValue(areaResponse.getAreaName());
// 区域城市
List<String> areaCityList = areaResponse.getAreaCityList();
String str = Convert.toStr(areaCityList);
String area = str.substring(1, str.length()-1);
row.getCell(3).setCellValue(area);
// 地址
List<String> PathList = areaResponse.getPathList();
String pathStr = Convert.toStr(PathList);
pathStr = pathStr.substring(1, pathStr.length()-1);
row.getCell(4).setCellValue(pathStr);
// 创建时间
LocalDateTime createdTime = areaResponse.getCreatedTime();
String time = createdTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
row.getCell(5).setCellValue(time);
}
//3. 通过输出流将Excel文件下载到客户端浏览器
ServletOutputStream out = response.getOutputStream();
excel.write(out);
//通过输出流将内存中的Excel文件写入到磁盘
FileOutputStream out1 = new FileOutputStream(new File("E:\\桌面\\learning-files\\mongo\\02_代码\\Demo\\src\\main\\resources\\static\\mode.xlsx"));
excel.write(out1);
//关闭资源
out.close();
excel.close();
} catch (IOException e) {
e.printStackTrace();
}
return ResponseResult.okResult(HttpCodeEnum.SUCCESS);
}
POI 相关操作可参考:juejin.cn/post/725335…
前端接收可参考:juejin.cn/post/725696…
缓存队列
若同一时刻大量用户对数据库进行添加操作,会给服务器带来巨大压力,
可将数据存入缓存队列,由并发操作转化为串行
相关代码如下:
服务器校验格式无误后,直接返回响应,数据库操作扔给缓存队列操作
@Override
public ResponseResult saveArea(AreaDto areaDto) {
// 获取区域名称
String areaName = areaDto.getAreaName();
if(StringUtils.isBlank(areaName)){
return ResponseResult.errorResult(HttpCodeEnum.AREA_NAME_IS_BLANK);
}
// 获取区域城市
List<AreaItemDto> cityList = areaDto.getAreaCityList();
if (cityList==null||cityList.size()==0){
return ResponseResult.errorResult(HttpCodeEnum.CITY_LIST_IS_NULL);
}
// 扔入缓存队列
blockTasks.add(areaDto);
return ResponseResult.okResult(HttpCodeEnum.SUCCESS);
}
缓存队列初始化及数据处理:
// 阻塞队列
private BlockingQueue<AreaDto> blockTasks = new ArrayBlockingQueue<>(1024 * 1024);
//线程池
private static final ExecutorService EXCUTOR_SERVIC = Executors.newSingleThreadExecutor();
// 一初始化就提交任务
@PostConstruct
private void init() {
EXCUTOR_SERVIC.submit(new SaveHandler() );
}
private class SaveHandler implements Runnable {
@Override
public void run() {
while(true) {
try {
// 获取队列中的信息
AreaDto areaDto = blockTasks.take();
// 创建订单
handleSave(areaDto);
} catch (Exception e) {
log.info("数据插入异常");
}
}
}
}
public void handleSave(AreaDto areaDto){
// 获取区域名称
String areaName = areaDto.getAreaName();
// 获取区域城市
List<AreaItemDto> cityList = areaDto.getAreaCityList();
// 获取区域的基本信息
Area area = new Area();
long positionId = idWorker.nextId("area_id");
// 插入到主表
area.setAreaName(areaName)
.setAreaPositionId(positionId)
.setCreatedTime(LocalDateTime.now());
save(area);
// 插入到从表
for (AreaItemDto itemDto : cityList) {
// 获取城市列表
List<String> list = itemDto.getPositionList();
// 获取区域城市
String areaCity = itemDto.getAreaCity();
// 转化列表为字符串
String pathList = Convert.toStr(list);
AreaDetail areaDetail = new AreaDetail();
areaDetail.setAreaCity(areaCity)
.setAreaAddress(pathList)
.setPositionId(positionId);
areaDetailService.save(areaDetail);
}
}
相关操作可参考: juejin.cn/post/724231…
minio 存储文件
开发中~
参考地址: juejin.cn/post/725707…
nginx 前端部署
开发中~
3.3 项目部署
导出为jar包:Idea 导出为jar包 - 掘金 (juejin.cn)
宝塔部署: blog.csdn.net/bakelFF/art…
注意:
-
若小白部署在服务器,请限制访问ip,否则将会面临安全问题
-
若没有服务器,却想公网展示,可使用内网穿透
四、 更多
访问地址(不定时关闭)
地址链接:42efe32f.r3.cpolar.top/index.html
项目地址
项目地址: gitee.com/gitee-enter…
面试结局
没有面试的面试
限时两天,以上开发耗时三天,对方以超时为由,失去面试机会,唉