手把手撸一个wiki系统(8):阅读数、点赞数

2021年7月15日17:11:451 93 11304字

阅读量

完成阅读数+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));
    }
}

您可能感兴趣的文章

匿名

发表评论

匿名网友

    • demo
      demo

      点赞