完整版请移步至我的个人博客查看:https://cyborg2077.github.io/
学成在线–项目环境搭建
学成在线–内容管理模块
学成在线–媒资管理模块
学成在线–课程发布模块
学成在线–认证授权模块
学成在线–选课学习模块
学成在线–项目优化
Git仓库:https://github.com/Cyborg2077/xuecheng-plus
具体审核的过程与课程预览的过程类似,运营人员查看课程信息、课程视频等内容
如果存在问题,则审核不通过,并附上审核不通过的原因供教学机构人员查看
如果课程内容没有违规信息且课程内容全面,则审核通过
课程审核通过后,教学机构发布课程成功
课程预览就是把课程的相关信息进行整合,在课程详情界面进行展示,通过课程预览页面查看信息是否存在问题
下图是课程预览的数据来源
课程预览的效果与最终课程发布后查看到的效果是一致的,所以课程预览时会通过网站门户域名地址进行预览,下图是课程预览的整个流程图
说明如下:
马上学习
打开视频播放页面根据前面的数据模型分析,课程预览就是把课程的相关信息进行整合,在课程预览界面进行展示,课程预览界面与课程发布的课程详情界面一致,保证了教学机构人员发布前看到的是什么样,发布后也会看到什么样
项目采用模板引擎技术实现课程预览界面,那么什么是模板引擎?
早期我们使用的JSP技术就是一种模板引擎技术
所以模板引擎就是模板 + 数据 = 输出
。JSP页面就是模板,页面中嵌入的JSP标签就是数据,两者相结合输出HTML网页
常用的Java模板引擎还有那些?
本项目采用Freemarker作为模板引擎技术
Freemarker官网:http://freemarker.foofun.cn/
org.springframework.boot
spring-boot-starter-freemarker
spring:
freemarker:
enabled: true
cache: false #关闭模板缓存,方便测试
settings:
template_update_delay: 0
suffix: .ftl #页面模板后缀名
charset: UTF-8
template-loader-path: classpath:/templates/ #页面模板位置(默认为 classpath:/templates/)
resources:
add-mappings: false #关闭项目中的静态资源映射(static、resources文件夹下的资源)
shared-configs:
- data-id: swagger-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
- data-id: logging-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
+ - data-id: freemarker-config-${spring.profiles.active}.yaml
+ group: xuecheng-plus-common
+ refresh: true
Hello World!
Hello ${broski}!
@Controller
public class FreemarkerController {
@GetMapping("/testfreemaker")
public ModelAndView test() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("test");
modelAndView.addObject("broski", "Kyle");
return modelAndView;
}
}
Hello Kyle!
{% note info no-icon %}
freemarker提供了很多指令用于解析各种类型的数据模型,参考地址:http://freemarker.foofun.cn/ref_directives.html
{% endnote %}
server {
listen 80;
server_name localhost;
#rewrite ^(.*) https://$server_name$1 permanent;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location / {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/;
index index.html index.htm;
}
#静态资源
location /static/img/ {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/img/;
}
location /static/css/ {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/css/;
}
location /static/js/ {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/js/;
}
location /static/plugins/ {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/plugins/;
add_header Access-Control-Allow-Origin http://ucenter.localhost;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Methods GET;
}
location /plugins/ {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/plugins/;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
#文件服务
upstream fileserver{
server localhost:9000 weight=10;
}
server {
listen 80;
server_name file.localhost;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location /video {
proxy_pass http://fileserver;
}
location /mediafiles {
proxy_pass http://fileserver;
}
}
nginx.exe -s reload
location /course/preview/learning.html {
alias D:\BaiduNetdiskDownload/xc-ui-pc-static-portal/course/learning.html;
}
location /course/search.html {
root D:\BaiduNetdiskDownload/xc-ui-pc-static-portal;
}
location /course/learning.html {
root D:\BaiduNetdiskDownload/xc-ui-pc-static-portal;
}
videoObject
,修改videoServer和video,同时将所有的www.xuecheng-plus.com
替换成localhost
,用VSCode可以快速全部替换 data: {
videServer:'http://file.localhost',
courseId:'',
teachplanId:'',
teachplans:[],
videoObject : {
container: '#vdplay', //容器的ID或className
variable: 'player',//播放函数名称
poster:'/static/img/asset-video.png',//封面图片
//loaded: 'loadedHandler', //当播放器加载后执行的函数
video:'http://file.localhost/video/a/9/a92da96ebcf28dfe194a1e2c393dd860/a92da96ebcf28dfe194a1e2c393dd860.mp4'
// video: [//视频地址列表形式
// ['http://file.xuecheng-plus.com/video/3/a/3a5a861d1c745d05166132c47b44f9e4/3a5a861d1c745d05166132c47b44f9e4.mp4', 'video/mp4', '中文标清', 0]
// ]
},
player : null,
preview:false
}
www.xuecheng-plus.com
替换成localhost
localhost/course/course_template.html
,点击视频封面,会访问localhost/course/preview/learning.html?id=82
,并且可以播放视频course_template.html
,拷贝至content-api打的resources/template
目录下,复制一份并重命名为course_template.ftl
@Slf4j
@RestController
public class CoursePublishController {
@GetMapping("/coursepreview/{courseId}")
public ModelAndView preview(@PathVariable("courseId") Long courseId){
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("course_template");
modelAndView.addObject("model", null);
return modelAndView;
}
}
#后台网关
upstream gatewayserver{
server 127.0.0.1:53010 weight=10;
}
server {
listen 80;
server_name localhost;
....
#api
location /api/ {
proxy_pass http://gatewayserver/;
}
····
}
nginx.exe -s reload
localhost/api/content/coursepreview/1
页面样式正常,但是现在的内容是静态内容,写死的,我们需要调用Service方法获取模型数据,并进行页面渲染@Data
public class CoursePreviewDto {
/**
* 课程基本计划、课程营销信息
*/
CourseBaseInfoDto courseBaseInfoDto;
/**
* 课程计划信息
*/
List teachplans;
/**
* 师资信息暂时不加
*/
}
/**
* 课程预览、发布接口
*/
public interface CoursePublishService {
/**
* 根据课程id获取课程预览信息
* @param courseId 课程id
* @return package com.xuecheng.content.model.dto.CoursePreviewDto;
*/
CoursePreviewDto getCoursePreviewInfo(Long courseId);
}
@Slf4j
@Service
public class CoursePublishServiceImpl implements CoursePublishService {
@Autowired
private CourseBaseInfoService courseBaseInfoService;
@Autowired
private TeachplanService teachplanService;
@Override
public CoursePreviewDto getCoursePreviewInfo(Long courseId) {
CoursePreviewDto coursePreviewDto = new CoursePreviewDto();
// 根据课程id查询 课程基本信息、营销信息
CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.getCourseBaseInfo(courseId);
// 根据课程id,查询课程计划
List teachplanDtos = teachplanService.findTeachplanTree(courseId);
// 封装返回
coursePreviewDto.setCourseBaseInfoDto(courseBaseInfo);
coursePreviewDto.setTeachplans(teachplanDtos);
return coursePreviewDto;
}
}
@Slf4j
@RestController
public class CoursePublishController {
@Autowired
private CoursePublishService coursePublishService;
@GetMapping("/coursepreview/{courseId}")
public ModelAndView preview(@PathVariable("courseId") Long courseId){
CoursePreviewDto coursePreviewInfo = coursePublishService.getCoursePreviewInfo(courseId);
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("course_template");
modelAndView.addObject("model", coursePreviewInfo);
return modelAndView;
}
}
.env
文件VUE_APP_SERVER_API_URL=http://localhost/api
course_template.ftl
上#openapi
location /open/content/ {
proxy_pass http://gatewayserver/content/open/;
}
location /open/media/ {
proxy_pass http://gatewayserver/media/open/;
}
/open/content/course/whole/{courseId}
@Slf4j
@RestController
@RequestMapping("/open")
public class CourseOpenController {
@Autowired
private CoursePublishService coursePublishService;
@GetMapping("/course/whole/{courseId}")
public CoursePreviewDto getPreviewInfo(@PathVariable Long courseId){
// 获取课程预览信息
return coursePublishService.getCoursePreviewInfo(courseId);
}
}
现在我们重启nginx和内容管理服务,访问视频播放页面,右侧已经可以成功显示目录了,如果没显示,访问http://localhost/open/content/course/whole/160
查看是否能查询到数据
3. 当我们点击右侧目录时,会自动跳转到对应的小节视频,所以我们来编写获取视频地址的接口,在media-api下定义MediaOpenController类,并定义接口/open/media/preview/{courseId}
@Slf4j
@RestController
@RequestMapping("/open")
public class MediaOpenController {
@Autowired
private MediaFileService mediaFileService;
@GetMapping("/preview/{mediaId}")
public RestResponse getMediaUrl(@PathVariable String mediaId) {
MediaFiles mediaFile = mediaFileService.getFileById(mediaId);
if (mediaFile == null || StringUtils.isEmpty(mediaFile.getUrl())) {
XueChengPlusException.cast("视频还没有转码处理");
}
return RestResponse.success(mediaFile.getUrl());
}
}
那么到此为止,两个接口都开发完毕,我们点击右侧目录,可以正确的播放视频
{% note info no-icon %}
{% note pink no-icon %}
为什么课程要审核通过才可以发布呢?
如何控制课程审核通过才可以发布呢?
下面是课程状态的转换关系
说明如下
未提交
,发布状态为未发布
提交审核
操作,此时课程的审核状态为已提交
已提交
时,运营平台人员对课程进行审核已提交
,运营平台人员再次审核课程已发布
下架
操作可以更改课程发布状态为下架
上架
操作可以再次发布课程,上架后课程发布状态为发布
提交审核
时,查看浏览器发出的请求请求网址: http://localhost:8601/api/content/courseaudit/commit/1
请求方法: POST
@PostMapping("/courseaudit/commit/{courseId}")
public void commitAudit(@PathVariable Long courseId) {
}
已提交
已提交
/**
* 提交审核
* @param courseId 课程id
*/
void commitAudit(Long courseId);
@Transactional
@Override
public void commitAudit(Long companyId, Long courseId) {
// 查询课程基本信息
CourseBase courseBase = courseBaseMapper.selectById(courseId);
// 查询课程营销信息
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
// 查询课程基本信息、课程营销信息
CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.getCourseBaseInfo(courseId);
// 查询课程计划
List teachplanTree = teachplanService.findTeachplanTree(courseId);
// 1. 约束
String auditStatus = courseBaseInfo.getAuditStatus();
// 1.1 审核完后,方可提交审核
if ("202003".equals(auditStatus)) {
XueChengPlusException.cast("该课程现在属于待审核状态,审核完成后可再次提交");
}
// 1.2 本机构只允许提交本机构的课程
if (!companyId.equals(courseBaseInfo.getCompanyId())) {
XueChengPlusException.cast("本机构只允许提交本机构的课程");
}
// 1.3 没有上传图片,不允许提交
if (StringUtils.isEmpty(courseBaseInfo.getPic())) {
XueChengPlusException.cast("没有上传课程封面,不允许提交审核");
}
// 1.4 没有添加课程计划,不允许提交审核
if (teachplanTree.isEmpty()) {
XueChengPlusException.cast("没有添加课程计划,不允许提交审核");
}
// 2. 准备封装返回对象
CoursePublishPre coursePublishPre = new CoursePublishPre();
BeanUtils.copyProperties(courseBaseInfo, coursePublishPre);
coursePublishPre.setMarket(JSON.toJSONString(courseMarket));
coursePublishPre.setTeachplan(JSON.toJSONString(teachplanTree));
coursePublishPre.setCompanyId(companyId);
coursePublishPre.setCreateDate(LocalDateTime.now());
// 3. 设置预发布记录状态为已提交
coursePublishPre.setStatus("202003");
// 判断是否已经存在预发布记录,若存在,则更新
CoursePublishPre coursePublishPreUpdate = coursePublishPreMapper.selectById(courseId);
if (coursePublishPreUpdate == null) {
coursePublishPreMapper.insert(coursePublishPre);
} else {
coursePublishPreMapper.updateById(coursePublishPre);
}
// 4. 设置课程基本信息审核状态为已提交
courseBase.setAuditStatus("202003");
courseBaseMapper.updateById(courseBase);
}
@PostMapping("/courseaudit/commit/{courseId}")
public void commitAudit(@PathVariable Long courseId) {
Long companyId = 1232141425L;
coursePublishService.commitAudit(companyId, courseId);
}
数据库事务
,由于应用主要靠关系数据库来控制事务,而数据库通常和应用在同一个服务器,所以基于关系型数据库的事务又被称为本地事务分布式事务
begin transaction;
// 1. 本地数据库操作:张三减少金额
// 2. 本第数据库操作:李四增加金额
end transaction;
begin transaction;
// 1. 本地数据库操作:张三减少金额
// 2. 远程调用:李四增加金额
end transaction;
课程发布任务
,这里使用本地事务保证课程发布信息保存成功,同时消息表也保存成功发布
,查看浏览器发送的请求请求网址: http://localhost:8601/api/content/coursepublish/1
请求方法: POST
@PostMapping("/coursepublish/{courseId}")
public void coursePublish(@PathVariable Long courseId) {
}
已发布
已发布
/**
* 发布课程
* @param companyId
* @param courseId
*/
void publishCourse(Long companyId, Long courseId);
@Transactional
@Override
public void publishCourse(Long companyId, Long courseId) {
// 1. 约束校验
// 1.1 获取课程预发布表数据
CoursePublishPre coursePublishPre = coursePublishPreMapper.selectById(courseId);
if (coursePublishPre == null) {
XueChengPlusException.cast("请先提交课程审核,审核通过后方可发布");
}
// 1.2 课程审核通过后,方可发布
if (!"202004".equals(coursePublishPre.getStatus())) {
XueChengPlusException.cast("操作失败,课程审核通过后方可发布");
}
// 1.3 本机构只允许发布本机构的课程
if (!coursePublishPre.getCompanyId().equals(companyId)) {
XueChengPlusException.cast("操作失败,本机构只允许发布本机构的课程");
}
// 2. 向课程发布表插入数据
saveCoursePublish(courseId);
// 3. 向消息表插入数据
saveCoursePublishMessage(courseId);
// 4. 删除课程预发布表对应记录
coursePublishPreMapper.deleteById(courseId);
}
/**
* 保存课程发布信息
* @param courseId 课程id
*/
private void saveCoursePublish(Long courseId) {
CoursePublishPre coursePublishPre = coursePublishPreMapper.selectById(courseId);
if (coursePublishPre == null) {
XueChengPlusException.cast("课程预发布数据为空");
}
CoursePublish coursePublish = new CoursePublish();
BeanUtils.copyProperties(coursePublishPre, coursePublish);
// 设置发布状态为已发布
coursePublish.setStatus("203002");
CoursePublish coursePublishUpdate = coursePublishMapper.selectById(courseId);
// 有则更新,无则新增
if (coursePublishUpdate == null) {
coursePublishMapper.insert(coursePublish);
} else {
coursePublishMapper.updateById(coursePublish);
}
// 更新课程基本信息表的发布状态为已发布
CourseBase courseBase = courseBaseMapper.selectById(courseId);
courseBase.setAuditStatus("203002");
courseBaseMapper.updateById(courseBase);
}
/**
* TODO 待会儿我们再来实现
* 保存消息表
* @param courseId 课程id
*/
private void saveCoursePublishMessage(Long courseId) {
}
/**
* 课程发布接口
*
* @param courseId 课程id
*/
@PostMapping("/coursepublish/{courseId}")
public void coursePublish(@PathVariable Long courseId) {
Long companyId = 1232141425L;
coursePublishService.publishCourse(companyId, courseId);
}
审核通过
发布
忽略
,配置任务阻塞处理策略为丢弃后续调度
public interface MqMessageService extends IService {
/**
* 扫描消息表记录,采用与扫描视频处理表相同的思路
* @param shardIndex 分片序号
* @param shardTotal 分片总数
* @param count 扫描记录数
* @return java.util.List 消息记录
*/
List getMessageList(int shardIndex, int shardTotal, String messageType, int count);
/**
* 添加消息
* @param businessKey1 业务id
* @param businessKey2 业务id
* @param businessKey3 业务id
* @return com.xuecheng.messagesdk.model.po.MqMessage 消息内容
*/
MqMessage addMessage(String messageType, String businessKey1, String businessKey2, String businessKey3);
/**
* 完成任务
* @param id 消息id
* @return int 更新成功:1
*/
int completed(long id);
/**
* 完成阶段任务
* @param id 消息id
* @return int 更新成功:1
*/
int completedStageOne(long id);
int completedStageTwo(long id);
int completedStageThree(long id);
int completedStageFour(long id);
/**
* 查询阶段状态
* @param id
* @return int
*/
int getStageOne(long id);
int getStageTwo(long id);
int getStageThree(long id);
int getStageFour(long id);
}
/**
* 消息处理抽象类
*/
@Slf4j
@Data
public abstract class MessageProcessAbstract {
@Autowired
MqMessageService mqMessageService;
/**
* 任务处理
* @param mqMessage 执行任务内容
* @return boolean true:处理成功,false处理失败
*/
public abstract boolean execute(MqMessage mqMessage);
/**
* 扫描消息表多线程执行任务
* @param shardIndex 分片序号
* @param shardTotal 分片总数
* @param messageType 消息类型
* @param count 一次取出任务总数
* @param timeout 预估任务执行时间,到此时间如果任务还没有结束则强制结束 单位秒
* @return void
*/
public void process(int shardIndex, int shardTotal, String messageType, int count, long timeout) {
try {
//扫描消息表获取任务清单
List messageList = mqMessageService.getMessageList(shardIndex, shardTotal, messageType, count);
//任务个数
int size = messageList.size();
log.debug("取出待处理消息" + size + "条");
if (size <= 0) {
return;
}
//创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(size);
//计数器
CountDownLatch countDownLatch = new CountDownLatch(size);
messageList.forEach(message -> {
threadPool.execute(() -> {
log.debug("开始任务:{}", message);
//处理任务
try {
boolean result = execute(message);
if (result) {
log.debug("任务执行成功:{})", message);
//更新任务状态,删除消息表记录,添加到历史表
int completed = mqMessageService.completed(message.getId());
if (completed > 0) {
log.debug("任务执行成功:{}", message);
} else {
log.debug("任务执行失败:{}", message);
}
}
} catch (Exception e) {
e.printStackTrace();
log.debug("任务出现异常:{},任务:{}", e.getMessage(), message);
}
//计数
countDownLatch.countDown();
log.debug("结束任务:{}", message);
});
});
//等待,给一个充裕的超时时间,防止无限等待,到达超时时间还没有处理完成则结束任务
countDownLatch.await(timeout, TimeUnit.SECONDS);
System.out.println("结束....");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
MessageProcessAbstract
,编写任务执行方法@Slf4j
@Component
public class MessageProcessClass extends MessageProcessAbstract {
@Autowired
private ;
@Override
public boolean execute(MqMessage mqMessage) {
// 1. 从获取任务id
Long id = mqMessage.getId();
log.debug("开始执行任务:{}", id);
// 2. 获取1阶段状态
MqMessageService mqMessageService = this.getMqMessageService();
int stageOne = mqMessageService.getStageOne(id);
if (stageOne == 0) {
log.debug("开始执行第一阶段的任务");
// TODO 第一阶段任务逻辑
// 一阶段任务完成,此方法的逻辑是将stageOne设置为1
int i = mqMessageService.completedStageOne(1);
if (i == 1) {
log.debug("完成一阶段任务");
}
} else {
log.debug("一阶段任务已经完成,无需再次执行");
}
return true;
}
}
@SpringBootTest
public class MessageProcessClassTest {
@Autowired
MessageProcessClass messageProcessClass;
@Test
public void test() {
System.out.println("开始执行-----》" + LocalDateTime.now());
messageProcessClass.process(0, 1, "test", 2, 10);
System.out.println("结束执行-----》" + LocalDateTime.now());
}
}
test
的信息,并执行测试方法,观察控制台输出
com.xuecheng
xuecheng-plus-message-sdk
0.0.1-SNAPSHOT
com.xuxueli
xxl-job-core
@Slf4j
@Component
public class CoursePublishTask extends MessageProcessAbstract {
@XxlJob("CoursePublishJobHandler")
private void coursePublishJobHandler() {
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
process(shardIndex, shardTotal, "course_publish", 5, 60);
log.debug("测试任务执行中...");
}
@Override
public boolean execute(MqMessage mqMessage) {
log.debug("开始执行课程发布任务,课程id:{}", mqMessage.getBusinessKey1());
// TODO 将课程信息静态页面上传至MinIO
// TODO 存储到Redis
// TODO 存储到ElasticSearch
return true;
}
}
content-service-dev.yaml
配置xxl-job的连接信息xxl:
job:
admin:
addresses: http://192.168.101.128:18088/xxl-job-admin/
executor:
appname: course-publish-job
address:
ip:
port: 10999
logpath: /data/applogs/xxl-job-jobhandler
logretentiondays: 30
accessToken: default_token
发布任务执行中...
字样
org.springframework.boot
spring-boot-starter-freemarker
@SpringBootTest
public class FreemarkerTest {
@Autowired
CoursePublishService coursePublishService;
@Test
public void testGenerateHtmlByTemplate() throws IOException, TemplateException {
// 1. 创建一个FreeMarker配置:
Configuration configuration = new Configuration(Configuration.getVersion());
// 2. 告诉FreeMarker在哪里可以找到模板文件。
String classpath = this.getClass().getResource("/").getPath();
configuration.setDirectoryForTemplateLoading(new File(classpath + "/templates/"));
// 2.1 指定字符编码
configuration.setDefaultEncoding("utf-8");
// 3. 创建一个数据模型,与模板文件中的数据模型类型保持一致,这里是CoursePreviewDto类型
CoursePreviewDto coursePreviewDto = coursePublishService.getCoursePreviewInfo(2L);
HashMap map = new HashMap<>();
map.put("model", coursePreviewDto);
// 4. 加载模板文件
Template template = configuration.getTemplate("course_template.ftl");
// 5. 将数据模型应用于模板
String content = FreeMarkerTemplateUtils.processTemplateIntoString(template, map);
InputStream inputStream = IOUtils.toInputStream(content);
FileOutputStream fileOutputStream = new FileOutputStream("D:\\test.html");
IOUtils.copy(inputStream,fileOutputStream);
}
}
静态化生成文件后,需要上传至分布式文件系统,根据微服务的职责划分,媒资管理服务负责维护系统中的文件,所以内容管理服务对页面静态化生成的html文件需要调用媒资管理服务的上传文件接口
微服务之间存在远程调用,在SpringCloud中可以使用Feign进行远程调用
{% note info no-icon %}
Feign是什么?
{% endnote %}
{% note pink no-icon %}
Feign是一个声明式的http客户端,官方地址:https://github.com/OpenFeign/feign
其作用就是帮助我们优雅的实现http请求发送,解决上面提到的远程调用问题
{% endnote %}
下面先准备Feign的开发环境,在content-service中添加依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
org.springframework.cloud
spring-cloud-starter-openfeign
io.github.openfeign
feign-httpclient
io.github.openfeign.form
feign-form
3.8.0
io.github.openfeign.form
feign-form-spring
3.8.0
feign:
client:
config:
default: # default全局的配置
loggerLevel: BASIC # 日志级别,BASIC就是基本的请求和响应信息
hystrix:
enabled: true
circuitbreaker:
enabled: true
httpclient:
enabled: true # 开启feign对HttpClient的支持
max-connections: 200 # 最大的连接数
max-connections-per-route: 50 # 每个路径的最大连接数
- data-id: feign-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
import feign.codec.Encoder;
import feign.form.spring.SpringFormEncoder;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.cloud.openfeign.support.SpringEncoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Scope;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.commons.CommonsMultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.OutputStream;
@Configuration
public class MultipartSupportConfig {
@Autowired
private ObjectFactory messageConverters;
@Bean
@Primary//注入相同类型的bean时优先使用
@Scope("prototype")
public Encoder feignEncoder() {
return new SpringFormEncoder(new SpringEncoder(messageConverters));
}
//将file转为Multipart
public static MultipartFile getMultipartFile(File file) {
FileItem item = new DiskFileItemFactory().createItem("file", MediaType.MULTIPART_FORM_DATA_VALUE, true, file.getName());
try (FileInputStream inputStream = new FileInputStream(file);
OutputStream outputStream = item.getOutputStream();) {
IOUtils.copy(inputStream, outputStream);
} catch (Exception e) {
e.printStackTrace();
}
return new CommonsMultipartFile(item);
}
}
/**
* 媒资管理服务远程调用接口
*/
@FeignClient(value = "media-api", configuration = MultipartSupportConfig.class)
public interface MediaServiceClient {
@RequestMapping(value = "/media/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String upload(@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName);
}
@EnableFeignClients(basePackages={"com.xuecheng.content.feignclient"})
/**
* 测试使用feign远程上传文件
*/
@SpringBootTest
public class FeignUploadTest {
@Autowired
MediaServiceClient mediaServiceClient;
//远程调用,上传文件
@Test
public void test() {
MultipartFile multipartFile = MultipartSupportConfig.getMultipartFile(new File("D:\\test.html"));
String result = mediaServiceClient.upload(multipartFile, "course", "test.html");
System.out.println(result);
}
}
当微服务运行不正常,会导致无法正常调用微服务,此时会出现异常,如果这种异常不去处理,可能会导致雪崩效应
微服务的雪崩效应表现在服务与服务之间调用,当其中一个服务无法提供服务时,可能导致其他服务也挂掉。
如何解决由于微服务异常所引起的雪崩效应呢?
熔断降级的相同点都是为了解决微服务系统崩溃的问题,但它们是两个不同的技术手段,两者又存在联系
而这都是为了保护系统,熔断是当下服务异常时一种保护系统的手段,降级是熔断后上游服务处理熔断的方法
feign:
hystrix:
enabled: true
circuitbreaker:
enabled: true
/**
* 媒资管理服务远程调用接口
*/
@FeignClient(value = "media-api", configuration = MultipartSupportConfig.class, fallback = MediaServiceClientFallback.class)
public interface MediaServiceClient {
@RequestMapping(value = "/media/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String upload(@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName);
}
@Slf4j
@Component
public class MediaServiceClientFallback implements MediaServiceClient{
@Override
public String upload(MultipartFile upload, String folder, String objectName) {
log.debug("方式一:熔断处理,无法获取异常");
return null;
}
}
/**
* 媒资管理服务远程调用接口
*/
@FeignClient(value = "media-api", configuration = MultipartSupportConfig.class, fallbackFactory = MediaServiceClientFallbackFactory.class)
public interface MediaServiceClient {
@RequestMapping(value = "/media/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String upload(@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName);
}
@Slf4j
@Component
public class MediaServiceClientFallbackFactory implements FallbackFactory {
@Override
public MediaServiceClient create(Throwable throwable) {
return new MediaServiceClient() {
@Override
public String upload(MultipartFile upload, String folder, String objectName) {
log.debug("方式二:熔断处理,熔断异常:{}", throwable.getMessage());
return null;
}
};
}
}
// 注入消息SDK
@Autowired
private MqMessageService mqMessageService;
/**
* 保存消息表
*
* @param courseId 课程id
*/
private void saveCoursePublishMessage(Long courseId) {
MqMessage mqMessage = mqMessageService.addMessage("course_publish", String.valueOf(courseId), null, null);
if(mqMessage == null){
XueChengPlusException.cast("添加消息记录失败");
}
}
/**
* 课程静态化
* @param courseId 课程id
* @return 静态化文件
*/
File generateCourseHtml(Long courseId);
/**
* 上传课程静态化页面
* @param courseId 课程id
* @param file 静态化文件
*/
void uploadCourseHtml(Long courseId, File file);
@Override
public File generateCourseHtml(Long courseId) {
File htmlFile = null;
try {
// 1. 创建一个Freemarker配置
Configuration configuration = new Configuration(Configuration.getVersion());
// 2. 告诉Freemarker在哪里可以找到模板文件
String classPath = this.getClass().getResource("/").getPath();
configuration.setDirectoryForTemplateLoading(new File(classPath + "/templates/"));
configuration.setDefaultEncoding("utf-8");
// 3. 创建一个模型数据,与模板文件中的数据模型保持一致,这里是CoursePreviewDto类型
CoursePreviewDto coursePreviewDto = this.getCoursePreviewInfo(courseId);
HashMap map = new HashMap<>();
map.put("model", coursePreviewDto);
// 4. 加载模板文件
Template template = configuration.getTemplate("course_template.ftl");
// 5. 将数据模型应用于模板
String content = FreeMarkerTemplateUtils.processTemplateIntoString(template, map);
// 5.1 将静态文件内容输出到文件中
InputStream inputStream = IOUtils.toInputStream(content);
htmlFile = File.createTempFile("course", ".html");
FileOutputStream fos = new FileOutputStream(htmlFile);
IOUtils.copy(inputStream, fos);
} catch (Exception e) {
log.debug("课程静态化失败:{}", e.getMessage());
e.printStackTrace();
}
return htmlFile;
}
@Override
public void uploadCourseHtml(Long courseId, File file) {
MultipartFile multipartFile = MultipartSupportConfig.getMultipartFile(file);
String course = mediaServiceClient.upload(multipartFile, "course", courseId + ".html");
if(course == null){
XueChengPlusException.cast("远程调用媒资服务上传文件失败");
}
}
@Slf4j
@Component
public class CoursePublishTask extends MessageProcessAbstract {
@Autowired
private CoursePublishService coursePublishService;
@XxlJob("CoursePublishJobHandler")
private void coursePublishJobHandler() {
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
process(shardIndex, shardTotal, "course_publish", 5, 60);
}
@Override
public boolean execute(MqMessage mqMessage) {
log.debug("开始执行课程发布任务,课程id:{}", mqMessage.getBusinessKey1());
// TODO 一阶段:将课程信息静态页面上传至MinIO
String courseId = mqMessage.getBusinessKey1();
generateCourseHtml(mqMessage, Long.valueOf(courseId));
// TODO 二阶段:存储到Redis
// TODO 三阶段:存储到ElasticSearch
// 三阶段都成功,返回true
return true;
}
private void generateCourseHtml(MqMessage mqMessage, Long courseId) {
// 1. 幂等性判断
// 1.1 获取消息id
Long id = mqMessage.getId();
// 1.2 获取小任务阶段状态
MqMessageService mqMessageService = this.getMqMessageService();
int stageOne = mqMessageService.getStageOne(id);
// 1.3 判断小任务阶段是否完成
if (stageOne == 1) {
log.debug("当前阶段为静态化课程信息任务,已完成,无需再次处理,任务信息:{}", mqMessage);
return;
}
// 2. 生成静态页面
File file = coursePublishService.generateCourseHtml(Long.valueOf(courseId));
if (file == null) {
XueChengPlusException.cast("课程静态化异常");
}
// 3. 将静态页面上传至MinIO
coursePublishService.uploadCourseHtml(Long.valueOf(courseId), file);
// 4. 保存第一阶段状态
mqMessageService.completedStageOne(id);
}
}
location /course/ {
proxy_pass http://fileserver/mediafiles/course/;
}
{% note info no-icon %}
{% note info no-icon %}
{% note info no-icon %}
详细的部署过程在文章中的1.4小节有详细叙述
{% link ElasticSearch–分布式搜索引擎, https://cyborg2077.github.io/2022/12/24/ElasticSearch/, https://s1.ax1x.com/2023/03/06/ppZ9JIS.png %}
拉取daocker镜像,这里采用的是ElasticSearch的7.12.1版本镜像
docker pull elasticsearch:7.12.1
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
elasticsearch:7.12.1
{
"name" : "64d819e15a86",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "S2S8sXLZTVSDjN25Qcq7KA",
"version" : {
"number" : "7.12.1",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "3186837139b9c6b6d23c3200870651f10d3343b7",
"build_date" : "2021-04-20T20:56:39.040728659Z",
"build_snapshot" : false,
"lucene_version" : "8.8.0",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
docker pull kibana:7.12.1
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
# 进入容器内部
docker exec -it elasticsearch /bin/bash
# 在线下载并安装
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
#退出
exit
#重启容器
docker restart elasticsearch
PUT /course-publish
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"companyId": {
"type": "keyword"
},
"companyName": {
"type": "text",
"search_analyzer": "ik_smart",
"analyzer": "ik_max_word"
},
"name": {
"type": "text",
"search_analyzer": "ik_smart",
"analyzer": "ik_max_word"
},
"users": {
"type": "text",
"search_analyzer": "ik_smart",
"analyzer": "ik_max_word"
},
"mt": {
"type": "keyword"
},
"mtName": {
"type": "keyword"
},
"st": {
"type": "keyword"
},
"stName": {
"type": "keyword"
},
"grade": {
"type": "keyword"
},
"teachmode": {
"type": "keyword"
},
"pic": {
"type": "text",
"index": false
},
"description": {
"type": "text",
"search_analyzer": "ik_smart",
"analyzer": "ik_max_word"
},
"createDate": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"status": {
"type": "keyword"
},
"remark": {
"type": "text",
"index": false
},
"charge": {
"type": "keyword"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"originalPrice": {
"type": "scaled_float",
"scaling_factor": 100
},
"validDays": {
"type": "integer"
}
}
}
}
search-dev.yaml
server:
servlet:
context-path: /search
port: 53080
elasticsearch:
hostlist: 192.168.101.128:9200
course:
index: course-publish
source_fields: id,companyId,companyName,name,users,grade,mt,mtName,st,stName,charge,pic,price,originalPrice,description,teachmode,validDays,createDate
@Configuration
public class ElasticsearchConfig {
// 1. 从nacos中读取es的地址,不过我这里只部署了单体ES,它这里可能是ES集群
@Value("${elasticsearch.hostlist}")
private String hostlist;
@Bean
public RestHighLevelClient restHighLevelClient() {
//2. 解析hostlist配置信息
String[] split = hostlist.split(",");
//3. 创建HttpHost数组,其中存放es主机和端口的配置信息
HttpHost[] httpHostArray = new HttpHost[split.length];
for (int i = 0; i < split.length; i++) {
String item = split[i];
httpHostArray[i] = new HttpHost(item.split(":")[0], Integer.parseInt(item.split(":")[1]), "http");
}
//4. 创建RestHighLevelClient客户端
return new RestHighLevelClient(RestClient.builder(httpHostArray));
}
}
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hostlist}")
private String hostlist;
@Bean
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(RestClient.builder(hostlist));
}
}
@Api(value = "课程信息索引接口", tags = "课程信息索引接口")
@RestController
@RequestMapping("/index")
public class CourseIndexController {
@Value("${elasticsearch.course.index}")
private String courseIndexStore;
@Autowired
IndexService indexService;
@ApiOperation("添加课程索引")
@PostMapping("course")
public Boolean add(@RequestBody CourseIndex courseIndex) {
Long id = courseIndex.getId();
if (id == null) {
XueChengPlusException.cast("课程id为空");
}
Boolean result = indexService.addCourseIndex(courseIndexStore, String.valueOf(id), courseIndex);
if (!result) {
XueChengPlusException.cast("添加课程索引失败");
}
return true;
}
}
@Api(value = "课程搜索接口", tags = "课程搜索接口")
@RestController
@RequestMapping("/course")
public class CourseSearchController {
@Autowired
CourseSearchService courseSearchService;
@ApiOperation("课程搜索列表")
@GetMapping("/list")
public SearchPageResultDto list(PageParams pageParams, SearchCourseParamDto searchCourseParamDto) {
return courseSearchService.queryCoursePubIndex(pageParams, searchCourseParamDto);
}
}
@Data
@ToString
public class SearchCourseParamDto {
//关键字
private String keywords;
//大分类
private String mt;
//小分类
private String st;
//难度等级
private String grade;
}
@Data
@ToString
public class SearchPageResultDto extends PageResult {
//大分类列表
List mtList;
//小分类列表
List stList;
public SearchPageResultDto(List items, long counts, long page, long pageSize) {
super(items, counts, page, pageSize);
}
}
public interface IndexService {
/**
* @param indexName 索引名称
* @param id 主键
* @param object 索引对象
* @return Boolean true表示成功,false失败
* @description 添加索引
* @author Mr.M
* @date 2022/9/24 22:57
*/
Boolean addCourseIndex(String indexName, String id, Object object);
/**
* @param indexName 索引名称
* @param id 主键
* @param object 索引对象
* @return Boolean true表示成功,false失败
* @description 更新索引
* @author Mr.M
* @date 2022/9/25 7:49
*/
Boolean updateCourseIndex(String indexName, String id, Object object);
/**
* @param indexName 索引名称
* @param id 主键
* @return java.lang.Boolean
* @description 删除索引
* @author Mr.M
* @date 2022/9/25 9:27
*/
Boolean deleteCourseIndex(String indexName, String id);
}
public interface CourseSearchService {
/**
* @param pageParams 分页参数
* @param searchCourseParamDto 搜索条件
* @return com.xuecheng.base.model.PageResult 课程列表
* @description 搜索课程列表
* @author Mr.M
* @date 2022/9/24 22:45
*/
SearchPageResultDto queryCoursePubIndex(PageParams pageParams, SearchCourseParamDto searchCourseParamDto);
}
@Slf4j
@Service
public class IndexServiceImpl implements IndexService {
@Autowired
RestHighLevelClient client;
@Override
public Boolean addCourseIndex(String indexName, String id, Object object) {
String jsonString = JSON.toJSONString(object);
IndexRequest indexRequest = new IndexRequest(indexName).id(id);
//指定索引文档内容
indexRequest.source(jsonString, XContentType.JSON);
//索引响应对象
IndexResponse indexResponse = null;
try {
indexResponse = client.index(indexRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("添加索引出错:{}", e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("添加索引出错");
}
String name = indexResponse.getResult().name();
System.out.println(name);
return name.equalsIgnoreCase("created") || name.equalsIgnoreCase("updated");
}
@Override
public Boolean updateCourseIndex(String indexName, String id, Object object) {
String jsonString = JSON.toJSONString(object);
UpdateRequest updateRequest = new UpdateRequest(indexName, id);
updateRequest.doc(jsonString, XContentType.JSON);
UpdateResponse updateResponse = null;
try {
updateResponse = client.update(updateRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("更新索引出错:{}", e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("更新索引出错");
}
DocWriteResponse.Result result = updateResponse.getResult();
return result.name().equalsIgnoreCase("updated");
}
@Override
public Boolean deleteCourseIndex(String indexName, String id) {
//删除索引请求对象
DeleteRequest deleteRequest = new DeleteRequest(indexName, id);
//响应对象
DeleteResponse deleteResponse = null;
try {
deleteResponse = client.delete(deleteRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("删除索引出错:{}", e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("删除索引出错");
}
//获取响应结果
DocWriteResponse.Result result = deleteResponse.getResult();
return result.name().equalsIgnoreCase("deleted");
}
}
@Slf4j
@Service
public class CourseSearchServiceImpl implements CourseSearchService {
@Value("${elasticsearch.course.index}")
private String courseIndexStore;
@Value("${elasticsearch.course.source_fields}")
private String sourceFields;
@Autowired
RestHighLevelClient client;
@Override
public SearchPageResultDto queryCoursePubIndex(PageParams pageParams, SearchCourseParamDto courseSearchParam) {
//设置索引
SearchRequest searchRequest = new SearchRequest(courseIndexStore);
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//source源字段过虑
String[] sourceFieldsArray = sourceFields.split(",");
searchSourceBuilder.fetchSource(sourceFieldsArray, new String[]{});
if (courseSearchParam == null) {
courseSearchParam = new SearchCourseParamDto();
}
//关键字
if (StringUtils.isNotEmpty(courseSearchParam.getKeywords())) {
//匹配关键字
MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(courseSearchParam.getKeywords(), "name", "description");
//设置匹配占比
multiMatchQueryBuilder.minimumShouldMatch("70%");
//提升另个字段的Boost值
multiMatchQueryBuilder.field("name", 10);
boolQueryBuilder.must(multiMatchQueryBuilder);
}
//过虑
if (StringUtils.isNotEmpty(courseSearchParam.getMt())) {
boolQueryBuilder.filter(QueryBuilders.termQuery("mtName", courseSearchParam.getMt()));
}
if (StringUtils.isNotEmpty(courseSearchParam.getSt())) {
boolQueryBuilder.filter(QueryBuilders.termQuery("stName", courseSearchParam.getSt()));
}
if (StringUtils.isNotEmpty(courseSearchParam.getGrade())) {
boolQueryBuilder.filter(QueryBuilders.termQuery("grade", courseSearchParam.getGrade()));
}
//分页
Long pageNo = pageParams.getPageNo();
Long pageSize = pageParams.getPageSize();
int start = (int) ((pageNo - 1) * pageSize);
searchSourceBuilder.from(start);
searchSourceBuilder.size(Math.toIntExact(pageSize));
//布尔查询
searchSourceBuilder.query(boolQueryBuilder);
//高亮设置
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.preTags("");
highlightBuilder.postTags("");
//设置高亮字段
highlightBuilder.fields().add(new HighlightBuilder.Field("name"));
searchSourceBuilder.highlighter(highlightBuilder);
//请求搜索
searchRequest.source(searchSourceBuilder);
//聚合设置
buildAggregation(searchRequest);
SearchResponse searchResponse = null;
try {
searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
log.error("课程搜索异常:{}", e.getMessage());
return new SearchPageResultDto(new ArrayList(), 0, 0, 0);
}
//结果集处理
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
//记录总数
TotalHits totalHits = hits.getTotalHits();
//数据列表
List list = new ArrayList<>();
for (SearchHit hit : searchHits) {
String sourceAsString = hit.getSourceAsString();
CourseIndex courseIndex = JSON.parseObject(sourceAsString, CourseIndex.class);
//取出source
Map sourceAsMap = hit.getSourceAsMap();
//课程id
Long id = courseIndex.getId();
//取出名称
String name = courseIndex.getName();
//取出高亮字段内容
Map highlightFields = hit.getHighlightFields();
if (highlightFields != null) {
HighlightField nameField = highlightFields.get("name");
if (nameField != null) {
Text[] fragments = nameField.getFragments();
StringBuffer stringBuffer = new StringBuffer();
for (Text str : fragments) {
stringBuffer.append(str.string());
}
name = stringBuffer.toString();
}
}
courseIndex.setId(id);
courseIndex.setName(name);
list.add(courseIndex);
}
SearchPageResultDto pageResult = new SearchPageResultDto<>(list, totalHits.value, pageNo, pageSize);
//获取聚合结果
List mtList = getAggregation(searchResponse.getAggregations(), "mtAgg");
List stList = getAggregation(searchResponse.getAggregations(), "stAgg");
pageResult.setMtList(mtList);
pageResult.setStList(stList);
return pageResult;
}
private void buildAggregation(SearchRequest request) {
request.source().aggregation(AggregationBuilders
.terms("mtAgg")
.field("mtName")
.size(100)
);
request.source().aggregation(AggregationBuilders
.terms("stAgg")
.field("stName")
.size(100)
);
}
private List getAggregation(Aggregations aggregations, String aggName) {
// 4.1.根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get(aggName);
// 4.2.获取buckets
List extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3.遍历
List brandList = new ArrayList<>();
for (Terms.Bucket bucket : buckets) {
// 4.4.获取key
String key = bucket.getKeyAsString();
brandList.add(key);
}
return brandList;
}
}
{% endtabs %}