
00 仿今日头条项目
仿今日头条项目
相关技术栈

- Spring-Cloud-Gateway : 微服务之前架设的网关服务,实现服务注册中的API请求路由,以及控制流速控制和熔断处理都是常用的架构手段,而这些功能Gateway天然支持
- 运用Spring Boot快速开发框架,构建项目工程;并结合Spring Cloud全家桶技术,实现后端个人中心、自媒体、管理中心等微服务。
- 运用Spring Cloud Alibaba Nacos作为项目中的注册中心和配置中心
- 运用mybatis-plus作为持久层提升开发效率
- 运用Kafka完成内部系统消息通知;与客户端系统消息通知;以及实时数据计算
- 运用Redis缓存技术,实现热数据的计算,提升系统性能指标
- 使用Mysql存储用户数据,以保证上层数据查询的高性能
- 使用Mongo存储用户热数据,以保证用户热数据高扩展和高性能指标
- 使用FastDFS作为静态资源存储器,在其上实现热静态资源缓存、淘汰等功能
- 运用Hbase技术,存储系统中的冷数据,保证系统数据的可靠性
- 运用ES搜索技术,对冷数据、文章数据建立索引,以保证冷数据、文章查询性能
- 运用AI技术,来完成系统自动化功能,以提升效率及节省成本。比如实名认证自动化
- PMD&P3C : 静态代码扫描工具,在项目中扫描项目代码,检查异常点、优化点、代码规范等,为开发团队提供规范统一,提升项目代码质量
环境搭建
docker 安装 Nacos
-
docker拉取镜像
1
docker pull nacos/nacos-server:1.2.0
-
创建容器
1
docker run --env MODE=standalone --name nacos --restart=always -d -p 8848:8848 nacos/nacos-server:1.2.0
-
MODE=standalone 单机版
-
–restart=always 开机启动
-
-p 8848:8848 映射端口
-
-d 创建一个守护式容器在后台运行
-
初始项目模块介绍
- 父工程
coo1heisenberg-todaynews- 统一管理项目依赖,继承springboot
- 子工程
todaynews-common- 主要用来定义项目通用的配置(例如:全局异常)
- 全局异常处理器会根据异常分为可预知异常(CostomException)、不可预知异常(Exception)
- 主要用来定义项目通用的配置(例如:全局异常)
todaynews-feign-api- 主要定义远程接口
todaynews-gateway- 管理网关微服务
todaynews-model- 存储数据库中的表的对应的实体类(pojo、dto)
todaynews-service- 存储所有微服务相关的工程
todaynews-test- 主要是项目中常用的测试案例
todaynews-utils- 存储项目中常用的工具类
app端 登录
关于app端用户相关的内容较多,可以单独设置一个库leadnews_user
| 表名称 | 说明 |
|---|---|
| ap_user | APP用户信息表 |
| ap_user_fan | APP用户粉丝信息表 |
| ap_user_follow | APP用户关注信息表 |
| ap_user_realname | APP实名认证信息表 |
项目中的持久层使用的mybatis-plus,一般都使用mybais-plus逆向生成对应的实体类

则app_user表对应的实体类:
1 |
|
在这个表中有一项为salt,是手动加密(md5+随机字符串)
-
md5是不可逆加密,md5相同的密码每次加密都一样,不太安全。在md5的基础上手动加盐(salt)处理
-
注册->生成盐

-
登录->使用盐来配合验证

运营端微服务的搭建

app登录 接口定义

常见端口的响应信息
1 | /** |
1 | /** |
app登录 分析

app登录 微服务框架

控制层 controller.v1
1 |
|
持久层 mapper
1 |
|
业务层 service
1 | // Service接口继承IService,实现类继承ServiceImpl。也可以使用Service类进行增删改查 |
实现类 service.Impl
1 |
|
UserApplication.java
1 |
|
资源类 resources
bootstrap.yml
1 | server: |
logback.vml
1 |
|
注册中心 nacos

接口测试工具
Postman



Swagger
Swagger是一个在线的接口文档管理中心
SpringBoot集成Swagger
-
引入依赖,在
todaynews-model和todaynews-common中引入依赖1
2
3
4
5
6
7
8<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency> -
在
todaynews-common工程中添加一个配置类,新增com.heima.common.swagger.SwaggerConfiguration1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37package com.heima.common.swagger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
public class SwaggerConfiguration {
public Docket buildDocket() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(buildApiInfo())
.select()
// 要扫描的API(Controller)基础包
.apis(RequestHandlerSelectors.basePackage("com.heima"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo buildApiInfo() {
Contact contact = new Contact("海森堡","","");
return new ApiInfoBuilder()
.title("头条点评-平台管理API文档")
.description("头条点评后台api")
.contact(contact)
.version("1.0.0").build();
}
} -
在
todaynews-common模块中的resources目录中新增以下目录和文件-
文件:
resources/META-INF/Spring.factories1
2
3org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.heima.common.exception.ExceptionCatch,\
com.heima.common.swagger.SwaggerConfiguration
-
-
常用Swagger注解如下:
-
@Api:修饰整个类,描述Controller的作用
-
@ApiOperation:描述一个类的一个方法,或者说一个接口
-
@ApiParam:单个参数的描述信息
-
@ApiModel:用对象来接收参数
-
@ApiModelProperty:用对象接收参数时,描述对象的一个字段
-
@ApiResponse:HTTP响应其中1个描述
-
@ApiResponses:HTTP响应整体描述
-
@ApiIgnore:使用该注解忽略这个API
-
@ApiError :发生错误返回的信息
-
@ApiImplicitParam:一个请求参数
-
@ApiImplicitParams:多个请求参数的描述信息
-
-
启动user微服务,访问地址:http://localhost:51801/swagger-ui.html
eg:
1 | // ApUserLoginController.java文件下 |
1 | // model类下的LoginDTO |

knife4j
核心功能:
- 文档说明
- 在线调试
- 个性化配置
- 离线文档
- 接口排序
快速集成
-
在
todaynews-common模块中的pom.xml文件中引入knife4j的依赖1
2
3
4<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency> -
在
todaynews-common模块中新建配置类,新建Swagger的配置文件SwaggerConfiguration.java文件,创建springfox提供的Docket分组对象1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44package com.heima.common.knife4j;
import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import springfox.bean.validators.configuration.BeanValidatorPluginsConfiguration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
// @EnableSwagger2:该注解是Springfox-swagger框架提供的使用Swagger注解
// @EnableKnife4j:该注解是knife4j提供的增强注解,Ui提供了例如动态参数、参数过滤、接口排序等增强功能,如果你想使用这些增强功能就必须加该注解,否则可以不用加
public class Swagger2Configuration {
public Docket defaultApi2() {
Docket docket=new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
//分组名称
.groupName("1.0")
.select()
//这里指定Controller扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.heima"))
.paths(PathSelectors.any())
.build();
return docket;
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("头条点评API文档")
.description("头条点评API文档")
.version("1.0")
.build();
}
} -
在
todaynews-common模块中的resources目录中新增以下目录和文件-
文件:
resources/META-INF/Spring.factories1
2
3org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.heima.common.exception.ExceptionCatch,\
com.heima.common.swagger.SwaggerConfiguration
-
-
访问,在浏览器输入地址:http://localhost:51801/doc.html

app端 网关


网关的搭建流程
导入依赖
1 | <dependencies> |
注册中心 nacos
1 | spring: |

bootstrap.yml
1 | server: |
AppGatewayApplication.java
1 |
|
认证过滤器
主要用来校验当前的token

实现流程:
-
在
com.heima.app.gateway.util包下拷贝AppJwtUtil.java -
在
com.heima.app.gateway.filter包下新建全局过滤器1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// AuthorizeFilter实现了GatewayFilter和Ordered接口,用于实现全局过滤器的逻辑
public class AuthorizeFilter implements Ordered, GlobalFilter {
/**
* 过滤的方法
*
* @param exchange
* @param chain
* @return
*/
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取 request 和 response
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
// 2. 判断是否是登录
if (request.getURI().getPath().contains("/login")) {
// 放行
return chain.filter(exchange);
}
// 3. 获取token
String token = request.getHeaders().getFirst("token");
// 4. 判断token是否存在
if (StringUtils.isBlank(token)) {
// HttpStatus.UNAUTHORIZED:401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 5. 判断token是否有效
try {
// 获取token中的数据
Claims claimsBody = AppJwtUtil.getClaimsBody(token);
// 是否是过期
// -1:有效,0:有效,1:过期,2:过期
int result = AppJwtUtil.verifyToken(claimsBody);
if (result == 1 || result == 2) {
// HttpStatus.UNAUTHORIZED:401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
} catch (Exception e) {
e.printStackTrace();
// HttpStatus.UNAUTHORIZED:401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 6. 放行
return chain.filter(exchange);
}
/**
* 优先级设置 值越小 优先级越高
*
* @return
*/
public int getOrder() {
return 0;
}
} -
启动user服务,继续访问其他微服务,会提示需要认证才能访问,这个时候需要在heads中设置设置token才能正常访问
app端 前端项目集成

- 通过nginx的反向代理功能访问后台的网关资源
- 通过nginx的静态服务器功能访问前端静态页面

-
在 nginx 安装的 conf 目录下新建一个文件夹
todaynews.conf,在当前文件夹中新建todaynews-app.conf文件,配置如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20upstream heima-app-gateway{
server localhost:51601;
}
server {
listen 8801;
location / {
root D:/BaiduNetdiskDownload/todaynews/app-web/;
index index.html;
}
location ~/app/(.*) {
proxy_pass http://heima-app-gateway/$1;
proxy_set_header HOST $host; # 不改变源请求头的值
proxy_pass_request_body on; #开启获取请求体
proxy_pass_request_headers on; #开启获取请求头
proxy_set_header X-Real-IP $remote_addr; # 记录真实发出请求的客户端IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; #记录代理信息
}
} -
nginx.conf 把里面注释的内容和静态资源配置相关删除,引入 todaynews-app.conf文件加载,配置如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14#user nobody;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# 引入自定义配置文件
include todaynews.conf/*.conf;
}
app端 文章列表
导入数据库 leadnews_article

为什么文章表要拆分成多个表?
- 垂直分表:将一个表的字段分散到多个表中,每个表存储其中一部分字段
- 优势:
- 减少IO争抢,减少锁表的几率,查看文章概述与文章详情互不影响
- 充分发挥高频数据的操作效率,对文章概述数据操作的高效率不会被操作文章详情数据的低效率所累
- 拆分规则:
- 把不常用的字段单独放在一张表
- 把 text,blob 等大字段拆分出来单独放在一张表
- 经常组合查询的字段单独放在一张表中
文章列表加载
实现思路:
- 在默认频道展示10条文章信息
- 可以切换频道查看不同种类文章
- 当用户下拉可以加载最新的文章(分页)本页文章列表中发布时间为最大的时间为依据
- 当用户上拉可以加载更多的文章信息(按照发布时间)本页文章列表中发布时间最小的时间为依据
- 如果是当前频道的首页,前端传递默认参数:
- minBehotTime:0(毫秒)
- maxBehotTime:20000000000000(毫秒)—>2063年
表结构分析:
-
ap_article 文章基本信息表

-
ap_article_config 文章配置表

-
ap_article_content 文章内容表

-
三表之间的关系

接口定义:
| 加载首页 | 加载更多 | 加载最新 | |
|---|---|---|---|
| 接口路径 | /api/v1/article/load |
/api/v1/article/loadmore |
/api/v1/article/loadnew |
| 请求方式 | POST | POST | POST |
| 参数 | ArticleHomeDto | ArticleHomeDto | ArticleHomeDto |
| 响应结果 | ResponseResult | ResponseResult | ResponseResult |
注意:POST请求可以把数据携带在请求体里,GET请求只能将数据拼接在uri的后面,请求参数比较多一般用POST
其中,ArticleHomeDto 为:
1 | package com.heima.model.article.dtos; |
app端 文章列表加载的步骤:
- 在 nacos 中添加对应的配置
- 定义接口,包括:接口路径、请求方式、入参、出参
- 编写mapper文件,包括:文章表与文章配置表多表查询
- 编写业务层代码
- 编写控制器代码
- swagger测试或前后端联调测试
-
在 nacos 中添加对应的配置
1
2
3
4
5
6
7
8
9
10
11spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/leadnews_article?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: 123456
# 设置Mapper接口所对应的XML文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
# 设置别名包扫描路径,通过该属性可以给包中的类注册别名
type-aliases-package: com.heima.model.article.pojos -
定义接口,在
com.heima.article.controller.v1目录下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class ArticleHomeController {
/**
* 加载首页
*
* @param dto
* @return
*/
public ResponseResult load( ArticleHomeDto dto) {
return null;
}
/**
* 加载更多
*
* @param dto
* @return
*/
public ResponseResult loadmore( ArticleHomeDto dto) {
return null;
}
/**
* 加载最新
*
* @param dto
* @return
*/
public ResponseResult loadnew( ArticleHomeDto dto) {
return null;
}
} -
编写mapper文件
-
在
com.heima.article.mapper目录下,编写一个接口文件1
2
3
4
5
6
7
8
9
10
11
12
public interface ApArticleMapper extends BaseMapper<ApArticle> {
/**
* 加载文章列表
* @param dto
* @param type 1 加载更多 2 加载最新
* @return
*/
public List<ApArticle> loadArticleList(ArticleHomeDto dto, Short type);
} -
在
resources.mapper目录下,编写mapper对应的映射文件 ApArticleMapper.xml1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<mapper namespace="com.heima.article.mapper.ApArticleMapper">
<resultMap id="resultMap" type="com.heima.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>
</mapper>
-
-
编写业务层代码
-
在
com.heima.article.service目录下定义接口1
2
3
4
5
6
7
8
9
10
11public interface ApArticleService extends IService<ApArticle> {
/**
* 加载文章列表
* @param dto
* @param type 1 加载更多 2 加载最新
* @return
*/
public ResponseResult load(ArticleHomeDto dto, Short type);
} -
在
com.heima.article.service.Impl目录下定义实现类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle> implements ApArticleService {
private ApArticleMapper apArticleMapper;
private final static short MAX_PAGE_SIZE = 50;
/**
* public static final Short LOADTYPE_LOAD_MORE = 1;
*
* public static final Short LOADTYPE_LOAD_NEW = 2;
*
* public static final String DEFAULT_TAG = "__all__";
*/
/**
* 加载文章列表
*
* @param dto
* @param type 1 加载更多 2 加载最新
* @return
*/
public ResponseResult load(ArticleHomeDto dto, Short type) {
// 1. 检验参数
// 分页条数的校验
Integer size = dto.getSize();
if (size == null || size == 0) {
size = 10;
}
// 分页值不超过50
size = Math.min(size, MAX_PAGE_SIZE);
dto.setsize(size);
// 校验参数 --> type
if (!type.equals(ArticleConstants.LOADTYPE_LOAD_MORE) && !type.equals(ArticleConstants.LOADTYPE_LOAD_NEW)) {
type = ArticleConstants.LOADTYPE_LOAD_MORE;
}
// 频道参数的校验
if (StringUtils.isBlank(dto.getTag())) {
dto.setTag(ArticleConstants.DEFAULT_TAG);
}
// 时间校验
if (dto.getMaxBehotTime() == null) {
dto.setMaxBehotTime(new Date());
}
if (dto.getMinBehotTime() == null) {
dto.setMinBehotTime(new Date());
}
// 2. 查询
List<ApArticle> articleList = apArticleMapper.loadArticleList(dto, type);
// 3. 返回
return ResponseResult.okResult(articleList);
}
}
-
-
编写控制器代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ArticleHomeController {
private ApArticleService apArticleService;
/**
* 加载首页
*
* @param dto
* @return
*/
public ResponseResult load( ArticleHomeDto dto) {
return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_MORE);
}
/**
* 加载更多
*
* @param dto
* @return
*/
public ResponseResult loadmore( ArticleHomeDto dto) {
return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_MORE);
}
/**
* 加载最新
*
* @param dto
* @return
*/
public ResponseResult loadnew( ArticleHomeDto dto) {
return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_NEW);
}
} -
还需要配置这个微服务的网关路由,在 nacos 中进行配置 leadnews-app-gateway
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30spring:
cloud:
gateway:
globalcors:
add-to-simple-url-handler-mapping: true
corsConfigurations:
'[/**]':
allowedHeaders: "*"
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- DELETE
- PUT
- OPTION
routes:
# 用户微服务
- id: user
uri: lb://leadnews-user
predicates:
- Path=/user/**
filters:
- StripPrefix= 1
# 文章微服务
- id: article
uri: lb://leadnews-article
predicates:
- Path=/article/**
filters:
- StripPrefix= 1
app端 文章详情
实现方案一:用户某一条文章,根据文章的id去查询文章内容表,返回渲染页面

实现方案二:静态模板展示

Freemarker
Freemarker 是一款 模板引擎技术 : 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。


注意:freemarker 模板文件通常都是以 ftl 作为扩展名,也可以为 html 、xml 、jsp 等
测试案例:
-
导入依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- apache 对 java io 的封装工具库 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-io</artifactId>
<version>1.3.2</version>
</dependency>
</dependencies> -
配置 application.yml
1
2
3
4
5
6
7
8
9
10server:
port: 8881 #服务端口
spring:
application:
name: freemarker-demo #指定服务名
freemarker:
cache: false #关闭模板缓存,方便测试
settings:
template_update_delay: 0 #检查模板更新延迟时间,设置为0表示立即检查,如果时间大于0会有缓存不方便进行模板测试
suffix: .ftl #指定Freemarker模板文件的后缀名 -
在resources下创建templates,此目录为 freemarker的默认模板存放目录
-
在templates下创建模板文件 01-basic.ftl ,模板中的插值表达式最终会被freemarker替换成具体的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18<!--模板文件 01-basic.ftl-->
<html>
<head>
<meta charset="utf-8">
<title>Hello World!</title>
</head>
<body>
<b>普通文本 String 展示:</b><br><br>
Hello ${name} <br>
<hr>
<b>对象Student中的数据展示:</b><br/>
姓名:${stu.name}<br/>
年龄:${stu.age}
<hr>
</body>
</html> -
创建一个 Student 类
1
2
3
4
5
6
7
public class Student {
private String name;//姓名
private int age;//年龄
private Date birthday;//生日
private Float money;//钱包
} -
创建Controller类,通过Model类来传参,最后返回模板文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class HelloController {
public String hello(Model model) {
// name
model.addAttribute("name","freemarker");
// stu
Student student = new Student();
student.setName("xiaoming");
student.setAge(18);
model.addAttribute("stu",student);
return "01-basic";
}
} -
创建启动类
1
2
3
4
5
6
public class FreemarkerDemoApplication {
public static void main(String[] args) {
SpringApplication.run(FreemarkerDemoApplication.class, args);
}
} -
测试,访问 http://localhost:8881/basic

Freemarker 指令语法
-
注释
1
<#-- ... -->
-
插值
1
${...}
-
FTL指令:和HTML标记类似,名字前加#予以区分,Freemarker会解析标签中的表达式或逻辑
1
<# > FTL指令 </#>
-
文本:仅文本信息,这些不是freemarker的注释、插值、FTL指令的内容会被freemarker忽略解析,直接输出内容
集合指令 - List:用来遍历 list
1 | <#list></#list> |
eg:
1 | <#list stus as stu> |
集合指令 - Map:用来遍历 map
-
获取 map 中的值
1
map['keyname'].property
1
map.keyname.property
-
遍历 map
1
2
3<#list userMap?keys as key>
key:${key} -- value:${userMap["${key}"]}
</#list>
eg:
1 | <a href="###">方式一:通过map['keyname'].property</a><br/> |
if指令:if 指令即判断指令,是常用的FTL指令,freemarker在解析时遇到if会进行判断,条件为真则输出if中间的内容,否则跳过内容不再输出。
1 | <#if ></if> |
1 | <#if expression> |
注意:在freemarker中,判断是否相等,= 与 == 是一样的
eg:
1 | <table> |
运算符:
-
算数运算符:FreeMarker表达式中完全支持算术运算
- 加法:
+ - 减法:
- - 乘法:
* - 除法:
/ - 求模 (求余):
%
- 加法:
-
比较运算符
=或者==:判断两个值是否相等.!=:判断两个值是否不等.>或者gt:判断左边值是否大于右边值>=或者gte:判断左边值是否大于等于右边值<或者lt:判断左边值是否小于右边值<=或者lte:判断左边值是否小于等于右边值
-
比较运算符注意事项:
- **
=和!=**可以用于字符串、数值和日期来比较是否相等 - **
=和!=**两边必须是相同类型的值,否则会产生错误 - 字符串
"x"、"x "、**"X"**比较是不等的.因为FreeMarker是精确比较 - 其它的运行符可以作用于数字和日期,但不能作用于字符串
- 使用**
gt等字母运算符代替>会有更好的效果,因为 FreeMarker会把>**解释成FTL标签的结束字符 - 可以使用括号来避免这种情况,如:
<#if (x>y)>
- **
-
逻辑运算符
- 逻辑与:&&
- 逻辑或:||
- 逻辑非:!
-
空值处理
-
判断某变量是否存在使用
??,用法为:variable??,如果该变量存在,返回true,否则返回false1
2
3
4
5
6
7<#-- 为防止stus为空报错可以加上判断 -->
<#if stus??>
<#list stus as stu>
......
</#list>
</#if> -
缺失变量默认值使用
!-
使用
!要以指定一个默认值,当变量为空时显示默认值1
2
3${name!}
<#-- 表示如果name为空显示空字符串 --> -
如果是嵌套对象则建议使用
()括起来1
2
3${(stu.bestFriend.name)!}
<#-- 如果stu或bestFriend或name为空默认显示空字符串 -->
-
-
内建函数:
1 | 变量 + ? + 函数名称 |
-
和到某个集合的大小
1
${集合名?size}
-
日期格式化
显示年月日:
${today?date}
显示时分秒:${today?time}
显示日期+时间:${today?datetime}
自定义格式化:${today?string("yyyy年MM月")} -
内建函数
c使用c函数将数字型转成字符串输出:
${point?c} -
将 json 字符串转成对象
eval1
2
3
4<#-- assign标签,assign的作用是定义一个变量 -->
<#assign text="{'bank':'工商银行','account':'10101920201920212'}" />
<#assign data=text?eval />
输出静态化文件
使用 freemarker 原生api 将 页面 生成 html 文件

-
修改 application.yml 文件,添加以下模板存放位置的配置信息
1
2
3
4
5
6
7
8
9
10
11server:
port: 8881 #服务端口
spring:
application:
name: freemarker-demo #指定服务名
freemarker:
cache: false #关闭模板缓存,方便测试
settings:
template_update_delay: 0 #检查模板更新延迟时间,设置为0表示立即检查,如果时间大于0会有缓存不方便进行模板测试
suffix: .ftl #指定Freemarker模板文件的后缀名
template-loader-path: classpath:/templates -
在 test 下创建测试类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class FreemarkerTest {
private Configuration configuration;
public void test() throws IOException, TemplateException {
Template template = configuration.getTemplate("01-basic.ftl");
/**
* 合成方法
*
* 两个参数
* 第一个参数:模型数据
* 第二个参数:输出流
*/
template.process(getDate(), new FileWriter("d:\\list.html"));
}
private Map getDate() {
Map<String, Object> map = new HashMap<>();
//小强对象模型数据
Student stu1 = new Student();
stu1.setName("小强");
stu1.setAge(18);
stu1.setMoney(1000.86f);
stu1.setBirthday(new Date());
//小红对象模型数据
Student stu2 = new Student();
stu2.setName("小红");
stu2.setMoney(200.1f);
stu2.setAge(19);
//将两个对象模型数据存放到List集合中
List<Student> stus = new ArrayList<>();
stus.add(stu1);
stus.add(stu2);
//向map中存放List集合数据
map.put("stus", stus);
//创建Map数据
HashMap<String, Student> stuMap = new HashMap<>();
stuMap.put("stu1", stu1);
stuMap.put("stu2", stu2);
//向map中存放Map数据
map.put("stuMap", stuMap);
//返回Map
return map;
}
}
MinIO


MinIO基于Apache License v2.0开源协议的对象存储服务,可以做为云存储的解决方案用来保存海量的图片,视频,文档。由于采用Golang实现,服务端可以工作在Windows,Linux, OS X和FreeBSD上。配置简单,基本是复制可执行程序,单行命令可以运行起来。
Docker安装流程
-
拉取镜像
1
docker pull minio/minio
-
创建容器
1
docker run -d \-p 9000:9000 \-p 9001:9001 \--name minio --restart=always --privileged=true \-v /home/minio/data:/data \-e "MINIO_ROOT_USER=minio" \-e "MINIO_ROOT_PASSWORD=minio123" \minio/minio server /data --console-address ":9001"
-
访问 minio 系统

Minio的使用
- bucket – 类比于文件系统的目录
- Object – 类比文件系统的文件
- Keys – 类比文件名
Minio上传文件进行静态访问
-
导入依赖
1
2
3
4
5<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>7.1.0</version>
</dependency> -
创建测试类,上传html文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33public class MinIOTest {
/**
* 将list.html上传到minio中,并且可以在浏览器中访问
*
* @param args
*/
public static void main(String[] args) {
try {
FileInputStream fileInputStream = new FileInputStream("D:\\list.html");
// 1. 获取minio的链接信息,创建一个minio的客户端
MinioClient minioClient = MinioClient.builder()
.credentials("minio", "minio123")
.endpoint("http://192.168.88.129:9000")
.build();
// 2. 上传
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.object("list.html") // 文件名称
.contentType("text/html") // 文件类型
.bucket("leadnews") // 桶名词 与minio创建的名词一致
.stream(fileInputStream, fileInputStream.available(), -1) // 文件流
.build();
minioClient.putObject(putObjectArgs);
// 访问路径
System.out.println("http://192.168.88.129:9000/leadnews/list.html");
} catch (Exception e) {
e.printStackTrace();
}
}
}
封装MinIO为starter

在heima.leadnews.starter目录下封装了MinIO
1 |
|
在项目中集成:
-
导入文件管理依赖,在 pom.xml 文件下
1
2
3
4
5<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-file-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency> -
添加minio配置文件,在 resources下面创建 application.yml
1
2
3
4
5
6minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://192.168.88.129:9000
readPath: http://192.168.88.129:9000 -
在spring管理的bean中注入FileStorageService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MinIOTest {
/**
* 将list.html上传到minio中,并且可以在浏览器中访问
*
* @param args
*/
private FileStorageService fileStorageService;
public void test() throws FileNotFoundException {
FileInputStream fileInputStream = new FileInputStream("D:\\list.html");
String path = fileStorageService.uploadHtmlFile("", "list.html", fileInputStream);
System.out.println(path);
}
}
文章详情页的展示
实现步骤:
-
在article微服务中添加MinIO和freemarker的支持
1
2
3
4
5
6
7
8
9
10
11<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!--集成了minio的配置文件-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-file-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency> -
在nacos的leadnews-article中在后面添加配置信息
1
2
3
4
5
6minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://192.168.88.129:9000
readPath: http://192.168.88.129:9000 -
将模板文件(article.ftl)拷贝到article微服务下

-
将 index.js 和 index.css 两个文件手动上传到MinIO中(为了加快文件的响应速度,在js文件夹中上传了axios.min.js文件)

-
新建 ApArticleContentMapper
1
2public interface ApArticleContentMapper extends BaseMapper<ApArticleContent> {
} -
在 artile 微服务中新增 测试类(暂时手动生成详情静态页)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class ArticleFreemarkerTest {
private ApArticleContentMapper apArticleContentMapper;
private Configuration configuration;
private FileStorageService fileStorageService;
private ApArticleService apArticleService;
public void createStaticUrlTest() throws Exception {
// 已知文章id
// 1. 获取文章内容
ApArticleContent apArticleContent = apArticleContentMapper
.selectOne(Wrappers.<ApArticleContent>lambdaQuery()
.eq(ApArticleContent::getArticleId, "1383827787629252610L"));
if (apArticleContent != null && StringUtils.isNotBlank(apArticleContent.getContent())) {
// 2. 文章内容通过freemarker生成html文件
Template template = configuration.getTemplate("article.ftl");
// 数据模型
Map<String, Object> content = new HashMap<>();
content.put("content", JSONArray.parseArray(apArticleContent.getContent()));
StringWriter out = new StringWriter();
// 合成
template.process(content, out);
// 3. 把html文件上传到minio中
InputStream in = new ByteArrayInputStream(out.toString().getBytes());
String path = fileStorageService.uploadHtmlFile("", apArticleContent.getArticleId() + ".html", in);
// 4. 修改ap_article表,保存static_url字段
apArticleService.update(Wrappers.<ApArticle>lambdaUpdate().eq(ApArticle::getId, apArticleContent.getArticleId())
.set(ApArticle::getStaticUrl, path));
}
}
}
自媒体 文章发布
自媒体后台搭建

自媒体后台搭建流程:
- 基础环境 和 数据准备
- sql 脚本
- 拷贝对应的实体类到
heima-leadnews-model模块下的com.heima.model
- 在
heima-leadnews-service下指定初始子模块 - 添加对应的 nacos 配置
- 在
heima-leadnews-gateway工程下指定初始子模块 - 添加对应的 nacos 配置
- 启动 gateway 与service 的SpringApplication的启动类,等待前端搭建完联调
自媒体前端搭建
通过nginx的虚拟主机功能,使用同一个nginx访问多个项目

自媒体前端搭建流程:
- 在 nginx 中 leadnews.conf 目录中新增 heima-leadnews-wemedia.conf 文件
- 网关地址修改(localhost:51602)
- 前端项目目录修改(wemedia-web解压的目录)
- 访问端口修改(8802)
1 | upstream heima-wemedia-gateway{ |
-
启动nginx,访问 http://localhost:8802

自媒体素材管理
-
需求分析
- 图片上传的页面,首先是展示素材信息,可以点击图片上传,弹窗后可以上传图片
-
表结构分析
-
wm_material 媒体图文素材信息表

-
-
创建对应的实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52/**
* <p>
* 自媒体图文素材信息表
* </p>
*
* @author itheima
*/
public class WmMaterial implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Integer id;
/**
* 自媒体用户ID
*/
private Integer userId;
/**
* 图片地址
*/
private String url;
/**
* 素材类型
0 图片
1 视频
*/
private Short type;
/**
* 是否收藏
*/
private Short isCollection;
/**
* 创建时间
*/
private Date createdTime;
} -
实现流程

-
token解析为用户存入header,在
com.heima.wemedia.gateway.filter.AuthorizeFilter.java文件中1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class AuthorizeFilter implements Ordered, GlobalFilter {
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取request和response对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//2.判断是否是登录
if (request.getURI().getPath().contains("/login")) {
//放行
return chain.filter(exchange);
}
//3.获取token
String token = request.getHeaders().getFirst("token");
//4.判断token是否存在
if (StringUtils.isBlank(token)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//5.判断token是否有效
try {
Claims claimsBody = AppJwtUtil.getClaimsBody(token);
//是否是过期
int result = AppJwtUtil.verifyToken(claimsBody);
if (result == 1 || result == 2) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 获取用户信息
Object userId = claimsBody.get("id");
// 存入到header中
request.mutate().headers(httpHeaders -> {
httpHeaders.add("userId", userId + "");
}).build();
// 重置请求
exchange.mutate().request(request);
} catch (Exception e) {
e.printStackTrace();
}
//6.放行
return chain.filter(exchange);
}
/**
* 优先级设置 值越小 优先级越高
*
* @return
*/
public int getOrder() {
return 0;
}
} -
创建拦截器,从header中获取用户存入当前线程
-
先在
com.heima.utils.thread.WmThreadLocalUtil.java下创建一个可以创建线程,获取线程以及清楚线程的工具类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class WmThreadLocalUtil {
private final static ThreadLocal<WmUser> WM_USER_THREAD_LOCAL = new ThreadLocal<>();
// 存入线程
public static void setUser(WmUser wmUser) {
WM_USER_THREAD_LOCAL.set(wmUser);
}
// 从线程中获取
public static WmUser getUser() {
return WM_USER_THREAD_LOCAL.get();
}
// 清理
public static void clear() {
WM_USER_THREAD_LOCAL.remove();
}
} -
在
com.heima.wemedia.interceptor.WmTokenInterceptor.java文件下定义拦截器1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41public class WmTokenInterceptor implements HandlerInterceptor {
/**
* 得到header中的用户信息,并且存入到当前线程中
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String userId = request.getHeader("userId");
if (userId != null) {
// 存入到当前线程中
WmUser wmUser = new WmUser();
wmUser.setId(Integer.valueOf(userId));
WmThreadLocalUtil.setUser(wmUser);
}
return true;
}
/**
* 清理线程中的数据
*
* @param request
* @param response
* @param handler
* @param modelAndView
* @throws Exception
*/
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
WmThreadLocalUtil.clear();
}
} -
在
com.heima.wemedia.config.WebMvcConfig.java文件下配置拦截器生效,拦截所有的请求1
2
3
4
5
6
7
public class WebMvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new WmTokenInterceptor()).addPathPatterns("/**");
}
}
-
-
上传图片

-
在
com.heima.wemedia.controller.v1.WmMaterialController.java文件下定义接口1
2
3
4
5
6
7
8
9
10
public class WmMaterialController {
public ResponseResult uploadPicture(MultipartFile multipartFile){
return null;
}
} -
在
com.heima.wemedia.mapper.WmMaterialMapper.java中创建对应的mapper文件1
2
3
public interface WmMaterialMapper extends BaseMapper<WmMaterial> {
} -
在
com.heima.wemedia.service.WmMaterialService.java中创建业务层的接口类,并定义对应要实现的接口1
2
3
4
5
6
7
8
9public interface WmMaterialService extends IService<WmMaterial> {
/**
* 图片上传
* @param multipartFile
* @return
*/
public ResponseResult uploadPicture(MultipartFile multipartFile);
} -
在
com.heima.wemedia.service.impl.WmMaterialServiceImpl.java中完成对应业务成的实现类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class WmMaterialServiceImpl extends ServiceImpl<WmMaterialMapper, WmMaterial> implements WmMaterialService {
private FileStorageService fileStorageService;
/**
* 图片上传
*
* @param multipartFile
* @return
*/
public ResponseResult uploadPicture(MultipartFile multipartFile) {
// 1. 检查参数
if (multipartFile == null || multipartFile.getSize() == 0) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
// 2. 上传图片到MinIO中
// 保证生成的图片不重复,可以用uuid
String fileName = UUID.randomUUID().toString().replace("-", "");
// 比如 originalFilename 获取的名字是 aa.jpg
String originalFilename = multipartFile.getOriginalFilename();
// 截取 . 的后半部分,比如:.jpg
String postfix = originalFilename.substring(originalFilename.lastIndexOf("."));
String fileId = null;
try {
fileId = fileStorageService
.uploadImgFile("", fileName + postfix, multipartFile.getInputStream());
log.info("上传图片到MinIO中,fileId:{}", fileId); // 打印上传成功的日志
} catch (IOException e) {
e.printStackTrace();
log.error("WmMaterialServiceImpl-上传图片失败"); // 打印上传失败的日志
}
// 3. 把图片保存到数据库中
WmMaterial wmMaterial = new WmMaterial();
wmMaterial.setUserId(WmThreadLocalUtil.getUser().getId());
wmMaterial.setUrl(fileId);
wmMaterial.setIsCollection((short) 0); // 是否收藏 0 不收藏 1 收藏
wmMaterial.setType((short) 0); // 0 图片 1 视频
wmMaterial.setCreatedTime(new Date());
save(wmMaterial);
// 4. 返回结果
return ResponseResult.okResult(wmMaterial);
}
} -
上面的实现类用到了MinIO,所以要分别在业务层的pom.xml中填入对应的依赖以及在nacos中添加对应的配置信息
1
2
3
4
5
6
7<dependencies>
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-file-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>1
2
3
4
5
6minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://192.168.88.129:9000
readPath: http://192.168.88.129:9000 -
完成
com.heima.wemedia.controller.v1.WmMaterialController.java1
2
3
4
5
6
7
8
9
10
11
12
public class WmMaterialController {
private WmMaterialService wmMaterialService;
public ResponseResult uploadPicture(MultipartFile multipartFile) {
return wmMaterialService.uploadPicture(multipartFile);
}
}
-
-
图片列表

-
在
com.heima.wemedia.controller.v1.WmMaterialController.java文件下定义接口1
2
3
public ResponseResult findList( WmMaterialDto dto) {
} -
在
com.heima.model.wemedia.dtos.WmMaterialDto下创建这个接口需要的DTO1
2
3
4
5
6
7
8
public class WmMaterialDto extends PageRequestDto {
/**
* 1 收藏
* 0 未收藏
*/
private Short isCollection;
} -
在
com.heima.wemedia.service.WmMaterialService.java文件下创建业务层的接口类1
2
3
4
5
6/**
* 素材列表查询
* @param dto
* @return
*/
public ResponseResult findList(WmMaterialDto dto); -
在
com.heima.wemedia.service.impl.WmMaterialServiceImpl.java文件下来实现业务成对应的接口1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39/**
* 素材列表查询
*
* @param dto
* @return
*/
public ResponseResult findList(WmMaterialDto dto) {
// 1. 检查参数
dto.checkParam();
/**
public void checkParam() {
if (this.page == null || this.page < 0) {
setPage(1);
}
if (this.size == null || this.size < 0 || this.size > 100) {
setSize(10);
}
}
*/
// 2. 分页查询
IPage page = new Page(dto.getPage(), dto.getSize());
LambdaQueryWrapper<WmMaterial> lambdaQueryWrapper = new LambdaQueryWrapper<>();
// 是否收藏
if (dto.getIsCollection() != null && dto.getIsCollection() == 1) {
lambdaQueryWrapper.eq(WmMaterial::getIsCollection, dto.getIsCollection());
}
// 按照用户查询
lambdaQueryWrapper.eq(WmMaterial::getUserId,WmThreadLocalUtil.getUser().getId());
// 按照时间倒叙查询
lambdaQueryWrapper.orderByDesc(WmMaterial::getCreatedTime);
page(page, lambdaQueryWrapper);
// 3. 结果返回
ResponseResult responseResult = new PageResponseResult(dto.getPage(), dto.getSize(), (int)page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
} -
把
com.heima.wemedia.controller.v1.WmMaterialController.java文件补全1
2
3
4
public ResponseResult findList( WmMaterialDto dto) {
return wmMaterialService.findList(dto);
} -
在启动类加上mybatis-plus的分页插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class WemediaApplication {
public static void main(String[] args) {
SpringApplication.run(WemediaApplication.class, args);
}
// Mybatis-Plus的分页插件
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
-
-
自媒体文章管理
频道列表查询
-
表结构分析
-
wm_channel 频道信息表

-
-
创建对应数据库表的实体类 wm_channel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class WmChannel implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
/**
* 频道名称
*/
private String name;
/**
* 频道描述
*/
private String description;
/**
* 是否默认频道
* 1:默认 true
* 0:非默认 false
*/
private Boolean isDefault;
/**
* 是否启用
* 1:启用 true
* 0:禁用 false
*/
private Boolean status;
/**
* 默认排序
*/
private Integer ord;
/**
* 创建时间
*/
private Date createdTime;
} -
频道列表查询对应的接口

-
接口定义
1
2
3
4
5
6
7
8
9
10
public class WmchannelController {
public ResponseResult findAll(){
return null;
}
} -
mapper
1
2
3
public interface WmChannelMapper extends BaseMapper<WmChannel> {
} -
service
1
2
3
4
5
6
7
8public interface WmChannelService extends IService<WmChannel> {
/**
* 查询所有频道
* @return
*/
public ResponseResult findAll();
} -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class WmChannelServiceImpl extends ServiceImpl<WmChannelMapper, WmChannel> implements WmChannelService {
/**
* 查询所有频道
*
* @return
*/
public ResponseResult findAll() {
return ResponseResult.okResult(list());
// list()是mybatis-plus中的方法
}
} -
补全controller
1
2
3
4
5
6
7
8
9
10
11
12
public class WmChannelController {
private WmChannelService wmChannelService;
public ResponseResult findAll() {
return wmChannelService.findAll();
}
}
-
文章列表加载
-
表结构分析
-
wm_news 自媒体文章表

-
-
创建对应数据库表的实体类 wm_news
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
public class WmNews implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Integer id;
/**
* 自媒体用户ID
*/
private Integer userId;
/**
* 标题
*/
private String title;
/**
* 图文内容
*/
private String content;
/**
* 文章布局
0 无图文章
1 单图文章
3 多图文章
*/
private Short type;
/**
* 图文频道ID
*/
private Integer channelId;
private String labels;
/**
* 创建时间
*/
private Date createdTime;
/**
* 提交时间
*/
private Date submitedTime;
/**
* 当前状态
0 草稿
1 提交(待审核)
2 审核失败
3 人工审核
4 人工审核通过
8 审核通过(待发布)
9 已发布
*/
private Short status;
/**
* 定时发布时间,不定时则为空
*/
private Date publishTime;
/**
* 拒绝理由
*/
private String reason;
/**
* 发布库文章ID
*/
private Long articleId;
/**
* //图片用逗号分隔
*/
private String images;
private Short enable;
//状态枚举类
public enum Status{
NORMAL((short)0),SUBMIT((short)1),FAIL((short)2),ADMIN_AUTH((short)3),ADMIN_SUCCESS((short)4),SUCCESS((short)8),PUBLISHED((short)9);
short code;
Status(short code){
this.code = code;
}
public short getCode(){
return this.code;
}
}
} -
接口定义

-
接口定义
1
2
3
4
5
6
7
8
9
10
public class WmNewsController {
// 带参数查询一般都用 @PostMapping
public ResponseResult findList( WmNewsPageReqDto dto) {
return null;
}
} -
定义 WmNewsPageReqDto 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class WmNewsPageReqDto extends PageRequestDto {
/**
* 状态
*/
private Short status;
/**
* 开始时间
*/
private Date beginPubDate;
/**
* 结束时间
*/
private Date endPubDate;
/**
* 所属频道ID
*/
private Integer channelId;
/**
* 关键字
*/
private String keyword;
} -
mapper
1
2
3
4
public interface WmNewsMapper extends BaseMapper<WmNews> {
} -
service
1
2
3
4
5
6
7
8
9
10public interface WmNewsService extends IService<WmNews> {
/**
* 条件查询文章列表
* @param dto
* @return
*/
public ResponseResult findList(WmNewsPageReqDto dto);
} -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class WmNewsServiceImpl extends ServiceImpl<WmNewsMapper, WmNews> implements WmNewsService {
/**
* 条件查询文章列表
*
* @param dto
* @return
*/
public ResponseResult findList(WmNewsPageReqDto dto) {
// 1. 检查参数
dto.checkParam();
// 2. 分页的条件查询
IPage page = new Page(dto.getPage(), dto.getSize());
LambdaQueryWrapper<WmNews> lambdaQueryWrapper = new LambdaQueryWrapper();
// 状态精确查询
if (dto.getStatus() != null) {
lambdaQueryWrapper.eq(WmNews::getStatus, dto.getStatus());
}
// 频道精确查询
if (dto.getChannelId() != null) {
lambdaQueryWrapper.eq(WmNews::getChannelId, dto.getChannelId());
}
// 时间范围查询
if (dto.getBeginPubDate() != null && dto.getEndPubDate() != null) {
lambdaQueryWrapper.between(WmNews::getSubmitedTime, dto.getBeginPubDate(), dto.getEndPubDate());
}
// 关键字的模糊查询
if (StringUtils.isNotBlank(dto.getKeyword())) {
lambdaQueryWrapper.like(WmNews::getTitle, dto.getKeyword());
}
// 查询当前登录人的文章
lambdaQueryWrapper.eq(WmNews::getUserId, WmThreadLocalUtil.getUser().getId());
// 按照发布时间倒叙查询
lambdaQueryWrapper.orderByDesc(WmNews::getPublishTime);
page(page, lambdaQueryWrapper);
// 3. 结果返回
ResponseResult responseResult = new PageResponseResult(dto.getPage(), dto.getSize(), (int) page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
}
} -
补全controller
1
2
3
4
5
6
7
8
9
10
11
12
13
public class WmNewsController {
private WmNewsService wmNewsService;
// 带参数查询一般都用 @PostMapping
public ResponseResult findList( WmNewsPageReqDto dto) {
return wmNewsService.findList(dto);
}
}
-
文章发布、修改、保存草稿
-
需求分析

-
表结构的分析
-
wm_material 素材表

-
wm_news_material 文章素材关系表

-
保存文章,除了需要 wm_news 表以外,还需要 wm_material 素材表、wm_news_material 文章素材关系表

-
文章素材关系表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class WmNewsMaterial implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Integer id;
/**
* 素材ID
*/
private Integer materialId;
/**
* 图文ID
*/
private Integer newsId;
/**
* 引用类型
0 内容引用
1 主图引用
*/
private Short type;
/**
* 引用排序
*/
private Short ord;
}
-
-
实现流程

-
接口定义


-
WmNewsDto的DTO对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class WmNewsDto {
private Integer id;
/**
* 标题
*/
private String title;
/**
* 频道id
*/
private Integer channelId;
/**
* 标签
*/
private String labels;
/**
* 发布时间
*/
private Date publishTime;
/**
* 文章内容
*/
private String content;
/**
* 文章封面类型 0 无图 1 单图 3 多图 -1 自动
*/
private Short type;
/**
* 提交时间
*/
private Date submitedTime;
/**
* 状态 提交为1 草稿为0
*/
private Short status;
/**
* 封面图片列表 多张图以逗号隔开
*/
private List<String> images;
} -
接口定义
1
2
3
4
public ResponseResult submitNews( WmNewsDto wmNewsDto) {
return null;
} -
新增 WmNewsMaterialMapper 类,文章与素材的关联关系需要批量保存,索引需要定义mapper文件和对应的映射文件
1
2
3
4
5
6
7
8
9
10
public interface WmNewsMaterialMapper extends BaseMapper<WmNewsMaterial> {
/**
* 批量保存文章的方法
* @param materialIds
* @param newsId
* @param type
*/
void saveRelations( List<Integer> materialIds, Integer newsId, Short type);
} -
resources目录下的 WmNewsMaterialMapper.xml
1
2
3
4
5
6
7
8
9
10
11
<mapper namespace="com.heima.wemedia.mapper.WmNewsMaterialMapper">
<insert id="saveRelations">
insert into wm_news_material (material_id,news_id,type,ord)
values
<foreach collection="materialIds" index="ord" item="mid" separator=",">
(#{mid},#{newsId},#{type},#{ord})
</foreach>
</insert>
</mapper> -
常量类准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class WemediaConstants {
public static final Short COLLECT_MATERIAL = 1;//收藏
public static final Short CANCEL_COLLECT_MATERIAL = 0;//取消收藏
public static final String WM_NEWS_TYPE_IMAGE = "image";
public static final Short WM_NEWS_NONE_IMAGE = 0;
public static final Short WM_NEWS_SINGLE_IMAGE = 1;
public static final Short WM_NEWS_MANY_IMAGE = 3;
public static final Short WM_NEWS_TYPE_AUTO = -1;
public static final Short WM_CONTENT_REFERENCE = 0;
public static final Short WM_COVER_REFERENCE = 1;
} -
在 WmNewsService 中新增方法
1
2
3
4
5
6/**
* 发布修改文章、保存文章为草稿
* @param wmNewsDto
* @return
*/
public ResponseResult submitNews(WmNewsDto wmNewsDto); -
对应的实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163/**
* 发布修改文章、保存文章为草稿
*
* @param wmNewsDto
* @return
*/
public ResponseResult submitNews(WmNewsDto wmNewsDto) {
// 0. 条件判断
if (wmNewsDto == null || wmNewsDto.getContent() == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
// 1. 保存或修改文章
WmNews wmNews = new WmNews();
// 属性拷贝,属性名称和类型相同时才会拷贝
BeanUtils.copyProperties(wmNewsDto, wmNews);
// 封面图片 list ---> string
if (wmNewsDto.getImages() != null && wmNewsDto.getImages().size() > 0) {
// [wqqqdqd.jpg,oiqw.jpg] --> wqqqdqd.jpg,oiqw.jpg
String imageStr = StringUtils.join(wmNewsDto.getImages(), ",");
wmNews.setImages(imageStr);
}
// 如果当前封面类型为自动 -1
if (wmNewsDto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) {
wmNews.setType(null);
}
saveOrUpdateWmNews(wmNews);
// 2. 判断是否为草稿,如果为草稿结束当前方法
if (wmNewsDto.getStatus().equals(WmNews.Status.NORMAL.getCode())) {
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
// 3. 不是草稿,保存文章内容、图片与素材的关系
// 获取文章内容中的图片信息
List<String> materials = extractUrlInfo(wmNewsDto.getContent());
saveRelativeInfoForContent(materials, wmNews.getId());
// 4. 不是草稿,保存文章封面图片与素材的关系
// 如果当前布局是自动,需要匹配封面图片
saveRelativeInfoForCover(wmNewsDto, wmNews, materials);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
/**
* 第一个功能,如果当前封面类型为自动,则设置封面类型的数据
* 1,如果内容图片大于等于1,小于3 单图 type 1
* 2,如果内容图片大于等于3 多图 type 3
* 3,如果内容没有图片,无图 type 0
* <p>
* 第二个功能:保存封面图片与素材的关系
*
* @param wmNewsDto
* @param wmNews
* @param materials
*/
private void saveRelativeInfoForCover(WmNewsDto wmNewsDto, WmNews wmNews, List<String> materials) {
List<String> images = wmNewsDto.getImages();
// 如果当前封面类型为自动,则设置封面类型的数据
if (wmNewsDto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) {
// 多图
if (materials.size() >= 3) {
wmNews.setType(WemediaConstants.WM_NEWS_MANY_IMAGE);
images = materials.stream().limit(3).collect(Collectors.toList());
}
// 单图
else if (materials.size() > 1 && materials.size() < 3) {
wmNews.setType(WemediaConstants.WM_NEWS_SINGLE_IMAGE);
images = materials.stream().limit(1).collect(Collectors.toList());
}
// 无图
else {
wmNews.setType(WemediaConstants.WM_NEWS_NONE_IMAGE);
}
// 修改文章
if (images != null && images.size() > 0) {
wmNews.setImages(StringUtils.join(images, ","));
}
updateById(wmNews);
}
// 保存封面图片与素材的关系
if (images != null && images.size() > 0) {
saveRelativeInfo(images, wmNews.getId(), WemediaConstants.WM_COVER_REFERENCE);
}
}
/**
* 处理文章内容图片与素材的关系
*
* @param materials
* @param newsId
*/
private void saveRelativeInfoForContent(List<String> materials, Integer newsId) {
// WemediaConstants.WM_CONTENT_REFERENCE 为 0
saveRelativeInfo(materials, newsId, WemediaConstants.WM_CONTENT_REFERENCE);
}
private WmNewsMaterialMapper wmNewsMaterialMapper;
private WmMaterialMapper wmMaterialMapper;
/**
* 保存文章图片与素材的关系到数据库中
*
* @param materials
* @param newsId
* @param wmContentReference
*/
private void saveRelativeInfo(List<String> materials, Integer newsId, Short wmContentReference) {
if (materials != null && !materials.isEmpty()) {
// 通过图片的url查询素材的id
List<WmMaterial> dbMaterials = wmMaterialMapper
.selectList(Wrappers.<WmMaterial>lambdaQuery().in(WmMaterial::getUrl, materials));
// 判断素材是否有效
if (dbMaterials == null || dbMaterials.size() == 0) {
// 手动抛出异常
// 第一个功能:能够提示调用者素材失效了
// 第二个功能:进行数据的回滚
throw new CustomException(AppHttpCodeEnum.MATERIAL_REFERENCE_FAIL);
}
if (materials.size() != dbMaterials.size()) {
throw new CustomException(AppHttpCodeEnum.MATERIAL_REFERENCE_FAIL);
}
List<Integer> idList = dbMaterials.stream().map(WmMaterial::getId).collect(Collectors.toList());
// 批量保存
wmNewsMaterialMapper.saveRelations(idList, newsId, wmContentReference);
}
}
/**
* 提取文章内容中的图片信息
*
* @param content
* @return
*/
private List<String> extractUrlInfo(String content) {
List<String> materials = new ArrayList<>();
// 将Json字符串转换成Map格式
List<Map> maps = JSON.parseArray(content, Map.class);
for (Map map : maps) {
if (map.get("type").equals("image")) {
String imageUrl = (String) map.get("value");
materials.add(imageUrl);
}
}
return materials;
}
/**
* 保存或修改文章
*
* @param wmNews
*/
private void saveOrUpdateWmNews(WmNews wmNews) {
// 补全属性
wmNews.setUserId(WmThreadLocalUtil.getUser().getId());
wmNews.setCreatedTime(new Date());
wmNews.setSubmitedTime(new Date());
wmNews.setEnable((short) 1);
if (wmNews.getId() == null) {
// 保存
save(wmNews);
} else {
// 修改
// 删除文章图片与素材的关系
wmNewsMaterialMapper
.delete(Wrappers.<WmNewsMaterial>lambdaQuery()
.eq(WmNewsMaterial::getNewsId, wmNews.getId()));
updateById(wmNews);
}
} -
补全controller
1
2
3
4
public ResponseResult submitNews( WmNewsDto dto){
return wmNewsService.submitNews(dto);
}
自媒体 文章审核


内容安全第三方接口
内容安全是识别服务,支持对图片、视频、文本、语音等对象进行多样化场景检测,有效降低内容违规风险
按照性能和收费来看,本项目使用的就是阿里云的内容安全接口,使用到了图片和文本的审核
详见:开发参考_内容安全(Content Moderation)-阿里云帮助中心 (aliyun.com)
项目集成阿里云内容安全接口
流程
-
导入对应的依赖信息,参考接口 sdk 说明
1
2
3
4
5
6
7
8
9<!-- 在common模块下的pom文件 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-green</artifactId>
</dependency> -
拷贝资料文件夹中的类到 common模块 下面,并添加到自动配置
- 包括了 GreenImageScan 和 GreenTextScan 及对应的工具类
如何使用JavaSDK接口检测图片是否包含风险内容_内容安全(Content Moderation)-阿里云帮助中心 (aliyun.com)


-
在 bootstarp.yml 中添加阿里云的ak
-
accessKeyId 和 secret(需自己申请)
-
在heima-leadnews-wemedia中的nacos配置中心添加以下配置
1
2
3
4
5aliyun:
accessKeyId: XXX
secret: XXX
aliyun.scenes=porn,terrorism,ad,qrcode,live,logo
scenes: terrorism
-
-
在自媒体微服务中测试类中注入审核文本和图片的bean进行测试
app端 文章保存接口实现
分布式id
产生背景:随着业务的增长,文章表可能要占用很大的物理存储空间,为了解决该问题,后期使用数据库分片技术。将一个数据库进行拆分,通过数据库中间件连接。如果数据库中该表选用ID自增策略,则 可能产生重复的ID ,此时应该使用 分布式ID生成策略 来生成ID。

分布式id 的解决方案:

雪花算法
snowflake 是 Twitter开源 的 分布式ID生成算法,结果是一个 long型 的 ID
其 核心思想 是:使用 41bit 作为 毫秒数,10bit 作为 机器的ID( 5个bit 是 数据中心 (可以理解为 机房的id), 5个bit 的 机器ID (可以理解为 机器的id)), 12bit 作为毫秒内的 流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0

mybatis-plus 已经集成了雪花算法,完成以下两步即可在项目中集成雪花算法:
-
在实体类中的id上加入如下配置,指定类型为 id_worker
1
2
private Long id; -
在 application.yml 文件中配置 数据中心id 和 机器id
1
2
3
4
5
6
7mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
# 设置别名包扫描路径,通过该属性可以给包中的类注册别名
type-aliases-package: com.heima.model.article.pojos
global-config:
datacenter-id: 1
workerId: 1 -
也可以在对应的nacos中设置配置

思路分析


保存app端文章 - feign 接口

实现步骤
-
在
heima-leadnews-feign-api中定义接口导入 feign远程调用依赖1
2
3
4<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency> -
定义 ArticleDto
1
2
3
4
5
6
7
8
public class ArticleDto extends ApArticle {
/**
* 文章内容
*/
private String content;
} -
定义文章端的远程接口
1
2
3
4
5
6
7
public interface IArticleClient {
// @RequestBody:通常会使用这个注解来接收客户端发送的 JSON、XML 或其他格式的数据,并将其转换为 Java 对象
public ResponseResult saveArticle( ArticleDto dto);
} -
在
heima-leadnews-article实现feign接口,在路径下创建 feign 的软件包1
2
3
4
5
6
7
8
public class ArticleClient implements IArticleClient {
public ResponseResult saveArticle( ArticleDto dto) {
return null;
}
} -
创建 ApArticleConfigMapper 类到文章微服务mapper包下
1
2
3
4
public interface ApArticleConfigMapper extends BaseMapper<ApArticleConfig> {
} -
在 ApArticleService 中新增保存方法进行实现
1
2
3
4
5
6/**
* 保存app端相关文章
* @param dto
* @return
*/
public ResponseResult saveArticle(ArticleDto dto);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56/**
* 保存app端相关文章
*
* @param dto
* @return
*/
private ApArticleConfigMapper apArticleConfigMapper;
private ApArticleContentMapper apArticleContentMapper;
public ResponseResult saveArticle(ArticleDto dto) {
// 1. 检查参数
if (dto == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
ApArticle apArticle = new ApArticle();
BeanUtils.copyProperties(dto, apArticle);
// 2. 判断是否存在id
if (dto.getId() == null) {
// 2.1 不存在id,保存 文章 文章配置 文章内容
// 保存文章
save(apArticle);
// 保存配置
/**
* 这里重写了有参构造方法
*
* public ApArticleConfig(Long articleId) {
* this.articleId = articleId;
* this.isDelete = false;
* this.isDown = false;
* this.isForward = true;
* this.isComment = true;
* }
*/
ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
apArticleConfigMapper.insert(apArticleConfig);
// 保存文章内容
ApArticleContent apArticleContent = new ApArticleContent();
apArticleContent.setArticleId(apArticle.getId());
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.insert(apArticleContent);
} else {
// 2.2 存在id 修改 文章 文章内容
// 修改文章
updateById(apArticle);
// 修改文章内容
ApArticleContent apArticleContent = apArticleContentMapper.selectOne(Wrappers
.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, dto.getId()));
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.updateById(apArticleContent);
}
// 3. 结果返回 文章id
return ResponseResult.okResult(apArticle.getId());
} -
补全 feign 包下的 ArticleClient.java
1
2
3
4
5
6
7
8
9
10
11
12
13
public class ArticleClient implements IArticleClient {
private ApArticleService apArticleService;
public ResponseResult saveArticle( ArticleDto dto) {
return apArticleService.saveArticle(dto);
}
} -
postman进行测试
自媒体文章自动审核功能实现
表结构的说明

- status字段:0 草稿 1 待审核 2 审核失败 3 人工审核 4 人工审核通过 8 审核通过(待发布) 9 已发布
feign 远程接口调用方式

实现流程
-
在 heima-leadnews-wemedia 中的 service 新增接口
1
2
3
4
5
6
7
8
9public interface WmNewsAutoScanService {
/**
* 自媒体文章审核
* @param id 自媒体文章id
*/
public void autoScanWmNews(Integer id);
} -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {
/**
* 自媒体文章审核
*
* @param id 自媒体文章id
*/
private WmNewsMapper wmNewsMapper;
public void autoScanWmNews(Integer id) {
// 1. 查询自媒体文章
WmNews wmNews = wmNewsMapper.selectById(id);
if (wmNews == null) {
throw new RuntimeException("WmNewsAutoScanServiceImpl-文章不存在");
}
if (wmNews.getStatus().equals(WmNews.Status.SUBMIT.getCode())) {
// 从内容中提取纯文本内容和图片
Map<String, Object> textAndImages = handleTextAndImages(wmNews);
// 2. 审核文本内容,阿里云接口
// 传wmNews为了修改文章的状态
boolean isTextScan = handleTextScan((String) textAndImages.get("content"), wmNews);
if (!isTextScan) {
return;
}
// 3. 审核图片 阿里云接口
boolean isImageScan = handleImageScan((List<String>) textAndImages.get("images"), wmNews);
if (!isImageScan) {
return;
}
// 4. 审核成功,保存app端相关文章数据
ResponseResult responseResult = saveAppArticle(wmNews);
if (!responseResult.getCode().equals(200)) {
throw new RuntimeException("WmNewsAutoScanServiceImpl-文章审核,保存app端相关文章数据失败");
}
// 回填article_id
wmNews.setArticleId((Long) responseResult.getData());
updateWmNews(wmNews, (short) 9, "审核成功");
}
}
/**
* 保存app端相关的文章数据
*
* @param wmNews
*/
private IArticleClient iArticleClient;
private WmChannelMapper wmChannelMapper;
private WmUserMapper wmUserMapper;
private ResponseResult saveAppArticle(WmNews wmNews) {
ArticleDto articleDto = new ArticleDto();
// 属性的拷贝
BeanUtils.copyProperties(wmNews, articleDto);
// 文章的布局
articleDto.setLayout(wmNews.getType());
// 频道
WmChannel wmChannel = wmChannelMapper.selectById(wmNews.getChannelId());
if (wmChannel != null) {
articleDto.setChannelName(wmChannel.getName());
}
// 作者
articleDto.setAuthorId(wmNews.getUserId().longValue());
WmUser wmUser = wmUserMapper.selectById(wmNews.getUserId());
if (wmUser != null) {
articleDto.setAuthorName(wmUser.getName());
}
if (wmNews.getArticleId() != null) {
articleDto.setId(wmNews.getArticleId());
}
articleDto.setCreatedTime(new Date());
ResponseResult responseResult = iArticleClient.saveArticle(articleDto);
return responseResult;
}
/**
* 审核图片
*
* @param images
* @param wmNews
* @return
*/
private FileStorageService fileStorageService;
private GreenImageScan greenImageScan;
private boolean handleImageScan(List<String> images, WmNews wmNews) {
// 1. 下载图片从MinIO中
boolean flag = true;
if (images == null || images.size() == 0) {
return flag;
}
// 图片去重
images = images.stream().distinct().collect(Collectors.toList());
List<byte[]> imageList = new ArrayList<>();
for (String image : images) {
byte[] bytes = fileStorageService.downLoadFile(image);
imageList.add(bytes);
}
// 2. 审核图片
try {
Map map = greenImageScan.imageScan(imageList);
if (map != null) {
// 审核失败
if (map.get("suggestion").equals("block")) {
flag = false;
updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容");
}
// 不确定信息,需要人工审核
if (map.get("suggestion").equals("review")) {
flag = false;
updateWmNews(wmNews, (short) 3, "当前文章中存在不确定内容");
}
}
} catch (Exception e) {
flag = false;
e.printStackTrace();
}
return flag;
}
/**
* 审核纯文本内容
*
* @param content
* @param wmNews
* @return
*/
private GreenTextScan greenTextScan;
private boolean handleTextScan(String content, WmNews wmNews) {
boolean flag = true;
if ((wmNews.getTitle() + content).length() == 0) {
return flag;
}
try {
Map map = greenTextScan.greeTextScan(wmNews.getTitle() + content);
if (map != null) {
// 审核失败
if (map.get("suggestion").equals("block")) {
flag = false;
updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容");
}
// 不确定信息,需要人工审核
if (map.get("suggestion").equals("review")) {
flag = false;
updateWmNews(wmNews, (short) 3, "当前文章中存在不确定内容");
}
}
} catch (Exception e) {
flag = false;
e.printStackTrace();
}
return flag;
}
/**
* 修改文章内容
*
* @param wmNews
* @param status
* @param reason
*/
private void updateWmNews(WmNews wmNews, short status, String reason) {
wmNews.setStatus(status);
wmNews.setReason(reason);
wmNewsMapper.updateById(wmNews);
}
/**
* 1. 从自媒体文章内容中提取文本和图片
* 2. 提取文章的封面图片
*
* @param wmNews
* @return
*/
private Map<String, Object> handleTextAndImages(WmNews wmNews) {
// 存储纯文本内容
StringBuilder sb = new StringBuilder();
// 存储图片内容
List<String> images = new ArrayList<>();
// 1. 从自媒体文章内容中提取文本和图片
if (StringUtils.isNoneBlank(wmNews.getContent())) {
List<Map> maps = JSONArray.parseArray(wmNews.getContent(), Map.class);
for (Map map : maps) {
if (map.get("type").equals("text")) {
sb.append(map.get("value"));
}
if (map.get("type").equals("image")) {
images.add((String) map.get("value"));
}
}
}
// 2. 提取文章的封面图片
if (StringUtils.isNoneBlank(wmNews.getImages())) {
String[] split = wmNews.getImages().split(",");
images.addAll(Arrays.asList(split));
}
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("content", sb.toString());
resultMap.put("images", images);
return resultMap;
}
} -
feign 远程接口调用方式,要先在依赖中导入feign-api的软件包,然后在启动类中填入注解
1 | <dependency> |
1 |
|
- 在 heima-leadnews-wemedia 服务中已经依赖了 heima-leadnews-feign-apis 工程,只需要在自媒体的引导类中开启feign的远程调用即可
@EnableFeignClients(basePackages = "com.heima.apis")需要指向 apis 这个包
feign 远程调用服务降级处理

- 服务降级 是 服务自我保护 的一种方式,或者 保护下游服务 的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃
- 服务降级 虽然 会导致请求失败,但是 不会导致阻塞
步骤:
-
在
com.heima.apis.article.fallback包下编写降级逻辑1
2
3
4
5
6
7
public class IArticleClientFallback implements IArticleClient {
public ResponseResult saveArticle(ArticleDto dto) {
return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR, "获取数据失败");
}
} -
远程接口中指向降级代码,在
com.heima.apis.article.IArticleClient包下实现1
2
3
4
5
6
7
public interface IArticleClient {
// @RequestBody:通常会使用这个注解来接收客户端发送的 JSON、XML 或其他格式的数据,并将其转换为 Java 对象
public ResponseResult saveArticle( ArticleDto dto);
} -
在 自媒体微服务 中添加类,扫描降级代码类的包,在
com.heima.wemedia.config包下进行1
2
3
4
public class InitConfig {
} -
客户端开启降级 heima-leadnews-wemedia,在 wemedia 的 nacos 配置中心里添加如下内容,开启服务降级,也可以指定服务响应的超时的时间
1
2
3
4
5
6
7
8
9
10feign:
# 开启feign对hystrix熔断降级的支持
hystrix:
enabled: true
# 修改调用超时时间
client:
config:
default:
connectTimeout: 2000
readTimeout: 2000
发布文章提交审核集成

Springboot集成异步线程调用:
-
在 自媒体文章审核 的方法上加上
@Async注解(标明要 异步调用 )1
2
3
4
5
//标明当前方法是一个异步方法
public void autoScanWmNews(Integer id) {
//代码略
} -
在 文章发布成功 后调用审核的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private WmNewsAutoScanService wmNewsAutoScanService;
/**
* 发布修改文章或保存为草稿
* @param dto
* @return
*/
public ResponseResult submitNews(WmNewsDto dto) {
//代码略
//审核文章
wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
} -
在 自媒体启动类 中使用
@EnableAsync注解开启异步调用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 开启异步调用
public class WemediaApplication {
public static void main(String[] args) {
SpringApplication.run(WemediaApplication.class, args);
}
// Mybatis-Plus的分页插件
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
自管理敏感词过滤
需要实现的功能:需要自己维护一套敏感词,在文章审核的时候,需要验证文章是否包含这些敏感词

DFA 实现原理
DFA全称为:Deterministic Finite Automaton,即确定有穷自动机
存储:一次性的把所有的敏感词存储到了多个map中,就是下图表示这种结构
敏感词:冰毒、大麻、大坏蛋


在 com.heima.utils.common.SensitiveWordUtil 包下有一个工具类,来实现DFA算法
1 | public class SensitiveWordUtil { |
DFA 算法集成到文章审核中
-
创建敏感词表 wm_sensitive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class WmSensitive implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Integer id;
/**
* 敏感词
*/
private String sensitives;
/**
* 创建时间
*/
private Date createdTime;
} -
在对应的 mapper 中创建 wm_sensitive 实体类
1
2
3
public interface WmSensitiveMapper extends BaseMapper<WmSensitive> {
} -
在文章审核的代码中添加自管理敏感词审核
-
在 WmNewsAutoScanServiceImpl 中的 autoScanWmNews 方法上添加如下代码
1
2
3
4
5
6
7
8
9//从内容中提取纯文本内容和图片
//.....省略
//自管理的敏感词过滤
boolean isSensitive = handleSensitiveScan((String) textAndImages.get("content"), wmNews);
if(!isSensitive) return;
//2.审核文本内容 阿里云接口
//.....省略 -
新增自管理敏感词审核代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29/**
* 自管理敏感词审核
*
* @param content
* @param wmNews
* @return
*/
private WmSensitiveMapper wmSensitiveMapper;
private boolean handleSensitiveScan(String content, WmNews wmNews) {
boolean flag = true;
// 1. 获取所有的敏感词
List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers
.<WmSensitive>lambdaQuery().select(WmSensitive::getSensitives));
List<String> sensitiveList = wmSensitives
.stream().map(WmSensitive::getSensitives).collect(Collectors.toList()
// 2. 初始化敏感词库
SensitiveWordUtil.initMap(sensitiveList);
// 3. 查看文章中是否包含敏感词
Map<String, Integer> map = SensitiveWordUtil.matchWords(content);
if (map.size() > 0) {
updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容" + map);
flag = false;
}
return flag;
}
-
图片中文字敏感词过滤
OCR (Optical Character Recognition,光学字符识别)是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗、亮的模式确定其形状,然后 用字符识别方法将形状翻译成计算机文字的过程

Tesseract-OCR
Tesseract-OCR 的特点:
- Tesseract 支持UTF-8编码格式,并且可以“开箱即用”地识别100多种语言
- Tesseract 支持多种输出格式:纯文本,hOCR(HTML),PDF等
- 官方建议,为了获得更好的OCR结果,最好提供给高质量的图像
- Tesseract 进行识别其他语言的训练
- 具体的训练方式,请参考官方提供的文档:https://tesseract-ocr.github.io/tessdoc/
Tess4j 使用
-
创建项目导入 tess4j 对应的依赖
1
2
3
4
5<dependency>
<groupId>net.sourceforge.tess4j</groupId>
<artifactId>tess4j</artifactId>
<version>4.1.1</version>
</dependency> -
导入中文字体库, tessdata文件夹 拷贝到 todaynews 的工作空间下

-
编写测试类进行测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32public class Application {
/**
* 识别图片中的文字
* @param args
*/
public static void main(String[] args) {
// 1. 创建实例
ITesseract tesseract = new Tesseract();
// 2. 设置字体库路径
tesseract.setDatapath("D:\\BaiduNetdiskDownload\\todaynews\\tessdata");
// 3. 设置语言 --> 简体中文
// chi_sim 这是固定写法
tesseract.setLanguage("chi_sim");
File file = new File("D:\\BaiduNetdiskDownload\\todaynews\\test.png");
// 4. 识别图片
String result = null;
try {
result = tesseract.doOCR(file);
} catch (TesseractException e) {
e.printStackTrace();
}
System.out.println("识别的结果为:" + result.replaceAll("\\r|\\n","-"));
/**
* 识别的结果为:江户川柯南私人侦探-婚外情调查 商务调查 要账情债 ”企业打假-寻人找人 财产调查“私人调查--电话: 12345678901-
*/
}
}
管理敏感词和图片文字识别集成到文章审核
-
导入依赖
1
2
3
4
5
6<!-- tess4j -->
<dependency>
<groupId>net.sourceforge.tess4j</groupId>
<artifactId>tess4j</artifactId>
<version>4.1.1</version>
</dependency> -
在 heima-leadnews-common 中创建工具类,简单封装一下 tess4j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24Getter
public class Tess4jClient {
private String dataPath;
private String language;
public String doOCR(BufferedImage image) throws TesseractException {
//创建Tesseract对象
ITesseract tesseract = new Tesseract();
//设置字体库路径
tesseract.setDatapath(dataPath);
//中文识别
tesseract.setLanguage(language);
//执行ocr识别
String result = tesseract.doOCR(image);
//替换回车和tal键 使结果为一行
result = result.replaceAll("\\r|\\n", "-").replaceAll(" ", "");
return result;
}
} -
在 spring.factories 配置中添加该类
1
2
3
4
5
6
7org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.heima.common.exception.ExceptionCatch,\
com.heima.common.swagger.SwaggerConfiguration,\
com.heima.common.swagger.Swagger2Configuration,\
com.heima.common.aliyun.GreenImageScan,\
com.heima.common.aliyun.GreenTextScan,\
com.heima.common.tess4j.Tess4jClient -
在 heima-leadnews-wemedia 中的配置中添加两个属性
1
2
3tess4j:
data-path: D:\BaiduNetdiskDownload\todaynews\tessdata
language: chi_sim -
在 WmNewsAutoScanServiceImpl 中的 handleImageScan 方法上添加如下代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41/**
* 自管理的敏感图片识别
*
* @param images
* @param wmNews
* @return
*/
private FileStorageService fileStorageService;
private Tess4jClient tess4jClient;
private boolean handleSensitivePictScan(List<String> images, WmNews wmNews) {
// 1. 下载图片从MinIO中
boolean flag = true;
if (images == null || images.size() == 0) {
return flag;
}
// 图片去重
images = images.stream().distinct().collect(Collectors.toList());
try {
for (String image : images) {
byte[] bytes = fileStorageService.downLoadFile(image);
// byte[] 转换为 bufferImage
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
BufferedImage bufferedImage = ImageIO.read(in);
// 图片识别
String result = tess4jClient.doOCR(bufferedImage);
// 过滤文字
flag = handleSensitiveScan(result, wmNews);
}
} catch (Exception e) {
e.printStackTrace();
}
return flag;
}
文章详情-静态文件生成
思路分析
文章端创建 app 相关文章时,生成文章详情静态页上传到 MinIO 中

实现步骤
-
新建 ArticleFreemarkerService 创建静态文件并上传到 minIO 中,在
com.heima.article.service.ArticleFreemarkerService软件包目录下1
2
3
4
5
6
7
8
9public interface ArticleFreemarkerService {
/**
* 生成静态文件上传到minIO中
* @param apArticle
* @param content
*/
public void buildArticleToMinIO(ApArticle apArticle,String content);
} -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class ArticleFreemarkerServiceImpl implements ArticleFreemarkerService {
/**
* 生成静态文件上传到minio中
*
* @param apArticle
* @param content
*/
private Configuration configuration;
private FileStorageService fileStorageService;
private ApArticleService apArticleService;
// 异步方式生成静态文件
public void buildArticleToMinIO(ApArticle apArticle, String content) {
// 已知文章id
// 1. 获取文章内容
if (StringUtils.isNotBlank(content)) {
// 2. 文章内容通过freemarker生成html文件
Template template = null;
StringWriter out = null;
try {
template = configuration.getTemplate("article.ftl");
// 数据模型
Map<String, Object> contentDataModel = new HashMap<>();
contentDataModel.put("content", JSONArray.parseArray(content));
out = new StringWriter();
// 合成
template.process(contentDataModel, out);
} catch (Exception e) {
e.printStackTrace();
}
// 3. 把html文件上传到minio中
InputStream in = new ByteArrayInputStream(out.toString().getBytes());
String path = fileStorageService.uploadHtmlFile("", apArticle.getId() + ".html", in);
// 4. 修改ap_article表,保存static_url字段
apArticleService.update(Wrappers.<ApArticle>lambdaUpdate().eq(ApArticle::getId, apArticle.getId())
.set(ApArticle::getStaticUrl, path));
}
}
} -
在 ApArticleService 的 saveArticle 实现方法中添加调用生成文件的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66/**
* 保存app端相关文章
*
* @param dto
* @return
*/
private ApArticleConfigMapper apArticleConfigMapper;
private ApArticleContentMapper apArticleContentMapper;
private ArticleFreemarkerService articleFreemarkerService;
public ResponseResult saveArticle(ArticleDto dto) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 1. 检查参数
if (dto == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
ApArticle apArticle = new ApArticle();
BeanUtils.copyProperties(dto, apArticle);
// 2. 判断是否存在id
if (dto.getId() == null) {
// 2.1 不存在id,保存 文章 文章配置 文章内容
// 保存文章
save(apArticle);
// 保存配置
/**
* 这里重写了有参构造方法
*
* public ApArticleConfig(Long articleId) {
* this.articleId = articleId;
* this.isDelete = false;
* this.isDown = false;
* this.isForward = true;
* this.isComment = true;
* }
*/
ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
apArticleConfigMapper.insert(apArticleConfig);
// 保存文章内容
ApArticleContent apArticleContent = new ApArticleContent();
apArticleContent.setArticleId(apArticle.getId());
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.insert(apArticleContent);
} else {
// 2.2 存在id 修改 文章 文章内容
// 修改文章
updateById(apArticle);
// 修改文章内容
ApArticleContent apArticleContent = apArticleContentMapper.selectOne(Wrappers
.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, dto.getId()));
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.updateById(apArticleContent);
}
// !!!
// 异步调用,生成静态文件上传到minio中
articleFreemarkerService.buildArticleToMinIO(apArticle, dto.getContent());
// 3. 结果返回 文章id
return ResponseResult.okResult(apArticle.getId());
} -
文章微服务开启异步调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 开启异步调用
public class ArticleApplication {
public static void main(String[] args) {
SpringApplication.run(ArticleApplication.class,args);
}
// Mybatis-Plus的分页插件
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
延迟任务精准发布文章

延迟任务
定时任务:有固定周期的,有明确的触发时间
延迟队列:没有固定的开始时间,它常常是 由一个事件触发 的,而在 这个事件触发之后的一段时间内触发另一个事件,任务 可以立即执行,也 可以延迟

技术对比
-
DelayQueue
- JDK 自带 DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素
- 存在的问题:使用线程池或者原生 DelayQueue 程序挂掉之后,任务都是放在内存,需要考虑未处理消息的丢失带来的影响,如何保证数据不丢失,需要持久化(磁盘)

-
RabbitMQ
- TTL:Time To Live (消息存活时间)
- 死信队列:Dead Letter Exchange(死信交换机),当消息成为 Dead message 后,可以重新发送另一个交换机(死信交换机)

-
redis实现
- zset 数据类型的 去重 有序(分数排序)特点进行延迟
- 例如:时间戳作为score进行排序

redis 实现延迟任务

- 把 任务 需要存储在 数据库 中的 原因:延迟任务是一个通用的服务,任何需要延迟得任务都可以调用该服务,需要 考虑数据持久化 的问题,存储数据库中是一种 数据安全 的考虑
- redis 中使用两种数据类型,list 和 zset 的原因:
- 原因一:list 存储立即执行的任务,zset 存储未来的数据
- 原因二:任务量过大之后,zset 的性能会下降,因为 操作redis 中的 list 命令 LPUSH 的 时间复杂度:
O(1),操作redis 中的 zset 命令 zadd 的时间复杂度:O(M*log(n))
- 在添加 zset 数据的时候,需要 预加载 的原因:如果 任务数据特别大,为了防止阻塞,只需要把 未来几分钟要执行的数据存入缓存 即可,是一种优化的形式
redis 实现延迟任务的流程
准备工作

-
在
heima-leadnews-service工程下创建heima-leadnews-schedule模块 -
添加 bootstrap.yml
1
2
3
4
5
6
7
8
9
10
11
12server:
port: 51701
spring:
application:
name: leadnews-schedule
cloud:
nacos:
discovery:
server-addr: 192.168.88.129:8848
config:
server-addr: 192.168.88.129:8848
file-extension: yml -
在 nacos 中添加对应的配置,并添加数据库及 MP 的配置
1
2
3
4
5
6
7
8
9
10
11spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/leadnews_schedule?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: root
# 设置Mapper接口所对应的XML文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
# 设置别名包扫描路径,通过该属性可以给包中的类注册别名
type-aliases-package: com.heima.model.schedule.pojos -
导入数据库
-
taskinfo 任务表

-
taskinfo_logs 任务日志表

- MySQL中,BLOB是一个二进制大型对象,是一个可以存储大量数据的容器;LongBlob 最大存储4G
-
-
构建实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40// taskinfo 任务表
public class Taskinfo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 任务id
*/
private Long taskId;
/**
* 执行时间
*/
private Date executeTime;
/**
* 参数
*/
private byte[] parameters;
/**
* 优先级
*/
private Integer priority;
/**
* 任务类型
*/
private Integer taskType;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53// taskinfo_logs 任务日志表
public class TaskinfoLogs implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 任务id
*/
private Long taskId;
/**
* 执行时间
*/
private Date executeTime;
/**
* 参数
*/
private byte[] parameters;
/**
* 优先级
*/
private Integer priority;
/**
* 任务类型
*/
private Integer taskType;
/**
* 版本号,用乐观锁
*/
private Integer version;
/**
* 状态 0=int 1=EXECUTED 2=CANCELLED
*/
private Integer status;
}- 悲观锁(Pessimistic Lock)
- 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。
- 效率较低
- 乐观锁(Optimistic Lock)
- 每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是 在更新的时候 会判断一下 在此期间 别人有没有去更新 这个数据,可以 使用版本号 等机制
- 效率较高
- 上面两个是 数据库自身解决并发的两种策略
- 悲观锁(Pessimistic Lock)
-
乐观锁的使用
-
在实体类中使用
@Version标明是一个版本的字段1
2
3
4
5/**
* 版本号,用乐观锁
*/
private Integer version; -
Mybatis-plus 对乐观锁的支持,在 启动类 中向 容器 中放入 乐观锁 的 拦截器
1
2
3
4
5
6
7
8
9
10/**
* mybatis-plus乐观锁支持
* @return
*/
public MybatisPlusInterceptor optimisticLockerInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
-
-
docker 安装 redis
-
拉去镜像
1
docker pull redis
-
创建容器
1
2
3docker run -d --name redis --restart=always -p 6379:6379 redis --requirepass "leadnews"
--requirepass "leadnews":这是创建的密码是leadnews -
链接测试,打开 Another Redis Desktop Manager,测试host、port、password 连接测试

-
-
在项目中集成redis
-
导入依赖
1
2
3
4
5
6
7
8
9<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis依赖commons-pool 这个依赖一定要添加 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency> -
在 nacos 中添加配置
1
2
3
4
5
6
7spring:
redis:
host: 192.168.200.130
password: leadnews
port: 6379
# 这部分写在spring下
-
拷贝一个 CacheService.java(redis集成配置) 这个工具类到 common 模块下,并添加自动配置
- 这里不给出 CacheService.java 的源码(太多了),见项目
- 编写测试类,测试 redis 是否可用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class RedisTest {
private CacheService cacheService;
public void testList() {
// 在list左边添加元素
cacheService.lLeftPush("list_001", "hello redis");
// 在list右边获取元素并删除
String list001 = cacheService.lRightPop("list_001");
System.out.println("list001 = " + list001);
}
public void testZet() {
// 添加数据到zset中 分值
cacheService.zAdd("zset_key_001","hello zset 001",1000);
cacheService.zAdd("zset_key_001","hello zset 002",8888);
cacheService.zAdd("zset_key_001","hello zset 003",7777);
cacheService.zAdd("zset_key_001","hello zset 004",9999);
// 按照分值获取数据
Set<String> zsetKey001 = cacheService.zRangeByScore("zset_key_001", 0, 8888);
System.out.println("zsetKey001 = " + zsetKey001);
// zsetKey001 = [hello zset 001, hello zset 003, hello zset 002]
}
}
-
添加任务
流程:
-
创建 TaskinfoMapper 和 TaskinfoLogsMapper 到项目的mapper文件包中
1
2
3
4
5
public interface TaskinfoMapper extends BaseMapper<Taskinfo> {
public List<Taskinfo> queryFutureTime(int type, int priority, Date future);
}1
2
3
4
public interface TaskinfoLogsMapper extends BaseMapper<TaskinfoLogs> {
} -
创建 task 类,用于接收添加任务的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Task implements Serializable {
/**
* 任务id
*/
private Long taskId;
/**
* 类型
*/
private Integer taskType;
/**
* 优先级
*/
private Integer priority;
/**
* 执行id
*/
private long executeTime;
/**
* task参数
*/
private byte[] parameters;
} -
创建 TaskService
1
2
3
4
5
6
7
8
9public interface TaskService {
/**
* 添加延迟任务
* @param task
* @return
*/
public long addTask(Task task);
}- 添加任务到数据库中
- 添加任务到 redis 中
- 如果任务的执行时间 小于等于 当前时间存入 list
- 如果任务的执行时间大于当前时间,小于等于预设时间(未来5分钟)存入 zset 中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103// TaskService的实现类
public class TaskServiceImpl implements TaskService {
/**
* 添加延迟任务
*
* @param task
* @return
*/
public long addTask(Task task) {
// 1. 添加任务到数据库中
boolean success = addTaskToDb(task);
if (success) {
// 2. 添加任务到redis
addTaskToCache(task);
}
return task.getTaskId();
}
/**
* 把任务添加到redis中
*
* @param task
*/
private CacheService cacheService;
private void addTaskToCache(Task task) {
// key 一般是 task的任务类型_task的优先级
String key = task.getTaskType() + "_" + task.getPriority();
// 获取5分钟之后的时间(毫秒值)
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 5);
long nextScheduleTime = calendar.getTimeInMillis();
// 2.1 如果任务执行时间小于等于当前时间,存入带list中
if (task.getExecuteTime() <= System.currentTimeMillis()) {
// ScheduleConstants.TOPIC = "topic_"
cacheService.lLeftPush(ScheduleConstants.TOPIC + key, JSON.toJSONString(task));
} else if (task.getExecuteTime() <= nextScheduleTime) {
// 2.2 如果任务执行时间大于当前时间,并且小于等于预设时间(未来5分钟),存入到zset中
cacheService.zAdd(ScheduleConstants.FUTURE + key
, JSON.toJSONString(task), task.getExecuteTime());
}
}
/**
* 添加任务到数据库中
*
* @param task
* @return
*/
private TaskinfoMapper taskinfoMapper;
private TaskinfoLogsMapper taskinfoLogsMapper;
private boolean addTaskToDb(Task task) {
boolean flag = false;
try {
// 保存任务表
Taskinfo taskinfo = new Taskinfo();
BeanUtils.copyProperties(task, taskinfo);
taskinfo.setExecuteTime(new Date(task.getExecuteTime()));
taskinfoMapper.insert(taskinfo);
// 设置taskid
task.setTaskId(taskinfo.getTaskId());
// 保存任务日志数据
TaskinfoLogs taskinfoLogs = new TaskinfoLogs();
BeanUtils.copyProperties(taskinfo, taskinfoLogs);
// 设置乐观锁的版本号
taskinfoLogs.setVersion(1);
taskinfoLogs.setStatus(ScheduleConstants.SCHEDULED); // 0 阅读不方便,设置一个常量
taskinfoLogsMapper.insert(taskinfoLogs);
flag = true;
} catch (Exception e) {
e.printStackTrace();
}
return flag;
}
} -
设置常量类 ScheduleConstants
1
2
3
4
5
6
7
8
9
10
11
12
13public class ScheduleConstants {
//task状态
public static final int SCHEDULED=0; //初始化状态
public static final int EXECUTED=1; //已执行状态
public static final int CANCELLED=2; //已取消状态
public static String FUTURE="future_"; //未来数据key前缀
public static String TOPIC="topic_"; //当前数据key前缀
}
取消任务
使用场景:第三接口网络不通,使用延迟任务进行重试,当达到阈值以后,取消任务

流程:
-
在 TaskService 中添加方法
1
2
3
4
5
6/**
* 取消任务
* @param taskId
* @return
*/
public boolean cancelTask(long taskId); -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64/**
* 取消任务
*
* @param taskId
* @return
*/
public boolean cancelTask(long taskId) {
boolean flag = false;
// 1. 删除任务,更新任务日志
Task task = updateDb(taskId, ScheduleConstants.CANCELLED);
// 2. 删除redis的数据
if (task != null) {
removeTaskFromCache(task);
flag = true;
}
return flag;
}
/**
* 删除redis中的数据
*
* @param task
*/
private void removeTaskFromCache(Task task) {
// key 一般是 task的任务类型_task的优先级
String key = task.getTaskType() + "_" + task.getPriority();
// 这里根据前面的条件,删除list的数据
if (task.getExecuteTime() <= System.currentTimeMillis()) {
cacheService.lRemove(ScheduleConstants.TOPIC + key, 0, JSON.toJSONString(task));
} else {
cacheService.zRemove(ScheduleConstants.FUTURE + key, JSON.toJSONString(task));
}
}
/**
* 删除任务,更新任务的日志
*
* @param taskId
* @param status
* @return
*/
private Task updateDb(long taskId, int status) {
Task task = null;
try {
// 1. 删除任务
taskinfoMapper.deleteById(taskId);
// 2. 更新任务的日志,主要修改任务日志的状态
TaskinfoLogs taskinfoLogs = taskinfoLogsMapper.selectById(taskId);
taskinfoLogs.setStatus(status);
taskinfoLogsMapper.updateById(taskinfoLogs);
task = new Task();
BeanUtils.copyProperties(taskinfoLogs, task);
task.setExecuteTime(taskinfoLogs.getExecuteTime().getTime());
} catch (Exception e) {
log.error("task cancel exception taskId = {}", taskId);
}
return task;
}
消费任务

流程:
-
在 TaskService 中添加方法
1
2
3
4
5
6
7/**
* 按照类型与优先级拉取
* @param type
* @param priority
* @return
*/
public Task poll(int type, int priority); -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29/**
* 按照类型与优先级拉取
*
* @param type
* @param priority
* @return
*/
public Task poll(int type, int priority) {
Task task = null;
try {
String key = type + "_" + priority;
// 1. 从redis中拉取数据
String taskJson = cacheService.lRightPop(ScheduleConstants.TOPIC + key);
if (StringUtils.isNotBlank(taskJson)) {
task = JSON.parseObject(taskJson, Task.class);
// 2. 删除数据库信息,修改任务日志为已完成的状态
updateDb(task.getTaskId(), ScheduleConstants.EXECUTED);
}
} catch (Exception e) {
e.printStackTrace();
log.error("poll task exception");
}
return task;
}
定时刷新

问题:如何获取 zset 中所有的 key ?
方案一:keys 模糊匹配
-
redis 是单线程,会被堵塞。并且使用 keys 的模糊匹配发现 redis 的 CPU使用率极高
1
Set<String> keys = cacheservice.keys("future_*");

方案二:scan(最常用的方法)
-
SCAN 命令是一个基于 游标 的迭代器,SCAN命令 每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN命令 的游标参数, 以此来延续之前的迭代过程
1
Set<String> scan = cacheservice.scan("future_*");

**问题:**数据如何进行同步?
主要就是:
- 第一:从 zset 中查出数据,并删除
- 第二:把数据存入到 list 中
原始方案:普通 redis 客户端和 服务器 交互模式

redis 通道技术:Pipeline请求模型

流程:
-
直接在 TaskServiceImpl 实现类下面定义实现方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29/**
* 未来数据定时刷新
*/
// @Scheduled:注解是创建定时任务,下面的意思是每分钟执行一次
// cron中的含义:[秒] [分] [小时] [日] [月] [周] [年]
// 在第一位的"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)
// ? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值。
public void refresh() {
log.info("未来数据定时刷新 -- 定时任务");
// 获取所有未来数据的集合key
Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
for (String futureKey : futureKeys) {
// 获取当前数据的key topic
String topicKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];
// 按照key和分值查询符合条件的数据
Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());
// 同步数据
if (!tasks.isEmpty()) {
cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
log.info("成功将" + futureKey + "刷新到了" + topicKey);
}
}
} -
在引导类加上注解
@EnableScheduling,开启定时任务1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 开启调度任务
public class ScheduleApplication {
public static void main(String[] args) {
SpringApplication.run(ScheduleApplication.class,args);
}
/**
* mybatis-plus乐观锁支持
* @return
*/
public MybatisPlusInterceptor optimisticLockerInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
定时同步
就是把数据库任务定时同步到 redis 中

流程:
-
清理缓存中的数据
1
2
3
4Set<String> futurekeys = cacheService.scan(ScheduleConstants.FUTURE + "*"); // future_
Set<String> topickeys = cacheService.scan(ScheduleConstants.TOPIC + "*"); // topic_
cacheService.delete(futurekeys);
cacheService.delete(topickeys); -
查询小于未来5分钟的所有任务
1
List<Taskinfo> allTasks = taskinfoMapper.selectList(Wrappers.<Taskinfo>lambdaQuery().lt(Taskinfo::getExecuteTime,calendar.getTime()));
-
新增任务到 redis
1
2
3
4
5
6for (Taskinfo taskinfo : allTasks) {
Task task = new Task();
BeanUtils.copyProperties(taskinfo,task);
task.setExecuteTime(taskinfo.getExecuteTime().getTime());
addTaskToCache(task);
}
完成实现流程:
在 TaskServiceImpl 里面定义方法
1 | /** |
分布式锁
问题:启动两台 heima-leadnews-schedule 服务,每台服务都会去执行 refresh 定时任务方法,如下图所示:

- 这样会造成集群的资源抢占的问题
分布式锁:控制分布式系统有序的去对 共享资源 进行操作,通过 互斥 来保证数据的一致性
分布式锁是用来 解决集群下的方法抢占执行
分布式锁的解决方案:

redis 分布式锁
sexnx (SET if Not eXists) 命令在 指定的 key 不存在时,为 key 设置指定的值
加锁的思路:

这种加锁的思路是,如果 key 不存在则为 key 设置 value,如果 key 已存在则 SETNX 命令不做任何操作
- 客户端A 请求服务器设置 key 的值,如果设置成功就表示加锁成功
- 客户端B 也去请求服务器设置 key 的值,如果返回失败,那么就代表加锁失败
- 客户端A 执行代码完成,删除锁
- 客户端B 在等待一段时间后再去请求设置key的值,设置成功
- 客户端B 执行代码完成,删除锁
总结:A获取到锁之后其他客户端就不能操作了,只能等到A释放锁以后,其他客户端才可以操作
实现流程:
-
在工具类 CacheService 中添加方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29/**
* 加锁
*
* @param name 名字
* @param expire 过期的时间
* @return
*/
public String tryLock(String name, long expire) {
name = name + "_lock";
String token = UUID.randomUUID().toString();
RedisConnectionFactory factory = stringRedisTemplate.getConnectionFactory();
RedisConnection conn = factory.getConnection();
try {
//参考redis命令:
//set key value [EX seconds] [PX milliseconds] [NX|XX]
Boolean result = conn.set(
name.getBytes(),
token.getBytes(),
Expiration.from(expire, TimeUnit.MILLISECONDS),
RedisStringCommands.SetOption.SET_IF_ABSENT //NX
);
if (result != null && result)
return token;
} finally {
RedisConnectionUtils.releaseConnection(conn, factory,false);
}
return null;
} -
在 定时任务 中添加这个方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32/**
* 未来数据定时刷新
*/
// @Scheduled:注解是创建定时任务,下面的意思是每分钟执行一次
// cron中的含义:[秒] [分] [小时] [日] [月] [周] [年]
// 在第一位的"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)
public void refresh() {
// 如果token有值执行代码,如果token无值意为加锁了
String token = cacheService.tryLock("FUTURE_TASK_SYNC", 100 * 30);
if (StringUtils.isNotBlank(token)) {
log.info("未来数据定时刷新 -- 定时任务");
// 获取所有未来数据的集合key
Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
for (String futureKey : futureKeys) {
// 获取当前数据的key topic
String topicKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];
// 按照key和分值查询符合条件的数据
Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());
// 同步数据
if (!tasks.isEmpty()) {
cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
log.info("成功将" + futureKey + "刷新到了" + topicKey);
}
}
}
}
延迟队列服务 提供对外接口

流程:
-
提供远程的 feign 接口,在 heima-leadnews-feign-api 编写类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public interface IScheduleClient {
/**
* 添加延迟任务
*
* @param task
* @return
*/
public ResponseResult addTask( Task task);
/**
* 取消任务
*
* @param taskId
* @return
*/
public ResponseResult cancelTask( long taskId);
/**
* 按照类型与优先级拉取
*
* @param type
* @param priority
* @return
*/
public ResponseResult poll( int type, int priority);
} -
在 heima-leadnews-schedule 微服务下建立 feign 软件包下提供对应的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class ScheduleClient implements IScheduleClient {
private TaskService taskService;
/**
* 添加延迟任务
*
* @param task
* @return
*/
public ResponseResult addTask( Task task) {
return ResponseResult.okResult(taskService.addTask(task));
}
/**
* 取消任务
*
* @param taskId
* @return
*/
public ResponseResult cancelTask( long taskId) {
return ResponseResult.okResult(taskService.cancelTask(taskId));
}
/**
* 按照类型与优先级拉取
*
* @param type
* @param priority
* @return
*/
public ResponseResult poll( int type, int priority) {
return ResponseResult.okResult(taskService.poll(type, priority));
}
}
发布文章添加 延迟队列接口

流程:
-
在 heima-leadnews-wemedia 的业务层创建 WmNewsTaskService
1
2
3
4
5
6
7
8
9public interface WmNewsTaskService {
/**
* 添加任务到延时队列中
* @param id 文章的id
* @param publishTime 发布的时间 可以作为任务的执行时间
*/
public void addNewsToTask(Integer id, Date publishTime);
} -
实现类 WmNewsTaskServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class WmNewsTaskServiceImpl implements WmNewsTaskService {
private IScheduleClient scheduleClient;
/**
* 添加任务到延时队列中
*
* @param id 文章的id
* @param publishTime 发布的时间 可以作为任务的执行时间
*/
public void addNewsToTask(Integer id, Date publishTime) {
log.info("添加任务到延迟服务中 --- begin");
Task task = new Task();
task.setExecuteTime(publishTime.getTime()); // 文章发布时间
task.setTaskType(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType());
task.setPriority(TaskTypeEnum.NEWS_SCAN_TIME.getPriority());
WmNews wmNews = new WmNews();
wmNews.setId(id);
task.setParameters(ProtostuffUtil.serialize(wmNews));
scheduleClient.addTask(task);
log.info("添加任务到延迟服务中 --- end");
}
} -
在module模块的common包下的enum定义枚举类
1
2
3
4
5
6
7
8
9
10
public enum TaskTypeEnum {
NEWS_SCAN_TIME(1001, 1,"文章定时审核"),
REMOTEERROR(1002, 2,"第三方接口调用失败,重试");
private final int taskType; //对应具体业务
private final int priority; //业务不同级别
private final String desc; //描述信息
} -
修改发布文章代码,在 WmNewsServiceImpl 实现类下:
- 把之前的异步调用修改为调用延迟任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56/**
* 发布修改文章、保存文章为草稿
*
* @param wmNewsDto
* @return
*/
private WmNewsAutoScanService wmNewsAutoScanService;
private WmNewsTaskService wmNewsTaskService;
public ResponseResult submitNews(WmNewsDto wmNewsDto) {
// 略
// 审核文章
// wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
wmNewsTaskService.addNewsToTask(wmNews.getId(), wmNews.getPublishTime());
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
---
##### 序列化工具对比
---
序列化工具对比
- JdkSerialize:java内置的序列化能将实现了 Serilazable 接口的对象进行序列化和反序列化, ObjectOutputStream 的 writeObject() 方法可序列化对象生成字节数组
- Protostuff:google开源的 protostuff 采用更为紧凑的二进制数组,表现更加优异,然后使用 protostuff 的编译工具生成 pojo 类
Protostuff 需要引导依赖:
```xml
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.6.0</version>
</dependency>
拉去任务 进行文章审核

流程:
-
在 WmNewsTaskService 中添加方法
1
2
3
4/**
* 消费任务,审核文章
*/
public void scanNewsByTask(); -
实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25/**
* 消费任务,审核文章
*/
private WmNewsAutoScanService wmNewsAutoScanService;
// fixedRate = 1000 表示固定的频率,每1s钟拉去一次
public void scanNewsByTask() {
log.info("消费任务,审核文章");
ResponseResult responseResult = scheduleClient.poll(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType()
, TaskTypeEnum.NEWS_SCAN_TIME.getPriority());
if (responseResult.getCode().equals(AppHttpCodeEnum.SUCCESS)
&& responseResult.getData() != null) {
Task task = JSON.parseObject(JSON.toJSONString(responseResult.getData()), Task.class);
WmNews wmNews = ProtostuffUtil.deserialize(task.getParameters(), WmNews.class);
wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
}
} -
在 WemediaApplication 自媒体的引导类中添加开启任务调度注解
@EnableScheduling1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 开启异步调用
// 开启调度任务
public class WemediaApplication {
public static void main(String[] args) {
SpringApplication.run(WemediaApplication.class, args);
}
// Mybatis-Plus的分页插件
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}






