个人简介:
> 📦个人主页:赵四司机
> 🏆学习方向:JAVA后端开发
> 📣种一棵树最好的时间是十年前,其次是现在!
> ⏰往期文章:SpringBoot项目整合微信支付
> 🧡喜欢的话麻烦点点关注喔,你们的支持是我的最大动力。
前言:
最近在做一个基于SpringCloud+Springboot+Docker的新闻头条微服务项目,所用教程为黑马的教程,现在项目开发进入了尾声,我打算通过写文章的形式进行梳理一遍,并且会将梳理过程中发现的Bug进行修复,有需要改进的地方我也会继续做出改进。这一系列的文章我将会放入微服务项目专栏中,这个项目适合刚接触微服务的人作为练手项目,假如你对这个项目感兴趣你可以订阅我的专栏进行查看,需要资料可以私信我,当然要是能给我点个小小的关注就更好了,你们的支持是我最大的动力。
目录
一:文章列表加载
1.需求分析
2.表结构分析
3.功能实现
(1)项目搭建
(2)Nacos配置
(3)代码实现
二:FreeMarker引入
1.概述
2.需求分析
3.代码实现
(1)模板信息
(2)业务层实现
一:文章列表加载
1.需求分析
用户成功登录之后,首先会加载的是推荐频道的文章,文章按封面又分为四种类型,分别为无图文章、单图文章、双图文章、三图文章。当然用户还可以选择其他频道进行浏览,并且可以点击文章查看详情。除此之外,用户下拉操作还会进行文章列表刷新,上拉操作会执行加载更多动作。
2.表结构分析
数据库表的设计分成了三张表,分别为文章信息表,文章配置表,文章内容表。至于为什么要拆分成三张表而不是用一张表来存储,这是因为文章的内容信息一般会比较长,这时候放在一张表那么无论什么时候都会去数据库中获取所有数据进行返回,但是有时候用户只是在首页进行访问,并没有点击文章进去查看,这时候假如是一张表结构的话用户再首页不断滑动也到数据库中获取文章的全部内容,这样显然是很费IO的,特别是获取文章内容这种数据量较大的数据。那么这时候设计成三张表就很合理了,只要用户不点击文章我就不需要去数据库中加载文章详细信息,我只需要加载简单的标题、封面、作者等信息即可,减少了数据库的压力。三张表结构如下:
①ap_article文章信息表:
②ap_article_config:文章配置表:
③ap_article_content文章内容表:
3.功能实现
(1)项目搭建
在tbug-headlines-service下创建tbug-headlines-article工程作为子工程,项目结构如下:
(2)Nacos配置
在Nacos添加如下配置
配置文件:
spring:
redis:
host: 49.234.52.192
password: 440983
port: 6379
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/headlines_article?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false
username: root
password: 440983
# 设置Mapper接口所对应的XML文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
# 设置别名包扫描路径,通过该属性可以给包中的类注册别名
type-aliases-package: com.my.model.article.pojos
global-config:
datacenter-id: 1
workerId: 1
minio:
accessKey: minio
secretKey: minio123
bucket: headlines
endpoint: http://49.234.52.192:9000
readPath: http://49.234.52.192:9000
(3)代码实现
mapper类
package com.heima.article.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.heima.model.article.dtos.ArticleHomeDto;
import com.heima.model.article.pojos.ApArticle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ApArticleMapper extends BaseMapper<ApArticle> {
public List<ApArticle> loadArticleList(@Param("dto") ArticleHomeDto dto, @Param("type") Short type);
}
对应映射文件:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.my.article.mapper.ApArticleMapper">
<!--namespace根据自己需要创建的的mapper的路径和名称填写-->
<resultMap id="resultMap" type="com.my.model.article.pojos.ApArticle">
<id column="id" property="id"/>
<result column="title" property="title"/>
<result column="author_id" property="authorId"/>
<result column="author_name" property="authorName"/>
<result column="channel_id" property="channelId"/>
<result column="channel_name" property="channelName"/>
<result column="layout" property="layout"/>
<result column="flag" property="flag"/>
<result column="images" property="images"/>
<result column="labels" property="labels"/>
<result column="likes" property="likes"/>
<result column="collection" property="collection"/>
<result column="comment" property="comment"/>
<result column="views" property="views"/>
<result column="province_id" property="provinceId"/>
<result column="city_id" property="cityId"/>
<result column="county_id" property="countyId"/>
<result column="created_time" property="createdTime"/>
<result column="publish_time" property="publishTime"/>
<result column="sync_status" property="syncStatus"/>
<result column="static_url" property="staticUrl"/>
</resultMap>
<select id="loadArticleList" resultMap="resultMap">
SELECT
aa.*
FROM
`ap_article` aa
LEFT JOIN ap_article_config aac ON aa.id = aac.article_id
<where>
and aac.is_delete != 1
and aac.is_down != 1
<!-- loadmore -->
<if test="type != null and type == 1">
and aa.publish_time <![CDATA[<]]> #{dto.minBehotTime}
</if>
<if test="type != null and type == 2">
and aa.publish_time <![CDATA[>]]> #{dto.maxBehotTime}
</if>
<if test="dto.tag != '__all__'">
and aa.channel_id = #{dto.tag}
</if>
</where>
order by aa.publish_time desc
limit #{dto.size}
</select>
<select id="findArticleListByLast5days" resultMap="resultMap">
SELECT
aa.*
FROM
`ap_article` aa
LEFT JOIN ap_article_config aac ON aa.id = aac.article_id
<where>
and aac.is_delete != 1
and aac.is_down != 1
<if test="dayParam != null">
and aa.publish_time <![CDATA[>=]]> #{dayParam}
</if>
</where>
</select>
</mapper>
业务层代码
package com.my.article.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.my.model.article.dtos.ArticleDto;
import com.my.model.article.dtos.ArticleHomeDto;
import com.my.model.article.pojos.ApArticle;
import com.my.model.common.dtos.ResponseResult;
import com.my.model.mess.ArticleVisitStreamMess;
import java.util.Map;
public interface ApArticleService extends IService<ApArticle> {
/**
* 根据参数加载文章
* @param loadtype 1.加载更多 2.加载最新
* @param dto
* @return
*/
ResponseResult load(Short loadtype, ArticleHomeDto dto);
}
实现类:
package com.my.article.service.serviceImpl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.my.article.mapper.ApArticleMapper;
import com.my.article.service.ApArticleService;
import com.my.common.constans.ArticleConstas;
import com.my.model.article.dtos.ArticleHomeDto;
import com.my.model.article.pojos.ApArticle;
import com.my.model.common.dtos.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Service
@Transactional
@Slf4j
public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle> implements ApArticleService {
@Autowired
private ApArticleMapper apArticleMapper;
//单页最大加载文章数
private final static short MAX_ARTICLE_SIZE = 50;
/**
* 根据类型加载文章
* @param loadtype 1.加载更多 2.加载最新
* @param dto
* @return
*/
@Override
public ResponseResult load(Short loadtype, ArticleHomeDto dto) {
//1.参数校验
//1.1校验单页加载文章数
Integer size = dto.getSize();
if(size == null || size == 0) {
log.warn("未设置单页加载文章数");
size = 10;
}
size = Math.min(size, MAX_ARTICLE_SIZE);
dto.setSize(size);
//1.2类型参数校验
if(!loadtype.equals(ArticleConstas.LOADTYPE_LOAD_MORE) && !loadtype.equals(ArticleConstas.LOADTYPE_LOAD_NEW)) {
loadtype = ArticleConstas.LOADTYPE_LOAD_MORE;
}
//1.3文章频道校验
if(dto.getTag().isEmpty()) {
dto.setTag(ArticleConstas.DEFAULT_TAG);
}
//前端Bug校正(tag=0时候表示“其他”频道,id为7)
if(dto.getTag().equals("0")) {
dto.setTag("7");
}
//1.4时间校验
if(dto.getMaxBehotTime() == null) dto.setMaxBehotTime(new Date());
if(dto.getMinBehotTime() == null) dto.setMinBehotTime(new Date());
//2.查询文章
List<ApArticle> articleList = apArticleMapper.loadArticleList(dto, loadtype);
//3.返回结果
return ResponseResult.okResult(articleList);
}
}
controller层:
package com.my.article.controller.v1;
import com.my.article.service.ApArticleService;
import com.my.common.constans.ArticleConstas;
import com.my.model.article.dtos.ArticleHomeDto;
import com.my.model.common.dtos.ResponseResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/article")
public class ArticleHomeController {
@Autowired
private ApArticleService apArticleService;
/**
* 加载首页
* @param dto
* @return
*/
@PostMapping("/load")
public ResponseResult load(@RequestBody ArticleHomeDto dto){
// return apArticleService.load(ArticleConstas.LOADTYPE_LOAD_MORE,dto);
return apArticleService.load2(dto,ArticleConstas.LOADTYPE_LOAD_MORE,true);
}
/**
* 加载更多
* @param dto
* @return
*/
@PostMapping("/loadmore")
public ResponseResult loadmore(@RequestBody ArticleHomeDto dto){
return apArticleService.load(ArticleConstas.LOADTYPE_LOAD_MORE,dto);
}
/**
* 加载最新
* @param dto
* @return
*/
@PostMapping("/loadnew")
public ResponseResult loadnew(@RequestBody ArticleHomeDto dto){
return apArticleService.load(ArticleConstas.LOADTYPE_LOAD_NEW,dto);
}
}
二:FreeMarker引入
1.概述
FreeMarker 是一款 模板引擎: 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。
模板编写为FreeMarker Template Language (FTL)。它是简单的,专用的语言, 不是 像PHP那样成熟的编程语言。 那就意味着要准备数据在真实编程语言中来显示,比如数据库查询和业务运算, 之后模板显示已经准备好的数据。在模板中,你可以专注于如何展现数据, 而在模板之外可以专注于要展示什么数据。关于FreeMarker的基础使用我这里就不做介绍了,你可以查看我这篇文章:freemarker模板引擎的常用命令介绍
2.需求分析
当自媒体创作者在创作端创作好文章并审核通过后通过Kafka将文章信息发送给article微服务进行保存,这时候article微服务就需要利用FreeMarker模板引擎将文章对象转换成html静态页面并上传至MinIO,至于什么是MinIO我将在下一篇文章介绍。这样用户点击文章查看详情时候就能直接从MinIO中获取静态页面进行展示。
3.代码实现
(1)模板信息
在tbug-headlines-article微服务的resource包下创建templates包并添加下面文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">
<title>土豆头条</title>
<!-- 引入样式文件 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vant@2.12.20/lib/index.css">
<!-- 页面样式 -->
<link rel="stylesheet" href="../../../plugins/css/index.css">
</head>
<body>
<div id="app">
<div class="article">
<van-row>
<van-col span="24" class="article-title" v-html="title"></van-col>
</van-row>
<van-row type="flex" align="center" class="article-header">
<van-col span="3">
<van-image round class="article-avatar" src="https://p3.pstatp.com/thumb/1480/7186611868"></van-image>
</van-col>
<van-col span="16">
<div v-html="authorName"></div>
<div>{{ publishTime | timestampToDateTime }}</div>
</van-col>
<van-col span="5">
<van-button round :icon="relation.isfollow ? '' : 'plus'" type="info" class="article-focus"
:text="relation.isfollow ? '取消关注' : '关注'" :loading="followLoading" @click="handleClickArticleFollow">
</van-button>
</van-col>
</van-row>
<van-row class="article-content">
<#if content??>
<#list content as item>
<#if item.type='text'>
<van-col span="24" class="article-text">${item.value}</van-col>
<#else>
<van-col span="24" class="article-image">
<van-image width="100%" src="${item.value}"></van-image>
</van-col>
</#if>
</#list>
</#if>
</van-row>
<van-row type="flex" justify="center" class="article-action">
<van-col>
<van-button round :icon="relation.islike ? 'good-job' : 'good-job-o'" class="article-like"
:loading="likeLoading" :text="relation.islike ? '取消赞' : '点赞'" @click="handleClickArticleLike"></van-button>
<van-button round :icon="relation.isunlike ? 'delete' : 'delete-o'" class="article-unlike"
:loading="unlikeLoading" @click="handleClickArticleUnlike">不喜欢</van-button>
</van-col>
</van-row>
<!-- 文章评论列表 -->
<van-list v-model="commentsLoading" :finished="commentsFinished" finished-text="没有更多了"
@load="onLoadArticleComments">
<van-row id="#comment-view" type="flex" class="article-comment" v-for="(item, index) in comments" :key="index">
<van-col span="3">
<van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
</van-col>
<van-col span="21">
<van-row type="flex" align="center" justify="space-between">
<van-col class="comment-author" v-html="item.authorName"></van-col>
<van-col>
<van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
@click="handleClickCommentLike(item)">{{ item.likes || '' }}
</van-button>
</van-col>
</van-row>
<van-row>
<van-col class="comment-content" v-html="item.content"></van-col>
</van-row>
<van-row type="flex" align="center">
<van-col span="10" class="comment-time">
{{ item.createdTime | timestampToDateTime }}
</van-col>
<van-col span="3">
<van-button round size="normal" v-html="item.reply" @click="showCommentRepliesPopup(item.id)">回复 {{
item.reply || '' }}
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
</van-list>
</div>
<!-- 文章底部栏 -->
<van-row type="flex" justify="space-around" align="center" class="article-bottom-bar">
<van-col span="13">
<van-field v-model="commentValue" placeholder="写评论">
<template #button>
<van-button icon="back-top" @click="handleSaveComment"></van-button>
</template>
</van-field>
</van-col>
<van-col span="3">
<van-button icon="comment-o" @click="handleScrollIntoCommentView"></van-button>
</van-col>
<van-col span="3">
<van-button :icon="relation.iscollection ? 'star' : 'star-o'" :loading="collectionLoading"
@click="handleClickArticleCollection"></van-button>
</van-col>
<van-col span="3">
<van-button icon="share-o"></van-button>
</van-col>
</van-row>
<!-- 评论Popup 弹出层 -->
<van-popup v-model="showPopup" closeable position="bottom"
:style="{ width: '750px', height: '60%', left: '50%', 'margin-left': '-375px' }">
<!-- 评论回复列表 -->
<van-list v-model="commentRepliesLoading" :finished="commentRepliesFinished" finished-text="没有更多了"
@load="onLoadCommentReplies">
<van-row id="#comment-reply-view" type="flex" class="article-comment-reply"
v-for="(item, index) in commentReplies" :key="index">
<van-col span="3">
<van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
</van-col>
<van-col span="21">
<van-row type="flex" align="center" justify="space-between">
<van-col class="comment-author" v-html="item.authorName"></van-col>
<van-col>
<van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
@click="handleClickCommentReplyLike(item)">{{ item.likes || '' }}
</van-button>
</van-col>
</van-row>
<van-row>
<van-col class="comment-content" v-html="item.content"></van-col>
</van-row>
<van-row type="flex" align="center">
<!-- TODO: js计算时间差 -->
<van-col span="10" class="comment-time">
{{ item.createdTime | timestampToDateTime }}
</van-col>
</van-row>
</van-col>
</van-row>
</van-list>
<!-- 评论回复底部栏 -->
<van-row type="flex" justify="space-around" align="center" class="comment-reply-bottom-bar">
<van-col span="13">
<van-field v-model="commentReplyValue" placeholder="写评论">
<template #button>
<van-button icon="back-top" @click="handleSaveCommentReply"></van-button>
</template>
</van-field>
</van-col>
<van-col span="3">
<van-button icon="comment-o"></van-button>
</van-col>
<van-col span="3">
<van-button icon="star-o"></van-button>
</van-col>
<van-col span="3">
<van-button icon="share-o"></van-button>
</van-col>
</van-row>
</van-popup>
</div>
<!-- 引入 Vue 和 Vant 的 JS 文件 -->
<script src=" https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js">
</script>
<script src="https://cdn.jsdelivr.net/npm/vant@2.12.20/lib/vant.min.js"></script>
<!-- 引入 Axios 的 JS 文件 -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<#--<script src="../../../plugins/js/axios.min.js"></script>-->
<!-- 页面逻辑 -->
<script src="../../../plugins/js/index.js"></script>
</body>
</html>
(2)业务层实现
package com.my.article.service;
import com.my.model.article.pojos.ApArticle;
public interface ArticleFreemarkerService {
/**
* 生成静态文件上传到minIO中
* @param apArticle
* @param content
*/
public void buildArticleToMinIO(ApArticle apArticle, String content);
}
package com.my.article.service.serviceImpl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.my.article.mapper.ApArticleContentMapper;
import com.my.article.mapper.ApArticleMapper;
import com.my.article.service.ArticleFreemarkerService;
import com.my.common.constans.ArticleConstas;
import com.my.file.service.FileStorageService;
import com.my.model.article.pojos.ApArticle;
import com.my.model.article.pojos.ApArticleContent;
import com.my.model.search.vos.SearchArticleVo;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
@Slf4j
@Service
@Transactional
public class ArticleFreemarkerServiceImpl implements ArticleFreemarkerService {
@Autowired
private Configuration configuration;
@Autowired
private FileStorageService fileStorageService;
@Autowired
private ApArticleMapper apArticleMapper;
/**
* 异步生成静态页面到Minio
* @param apArticle
* @param apArticleContent
*/
@Override
@Async
public void buildArticleToMinIO(ApArticle apArticle, String apArticleContent) {
StringWriter out = new StringWriter();
if(StringUtils.isNotBlank(apArticleContent)){
try {
if(apArticle.getStaticUrl() != null) {
//删除原来Url
fileStorageService.delete(apArticle.getStaticUrl());
log.info("成功删除原来的html静态文件");
}
log.info("开始生成新的静态html文件...");
//1.文章内容通过freemarker生成html文件
Template template = configuration.getTemplate("article.ftl");
Map<String, Object> params = new HashMap<>();
params.put("content", JSONArray.parseArray(apArticleContent));
template.process(params, out);
} catch (Exception e) {
e.printStackTrace();
}
//2.把html文件上传到minio中
InputStream is = new ByteArrayInputStream(out.toString().getBytes());
String path = fileStorageService.uploadHtmlFile("", apArticle.getId() + ".html", is);
log.info("将html文件上传到minio中:{}",path);
//3.修改ap_article表,保存static_url字段
ApArticle article = new ApArticle();
article.setId(apArticle.getId());
article.setStaticUrl(path);
apArticleMapper.updateById(article);
//4.创建索引,发送消息
createArticleESIndex(article,apArticleContent,path);
}
}
@Autowired
private KafkaTemplate<String,String> kafkaTemplate;
/**
* 创建索引,发送消息
* @param article
* @param apArticleContent
* @param path
*/
private void createArticleESIndex(ApArticle article, String apArticleContent, String path) {
SearchArticleVo searchArticleVo = new SearchArticleVo();
BeanUtils.copyProperties(apArticleContent,searchArticleVo);
searchArticleVo.setContent(apArticleContent);
searchArticleVo.setStaticUrl(path);
//发送消息
kafkaTemplate.send(ArticleConstas.ARTICLE_ES_SYNC_TOPIC, JSON.toJSONString(searchArticleVo));
}
}
下篇预告:MinIO实现文章静态页面存取
转载请注明:【Spring Cloud】新闻头条微服务项目:FreeMarker模板引擎实现文章静态页面生成 | 胖虎的工具箱-编程导航