阅读量
完成阅读数+1功能,先写sql
docmapper:
/**
* 阅读量+1
* @param id
* @return
*/
int increaseViewCount(@Param("id") Long id);
xml:
<update id="increaseViewCount" >
update doc set viw_count = viw_count + 1 where id = #{id}
</update>
在service的getContent方法里增加语句:
public String getContent(Long id){
String content = contentMapper.selectById(id).getContent();
//阅读量+1
docMapper.increaseViewCount(id);
if (ObjectUtils.isEmpty(content)){
return "";
}else {
return content;
}
}
前端doc页面:
<div>
<h2>{{doc.name}}</h2>
<div>
<span>阅读数:{{doc.viewCount}}</span>
<span>点赞数:{{doc.voteCount}}</span>
</div>
<a-divider style="height: 2px; background-color: #9999cc"/>
</div>
标题变量doc:
/**
* 文档查询
**/
const doc = ref();
doc.value = {};
const handleQuery = () => {
axios.get("/doc/all/" + route.query.ebookId).then((response) => {
const data = response.data;
if (data.success) {
docs.value = data.content;
level1.value = [];
level1.value = Tool.array2Tree(docs.value, 0);
if (Tool.isNotEmpty(level1.value)) {
defaultSelectedKeys.value = [level1.value[0].id];
handleQueryContent(level1.value[0].id);
// 初始显示文档信息
doc.value = level1.value[0];
}else{
message.warning('暂无文档,请新增')
}
} else {
message.error(data.message);
}
});
};
const onSelect = (selectedKeys: any, info: any) => {
console.log('selected', selectedKeys, info);
if (Tool.isNotEmpty(selectedKeys)) {
// 选中某一节点时,加载该节点的文档信息
doc.value = info.selectedNodes[0].props;
// 加载内容
handleQueryContent(selectedKeys[0]);
}
};
点赞量
controller:
@GetMapping("/vote/{id}")
public CommonResp vote(@PathVariable Long id){
CommonResp commonResp = new CommonResp<>();
docService.vote(id);
return commonResp;
}
service:
public void vote(Long id){
docMapper.increaseVoteCount(id);
}
mapper:
/** * 点赞量+1 * @param id * @return */int increaseVoteCount(@Param("id") Long id);
sql:
<update id="increaseVoteCount" > update doc set vote_count = vote_count + 1 where id = #{id}</update>
前端:
<span>点赞数:{{doc.voteCount}}</span>
// 点赞 const vote = () => { axios.get('/doc/vote/' + doc.value.id).then((response) => { const data = response.data; if (data.success) { doc.value.voteCount++; } else { message.error(data.message); } }); };
优化
此时,点赞可以连续任意点,这肯定是不合理的;而点赞功能要对非登录用户也开放,那么考虑通过用户ip和电子书id放到redis来验证
utils包下新建RequestContext.java:
public class RequestContext implements Serializable { //远程ip。使用线程的本地变量,只在当前线程有效,线程间不会互相干扰 private static ThreadLocal<String> remoteAddr = new ThreadLocal<>(); public static String getRemoteAddr() { return remoteAddr.get(); } public static void setRemoteAddr(String remoteAddr) { RequestContext.remoteAddr.set(remoteAddr); }}
同样位置新建RedisUtil.java,对redisTemplate简单封装一下,验证key是否重复
@Componentpublic class RedisUtil { private static final Logger LOG = LoggerFactory.getLogger(RedisUtil.class); @Resource private RedisTemplate redisTemplate; /** * true:不存在,放一个KEY * false:已存在 * @param key * @param second * @return */ public boolean validateRepeat(String key, long second) { if (redisTemplate.hasKey(key)) { LOG.info("key已存在:{}", key); return false; } else { LOG.info("key不存在,放入:{},过期 {} 秒", key, second); redisTemplate.opsForValue().set(key, key, second, TimeUnit.SECONDS); return true; } }}
修改LogAspect:
RequestContext.setRemoteAddr(getRemoteIp(request)); ...... /** * 使用nginx做反向代理,需要用该方法才能取到真实的远程IP * @param request * @return */ public String getRemoteIp(HttpServletRequest request) { String ip = request.getHeader("x-forwarded-for"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; }
DocService:
@Resourcepublic RedisUtil redisUtil;
public void vote(Long id) { // docMapperCust.increaseVoteCount(id); // 远程IP+doc.id作为key,24小时内不能重复 String ip = RequestContext.getRemoteAddr(); if (redisUtil.validateRepeat("DOC_VOTE_" + id + "_" + ip, 3600*24)) { docMapper.increaseVoteCount(id); } else { throw new BusinessException(BusinessExceptionCode.VOTE_REPEAT); }}
定义异常代码:
VOTE_REPEAT("您已点赞过"),
见提交8-2
信息定时更新
启动类上添加注解:
@EnableScheduling
新建Task包,新建DocTask:
@Componentpublic class DocTask { private static final Logger LOG = LoggerFactory.getLogger(DocTask.class); @Resource private DocService docService; @Resource private SnowFlake snowFlake; /** * 每30秒更新电子书信息 */ @Scheduled(cron = "5/30 * * * * ?") public void cron() { // 增加日志流水号 MDC.put("LOG_ID", String.valueOf(snowFlake.nextId())); LOG.info("更新电子书下的文档数据开始"); long start = System.currentTimeMillis(); docService.updateEbookInfo(); LOG.info("更新电子书下的文档数据结束,耗时:{}毫秒", System.currentTimeMillis() - start); }}
Task调用的service:
/** * 电子书信息更新 */public void updateEbookInfo(){ docMapper.updateEbookInfo();}
service对应的mapper:
/** * 文档信息更新接口 */void updateEbookInfo();
mapper具体的xml:
<update id="updateEbookInfo"> update ebook t1, (select ebook_id, count(1) doc_count, sum(view_count) view_count, sum(vote_count) vote_count from doc group by ebook_id) t2 set t1.doc_count = t2.doc_count, t1.view_count = t2.view_count, t1.vote_count = t2.vote_count where t1.id = t2.ebook_id</update>
前端修改图标
Home:
<template #actions> <span> <component v-bind:is="'FileTextOutlined'" /> {{ item.docCount }} </span> <span> <component v-bind:is="'EyeOutlined'" /> {{ item.viewCount }} </span> <span> <component v-bind:is="'LikeOutlined'" /> {{ item.voteCount }} </span></template>
导入图标:
import {LikeOutlined, EyeOutlined, FileTextOutlined} from '@ant-design/icons-vue';…… components: { LikeOutlined, EyeOutlined, FileTextOutlined, },
见提交8-3;
日志流水号
大量日志下,增加流水号用于对不同日志的区分
LogAspect里通过slf4j的MDC获取id:
@Resourceprivate SnowFlake snowFlake;……// 增加日志流水号MDC.put("LOG_ID", String.valueOf(snowFlake.nextId()));
然后在logback.xml日志文件里添加即可:
%green(%-18X{LOG_ID})
集成websocket
相关教程:https://www.cnblogs.com/xuwenjin/p/12664650.html
服务端:
依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId></dependency>
config包下新增WebSocketConfig,内容:
/** * 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint * Created by tangssst@qq.com on 2021/07/13 */@Configurationpublic class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); }}
新增WebSocket包,新建WebSocketServer:
/** * @ServerEndpoint注解是服务端与客户端交互的关键,其值与页面中的请求路径对应。 */@Component@ServerEndpoint("/ws/{token}")public class WebSocketServer { private static final Logger LOG = LoggerFactory.getLogger(WebSocketServer.class); /** * 每个客户端一个token */ private String token = ""; private static HashMap<String, Session> map = new HashMap<>(); /** * 连接成功 */ @OnOpen public void onOpen(Session session, @PathParam("token") String token) { map.put(token, session); this.token = token; LOG.info("有新连接:token:{},session id:{},当前连接数:{}", token, session.getId(), map.size()); } /** * 连接关闭 */ @OnClose public void onClose(Session session) { map.remove(this.token); LOG.info("连接关闭,token:{},session id:{}!当前连接数:{}", this.token, session.getId(), map.size()); } /** * 收到消息 */ @OnMessage public void onMessage(String message, Session session) { LOG.info("收到消息:{},内容:{}", token, message); } /** * 连接错误 */ @OnError public void onError(Session session, Throwable error) { LOG.error("发生错误", error); } /** * 群发消息 */ public void sendInfo(String message) { for (String token : map.keySet()) { Session session = map.get(token); try { session.getBasicRemote().sendText(message); } catch (IOException e) { LOG.error("推送消息失败:{},内容:{}", token, message); } LOG.info("推送消息:{},内容:{}", token, message); } }}
在.env里配置环境:
NODE_ENV = developmentVUE_APP_SERVER=http://127.0.0.1:8001VUE_APP_WS_SERVER=ws://127.0.0.1:8001
NODE_ENV = productionVUE_APP_SERVER=http://wiki.ysboke.cnVUE_APP_WS_SERVER=ws://wiki.ysboke.cn
tool.ts:
/** * 随机生成[len]长度的[radix]进制数 * @param len * @param radix 默认62 * @returns {string} */public static uuid (len: number, radix = 62) { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); const uuid = []; radix = radix || chars.length; for (let i = 0; i < len; i++) { uuid[i] = chars[0 | Math.random() * radix]; } return uuid.join('');}
然后是在通用组件Footer.vue里引用:
<script lang="ts">import { defineComponent, computed, onMounted } from 'vue';import store from "@/store";import {Tool} from "@/util/tool";import { notification } from 'ant-design-vue';export default defineComponent({ name: 'the-footer', setup() { const user = computed(() => store.state.user); let websocket: any; let token: any; const onOpen = () => { console.log('WebSocket连接成功,状态码:', websocket.readyState) }; const onMessage = (event: any) => { console.log('WebSocket收到消息:', event.data); notification['info']({ message: '收到消息', description: event.data, }); }; const onError = () => { console.log('WebSocket连接错误,状态码:', websocket.readyState) }; const onClose = () => { console.log('WebSocket连接关闭,状态码:', websocket.readyState) }; const initWebSocket = () => { // 连接成功 websocket.onopen = onOpen; // 收到消息的回调 websocket.onmessage = onMessage; // 连接错误 websocket.onerror = onError; // 连接关闭的回调 websocket.onclose = onClose; }; onMounted(() => { // WebSocket if ('WebSocket' in window) { token = Tool.uuid(10); // 连接地址:ws://127.0.0.1:8880/ws/xxx websocket = new WebSocket(process.env.VUE_APP_WS_SERVER + '/ws/' + token); initWebSocket() // 关闭 // websocket.close(); } else { alert('当前浏览器 不支持') } }); return { user } }});</script>
此时在F12就能看到提示了:WebSocket连接成功,状态码1
见提交8-4
点赞通知
集成了websocket后,在docService里:
@Resourceprivate WebSocketServer webSocketServer;
在vote点赞方法里进行通知,但是这里的通知应该跟点赞代码解耦,不然如果通知功能故障这个vote接口就跟着故障了
,前端也拿不到响应,点赞数没变化
异步化调用
启动类里添加注解开启异步化:
@EnableAsync
然后,使用注解来标识要异步化的方法:@Async,那么当调用该方法的时候就会另起一个线程了
注意,该注解不能与调用者在一个类里,会影响cglib的代理
新建WebSocketService:
@Componentpublic class WebSocketService { @Resource public WebSocketServer webSocketServer; /** * 推送消息 * @param message * @param logId */ @Async public void sendInfo(String message, String logId) { MDC.put("LOG_ID", logId); webSocketServer.sendInfo(message); }}
然后我们再在docService的vote里调用:
public void vote(Long id) { // 远程IP+doc.id作为key,24小时内不能重复 String ip = RequestContext.getRemoteAddr(); if (redisUtil.validateRepeat("DOC_VOTE_" + id + "_" + ip, 3600*24)) { docMapper.increaseVoteCount(id); } else { throw new BusinessException(BusinessExceptionCode.VOTE_REPEAT); } // 推送消息 Doc docDb = docMapper.selectById(id); String logId = MDC.get("LOG_ID"); webSocketService.sendInfo("【" + docDb.getName() + "】被点赞!", logId); // rocketMQTemplate.convertAndSend("VOTE_TOPIC", "【" + docDb.getName() + "】被点赞!");}
见提交8-5
事务
一个方法里对多个关联的表操作,需要保证操作的一致性,要么都成功,要么都失败,不能一个成功了一个失败了,所以要作为事务来处理。
SpringBoot启动事务很简单,在方法上添加注解 @Transactional 即可
同样的,同一个类里被调用注解不生效
见8-6
使用RocketMQ解耦点赞通知
rocketMQ下载:https://rocketmq.apache.org/docs/quick-start/
中文文档:https://github.com/apache/rocketmq/tree/master/docs/cn
配置环境:新建ROCKETMQ_HOME,内容为根目录。
注意!32位的jdk会启动失败!!!!这个坑爬出来花了好久
再到/bin下修改runserver.cmd和runbroker.cmd,改下内存占用512m、512m、256m。后者还要额外改个MaxDirectMemorySize,我改为64m
启动:在bin目录下启动mqnamesrv.cmd(这玩意儿调用了runserver和Jdk),在当前目录启动cmd,带参数启动mqbroker.cmd:
mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true
一闪而过说明配置错误启动失败。
总结一下如何启动:
1、点击:mqnamesrv.cmd
2、进入cmd,输入:mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true
3、如果打包了控制台,进入jar的cmd,输入:java -jar rocketmq-console-ng-2.0.0.jar --server.port=8003 --rocketmq.config.namesrvAddr=localhost:9876
访问地址:localhost:8003
添加依赖:
<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-spring-boot-starter</artifactId> <version>2.2.0</version></dependency>
在配置文件里配置rocketmq:
#rocketmq
rocketmq:
name-server: localhost:9876
producer:
group: default
docservice使用:
@Resource
private RocketMQTemplate rocketMQTemplate;
……
业务代码:
……
rocketMQTemplate.convertAndSend("VOTE_TOPIC", "【" + docDb.getName() + "】被点赞!");
新建mq包,新建VoteTopicConsumer类:
@Service
@RocketMQMessageListener(consumerGroup = "default", topic = "VOTE_TOPIC")
public class VoteTopicConsumer implements RocketMQListener<MessageExt> {
private static final Logger LOG = LoggerFactory.getLogger(VoteTopicConsumer.class);
@Resource
public WebSocketServer webSocketServer;
@Override
public void onMessage(MessageExt messageExt) {
byte[] body = messageExt.getBody();
LOG.info("ROCKETMQ收到消息:{}", new String(body));
webSocketServer.sendInfo(new String(body));
}
}