微服务(六)-实现搜索功能、Kafka和消息队列、zookeeper、AOP、熔断器Hystrix、服务降级、网站限流原理

1 达内知道实现搜索功能

1.1 搜索功能的业务流程

  我们希望实现用户登录之后,在首页输入搜索关键字之后,点击搜索按钮,跳转到搜索结果页,页面中分页显示所有搜索结果。

实现完整效果的业务流程为:

  1. 将mysql数据库中question表中的所有数据同步到ES

  2. 根据用户输入的搜索关键字,到ES中查询匹配的问题

  3. 分页显示在搜索结果页面

  4. 重构学生发布问题的方法,将学生发布到数据库的问题同时新增到ES

1.2 配置knows-search模块

添加能够注册到Nacos的依赖:

 <dependency>
     <groupId>com.alibaba.cloud</groupId>
     <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
 </dependency>
 <dependency>
     <groupId>cn.tedu</groupId>
     <artifactId>knows-commons</artifactId>
 </dependency>

application.properties文件添加Nacos的位置,注册到注册中心:

 spring.cloud.nacos.discovery.server-addr=localhost:8848

SpringBoot启动类添加注册到Nacos的注解和Ribbon的支持:

 @SpringBootApplication
 @EnableDiscoveryClient
 public class KnowsSearchApplication {
 
     public static void main(String[] args) {
         SpringApplication.run(KnowsSearchApplication.class, args);
    }
     
     //添加Ribbon支持
     @Bean
     @LoadBalanced
     public RestTemplate restTemplate(){
         return new RestTemplate();
    }
 
 }

1.3 同步数据到ES

  实际开发中,这个同步过程数据量可能很大,我们需要分批分次的从数据中查询然后增加到ES中,所以操作上是分页查询,循环向ES中新增,分页查询所有问题是faq模块的内容,需要编写Rest接口。分页查询当前数据库中所有数据的总页数也是faq模块的Rest接口,只有确定了总页数,才能确定循环新增到ES的次数。全查所有问题Mybatis Plus提供了方法,直接从业务逻辑层开始。

转到Faq模块

IQuestionService接口添加方法:

 // 分页查询所有问题,用于同步到ES
 PageInfo<Question> getQuestions(
                    Integer pageNum, Integer pageSize);

QuestionServiceImpl实现类方法:

 @Override
 public PageInfo<Question> getQuestions(
         Integer pageNum, Integer pageSize) {
     // 设置分页查询
     PageHelper.startPage(pageNum,pageSize);
     List<Question> list=questionMapper.selectList(null);
     //别忘了返回
     return new PageInfo<>(list);
 }

然后编写控制层(Rest接口):QuestionController

     //根据页码分页查询所有问题,只需要获得该页内容就行
     @GetMapping("/page")
     public List<Question> questions(
                             Integer pageNum,Integer pageSize){
         PageInfo<Question> pageInfo=
                 questionService.getQuestions(pageNum,pageSize);
         return pageInfo.getList();//List是PageInfo里面的一部分
    }
 
     // 返回按指定pageSize分页的总页数
     @GetMapping("/page/count")
     public Integer pageCount(Integer pageSize){
         // MybatisPlus提供的直接查询Question当前表条数的方法
         Integer count=questionService.count();
 //     return count%pageSize==0 ? count/pageSize : count/pageSize+1;
         return (count+pageSize-1)/pageSize;
    }

可以发同步测试:

(1)http://localhost:8001/v2/questions/page?pageNum=1&pageSize=8

(2)http://localhost:8001/v2/questions/page/count?pageSize=8

faq模块的准备工作完成。

转到search模块:

创建一个QuestionVo类,将查询到的数据保存到ES中,QuestionVo和数据库实体类Question属性一致,可以直接复制到search模块,修改如下:

 package cn.tedu.knows.search.vo;
 
 import cn.tedu.knows.commons.model.Tag;
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.experimental.Accessors;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Transient;
 import org.springframework.data.elasticsearch.annotations.DateFormat;
 import org.springframework.data.elasticsearch.annotations.Document;
 import org.springframework.data.elasticsearch.annotations.Field;
 import org.springframework.data.elasticsearch.annotations.FieldType;
 
 import java.io.Serializable;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.List;
 
 /**
  * <p>
  *
  * </p>
  *
  * @author tedu.cn
  * @since 2021-08-23
  */
 @Data
 @EqualsAndHashCode(callSuper = false)
 @Accessors(chain = true)
 @Document(indexName = "knows")
 public class QuestionVo implements Serializable {
 
     private static final long serialVersionUID = 1L;
 
     // 声明问题状态的3个常量
     public static final Integer POSTED=0;  // 已提交\未回复
     public static final Integer SOLVING=1; // 正在采纳\已回复
     public static final Integer SOLVED=2;  // 已经采纳\已解决
 
     @Id
     private Integer id;
 
     /**
      * 问题的标题
      */
     @Field(type = FieldType.Text,analyzer = "ik_smart",
             searchAnalyzer = "ik_smart")
     private String title;
 
     /**
      * 提问内容
      */
     @Field(type = FieldType.Text,analyzer = "ik_smart",
             searchAnalyzer = "ik_smart")
     private String content;
 
     /**
      * 提问者用户名
      */
     @Field(type = FieldType.Keyword)
     private String userNickName;
 
     /**
      * 提问者id
      */
     @Field(type = FieldType.Integer)
     private Integer userId;
 
     /**
      * 创建时间
      */
     @Field(type = FieldType.Date,format = DateFormat.basic_date_time)
     private LocalDateTime createtime;
 
     /**
      * 状态,0-》未回答,1-》待解决,2-》已解决
      */
     @Field(type = FieldType.Integer)
     private Integer status;
 
     /**
      * 浏览量
      */
     @Field(type = FieldType.Integer)
     private Integer pageViews;
 
     /**
      * 该问题是否公开,所有学生都可见,0-》否,1-》是
      */
     @Field(type = FieldType.Integer)
     private Integer publicStatus;
 
     @Field(type = FieldType.Date,format = DateFormat.basic_date)
     private LocalDate modifytime;
 
     @Field(type = FieldType.Integer)
     private Integer deleteStatus;
 
     @Field(type = FieldType.Keyword)
     private String tagNames;
 
     /**
      * 当前问题包含的标签集合
      */
     //@Transient:临时的
     // 不需要将这个列的值保存到ES
     @Transient
     private List<Tag> tags;
 
 }

创建QuestionRepository接口,将Question对象和ES进行数据访问层的连接:

 @Repository
 public interface QuestionRepository extends ElasticsearchRepository<QuestionVo,Integer> {
 }

下面编写执行同步数据的业务逻辑层,还是先写接口,创建service包,包中创建IQuestionService接口添加方法如下:

 public interface IQuestionService {
     // 接口中写方法:同步数据到ES
     void syncData();
 }

在service包下创建impl包,创建QuestionServiceImpl类,实现IQuestionService接口,代码如下:

 @Service
 @Slf4j
 public class QuestionServiceImpl implements IQuestionService {
     @Resource
     private QuestionRepository questionRepository;
     @Resource
     private RestTemplate restTemplate;
 
     @Override
     public void syncData() {
         // 先发Ribbon请求到faq模块,获得总页数,以便循环
         String url= "http://faq-service/v2/questions/page/count?pageSize={1}";
         int pageSize=8;
         Integer total=restTemplate.getForObject(url,Integer.class,pageSize);
         for(int i=1;i<=total;i++){
             // 循环中发Ribbon到faq分页查询所有问题
             url= "http://faq-service/v2/questions/page?pageNum={1}&pageSize={2}";
             QuestionVo[] questions=restTemplate.getForObject(
                     url , QuestionVo[].class , i , pageSize);
             questionRepository.saveAll(Arrays.asList(questions));
             log.debug("完成第{}页的新增",i);
        }
    }
 }

这个业务只会在同步数据时运行一次,不需要设置控制器访问,所以在测试类中调用即可。测试类代码如下:

 package cn.tedu.knows.search;
 
 import cn.tedu.knows.search.service.IQuestionService;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
 
 import javax.annotation.Resource;
 
 @SpringBootTest
 public class TestSync {
     @Resource
     IQuestionService questionService;
     @Test
     public void testSync(){
         //调用同步数据到ES的方法
         questionService.syncData();;
    }
 }
 

  测试前启动nacos、ES、faq-service,能够查询到数据表示查询成功。

1.4 根据关键字执行查询

  我们需要将用户输入的查询关键字获得之后,进行查询才能获得查询结果,其中的核心是如何编写查询代码,首先要理解查询的业务逻辑,我们先写个sql语句查询数据库代码如下:

 SELECT * FROM question
 WHERE (title LIKE '%java%' OR content LIKE '%java%') AND (user_id=11 OR public_status=1)

Es中查询示意图如下:

match:匹配分词,就是数据库中的模糊查询,查询的只能是字符串

term:判断相等,用户id和公开状态都是int类型,不能match,只能term

must:逻辑"与"就是数据库中的"and",java中的"&&"

should:逻辑"或"就是数据库中的"or",java中的"||"

具体执行代码如下:

 ### 条件搜索,查询用户11 或者 公开的 同时 标题或者内容中包含Java的问题
 POST http://localhost:9200/knows/_search
 Content-Type: application/json
 
 {
  "query": {
    "bool": {
      "must": [{
        "bool": {
          "should": [{"match": {"title": "java"}}, {"match": {"content": "java"}}]
        }
      }, {
        "bool": {
          "should": [{"term": {"publicStatus": 1}}, {"term": {"userId": 11}}]
        }
      }]
    }
  }
 }

测试输出查询结果,表示查询成功。

1.5 从ES查询匹配结果

  上次课我们已经梳理了按用户输入的关键字进行查询的思路,后来提供给大家ES的查询语句,下面实现查询的数据访问层代码,在QuestionRepository接口中添加按要求实现的查询方法:

 @Query("{\n" +
         "   \"bool\": {\n" +
         "     \"must\": [{\n" +
         "       \"bool\": {\n" +
         "         \"should\": [{\"match\": {\"title\": \"?0\"}}, {\"match\": {\"content\": \"?1\"}}]\n" +
         "       }\n" +
         "     }, {\n" +
         "       \"bool\": {\n" +
         "         \"should\": [{\"term\": {\"publicStatus\": 1}}, {\"term\": {\"userId\": ?2}}]\n" +
         "       }\n" +
         "     }]\n" +
         "   }\n" +
         " }")
 public Page<QuestionVo> queryAllByParams(
                     String title, String content,
                     Integer userId, Pageable pageable
                    );

  这样的查询必须进行测试,测试代码如下:

 @Resource
 QuestionRepository questionRepository;
 @Test
 public void query(){
     Page<QuestionVo> questions=questionRepository.queryAllByParams("java","java",11, PageRequest.of(0,8));
     for(QuestionVo vo:questions){
         System.out.println(vo);
    }
 }
  • 实现业务逻辑层代码:

  因为我们现在编写好的前端代码都是按照PageHelper插件提供的PageInfo类型设计的,如果本次查询返回的类型变为SpringData提供的Page类型,那么页面必须要进行修改。为了避免这些不必要的修改,我们编写一个Page类转PageInfo的转换类,在业务逻辑层实现转换,返回PageInfo类型,实现不修改页面就能直接显示查询结果的目的,需要添加支持PageHelper的依赖,在knows-search模块的pom.xml添加依赖:

 <dependency>
     <groupId>com.github.pagehelper</groupId>
     <artifactId>pagehelper</artifactId>
     <version>5.2.0</version>
 </dependency>

  从苍老师网站复制提供的Page转PageInfo的类 : Pages,代码如下:

 public class Pages {
     /**
      * 将Spring-Data提供的翻页数据,转换为Pagehelper翻页数据对象
      * @param page Spring-Data提供的翻页数据
      * @return PageInfo
      */
     public static <T> PageInfo<T> pageInfo(Page<T> page){
         //当前页号从1开始, Spring-Data从0开始,所以要加1
         int pageNum = page.getNumber()+1;
         //当前页面大小
         int pageSize = page.getSize();
         //总页数 pages
         int pages = page.getTotalPages();
         //当前页面中数据
         List<T> list = new ArrayList<>(page.toList());
         //当前页面实际数据大小,有可能能小于页面大小
         int size = page.getNumberOfElements();
         //当前页的第一行在数据库中的行号, 这里从0开始
         int startRow = page.getNumber()*pageSize;
         //当前页的最后一行在数据库中的行号, 这里从0开始
         int endRow = page.getNumber()*pageSize+size-1;
         //当前查询中的总行数
         long total = page.getTotalElements();
 
         PageInfo<T> pageInfo = new PageInfo<>(list);
         pageInfo.setPageNum(pageNum);
         pageInfo.setPageSize(pageSize);
         pageInfo.setPages(pages);
         pageInfo.setStartRow(startRow);
         pageInfo.setEndRow(endRow);
         pageInfo.setSize(size);
         pageInfo.setTotal(total);
         pageInfo.calcByNavigatePages(PageInfo.DEFAULT_NAVIGATE_PAGES);
 
         return pageInfo;
    }
 }
  • 编写业务逻辑层接口,IQuestionService代码:

 // 用户搜索问题的业务逻辑层方法
 PageInfo<QuestionVo> search(String key,String username,Integer pageNum,Integer pageSize);

QuestionServiceImpl实现方法:

 //根据用户名获得用户对象
 private User getUser(String username){
     String url="http://sys-service/v1/auth/user?username={1}";
     User user=restTemplate.getForObject(url,User.class,username);
     return user;
 }
 
 // 用户搜索问题的业务逻辑层方法实现
 @Override
 public PageInfo<QuestionVo> search(String key, String username, Integer pageNum, Integer pageSize) {
     User user=getUser(username);
     // 单独编写分页规则,设置按创建时间排序
     Pageable pageable= PageRequest.of(pageNum-1,pageSize,
             Sort.Direction.DESC,"createtime");
     //执行查询
     Page<QuestionVo> page=questionRepository.queryAllByParams(
             key,key,user.getId(),pageable);
     // 返回时将Page转换为PageInfo
     return Pages.pageInfo(page);
 }

这个也要测试一下:

 //测试搜索业务逻辑层
 @Test
 public void search(){
     PageInfo<QuestionVo> pageInfo=questionService.search(
             "java","st2",1,8);
     for(QuestionVo vo: pageInfo.getList()){
         System.out.println(vo);
    }
 }

  保证nacos\sys\es启动,如果上面数据访问层测试成功,业务逻辑层测试失败,有可能是ES不稳定造成的,可以重启或者重新安装Es解决。

编写控制层和各种配置:

  经过业务逻辑层测试,下面就开始编写控制层,在控制器中需要从SpringSecurity中获得用户详情,要添加依赖:

 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
 </dependency>
  • 控制层代码如下:

 @RestController
 @Slf4j
 @RequestMapping("/v3/questions")
 public class QuestionController {    
     @Resource    
     private IQuestionService questionService;    
     @PostMapping    
     public PageInfo<QuestionVo> search(String key,Integer pageNum,    
                                        @AuthenticationPrincipal UserDetails user){  
         if(pageNum==null)            
             pageNum=1;        
         Integer pageSize=8;        
         // 调用业务逻辑层查询方法        
         PageInfo<QuestionVo> pageInfo=questionService.search(key,user.getUsername(),pageNum,pageSize);        
         // 别忘了返回!!!        
         return pageInfo;    
    }
 }
  • 网关路由配置

转到gateway:在application.yml文件下添加路由:

 - id: gateway-search  
  uri: lb://search-service  
  predicates:    
  - Path=/v3/**
  • Spring-Security放行配置

  转回knows-search模块:复制sys或faq模块的SecurityConfig类到当前项目的security包即可

  • 复制拦截器

  复制faq模块的Interceptor包到当前项目即可

  • 跨域设置和拦截器注册

  复制faq模块的security包中的WebConfig类,修改一下原本的拦截器注册路径,最终路径如下:

 registry.addInterceptor(authInterceptor).addPathPatterns("/v3/questions");//搜索问题

1.6 前端调用查询

  前端页面调用查询的基本流程:我们本次只开发学生搜索功能,也就是index_student.html页面上的搜索功能。index_student.html页面是搜索的发起点,发起搜索后,我们设计跳转到search.html页面,然后search页面要接收学生在首页输入的关键字,最后在search.html页面加载完毕时运行的方法里调用控制器方法,执行查询。

转到client项目:

  先编写一个js文件,由index_student.html引用,代码中跳转到search页面并发送用户搜索的关键字,创建search_app.js:

 let searchApp=new Vue({
     el:"#searchApp",
     data:{
         key:""
    },
     methods:{
         search:function(){
             //用户点击搜索图标,跳转到search.html页面显示搜索结果
             // 并将用户输入的搜索关键字发送到search.html的url中
             //encodeURI是将用户输入的关键字转换成unicode编码,
             // 在接收方法解析回中文,防止传递中文时的乱码
             location.href="/search.html?"+encodeURI(this.key);
        }
    }
 })

index_student.html页面添加html绑定和引用:

 <div class="form-inline my-2 my-lg-0" id="searchApp">
   <input class="form-control form-control-sm mr-sm-2 rounded-pill"
          type="search" placeholder="Search" aria-label="Search"
           v-model="key">
     <!-- ↑↑↑↑↑↑↑↑↑↑↑↑↑ -->
   <button class="btn btn-sm btn-outline-secondary my-2 my-sm-0 rounded-pill"
           type="button"
           @click="search">
       <!-- ↑↑↑↑↑↑↑↑↑↑↑↑↑ -->
     <i class="fa fa-search" aria-hidden="true"></i></button>
 </div>

页面尾部引用js:

 <script src="js/search_app.js"></script>

  然后开始编写search.html页面,复制index_teacher.html命名为search.html,将"我的任务"修改为"搜索结果",再复制index_teacher.js文件命名为search.js,在search.html页面尾部引用修改为search.js:

 </body>
 <script src="js/utils.js"></script>
 <script src="js/tags_nav_temp.js"></script>
 <script src="js/user_info_temp.js"></script>
 <script src="js/tags_nav.js"></script>
 <script src="js/user_info.js"></script>
 <script src="js/search.js"></script>
  <!-- ↑↑↑↑↑↑↑↑↑↑↑↑↑ -->
 </html>

最后修改search.js文件,loadQuestion方法修改为:

 loadQuestions:function (pageNum) {
     // 判断pageNum是==null,可以理解为判断pageNum不存在
     if(! pageNum){
         pageNum = 1;
    }
     // 获得?之后用户输入的查询关键字
     // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     let key=location.search;
     if(!key){
         return;
    }
     // ?java
     // 01234
     key=decodeURI(key.substring(1));
     let form=new FormData();
     form.append("pageNum",pageNum);
     form.append("accessToken",token);
     form.append("key",key);
     // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
     axios({
         // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         url: 'http://localhost:9000/v3/questions',
         // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         method: "post",
         // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         data:form
    }).then(function(r){
         console.log("成功加载数据");
         console.log(r);
         if(r.status == OK){
             questionsApp.questions = r.data.list;
             questionsApp.pageinfo = r.data;
             //为question对象添加持续时间属性
             questionsApp.updateDuration();
             questionsApp.updateTagImage();
        }
    })
 },

  启动我们编写的各个服务Nacos\ES\Redis\gateway\sys\faq\auth\search\client项目,登录之后进行搜索,在search页面应该能够显示搜索结果,但是没有问题的标签和问题的配图。

  • 查询结果显示标签和图片

  因为我们查询的QuestionVo对象中没有包含tags的集合

转到knows-search模块:修改业务逻辑层代码QuestionServiceImpl类

 @Override
 public PageInfo<QuestionVo> search(String key, String username, Integer pageNum, Integer pageSize) {
     User user=getUser(username);
     // 单独编写分页规则,设置按创建时间排序
     Pageable pageable= PageRequest.of(pageNum-1,pageSize,
             Sort.Direction.DESC,"createtime");
     //执行查询
     Page<QuestionVo> page=questionRepository.queryAllByParams(
             key,key,user.getId(),pageable);
     // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     for(QuestionVo vo:page){
         vo.setTags(tagNames2Tags(vo.getTagNames()));
    }
     // 返回时将Page转换为PageInfo
     return Pages.pageInfo(page);
 }
 
 // 下面的方法是从faq的问题业务逻辑代码中复制过来的!
 private List<Tag> tagNames2Tags(String tagNames){
     String[] names=tagNames.split(",");
     // ↓↓↓↓↓↓↓↓↓↓↓↓
     String url="http://faq-service/v2/tags";
     Tag[] tagArr=restTemplate.getForObject(url,Tag[].class);
     Map<String,Tag> map=new HashMap<>();
     for(Tag t: tagArr){
         map.put(t.getName(),t);
    }
     // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
     List<Tag> tags=new ArrayList<>();
     for(String name:names){
         tags.add(map.get(name));
    }
     return tags;
 }

1.7 新增问题同步到ES

新增问题同步到ES的实现思路:

 

  1.学生填写表单->

  2.通过网关将请求发送到faq模块->

  3.faq模块业务逻辑层运行新增逻辑->

  4.通过ribbon调用search模块的新增方法->

  5.search模块新增方法运行完毕之后->

  6.faq模块给页面响应

  上图所示和文字的表单同样的含义,业务流程中出现了一个问题,就是从4步骤开始faq模块的线程就进入了阻塞,直到5步骤完成faq模块才能继续运行,严重的浪费了资源,我们要解决这个浪费资源的问题,解决方案如下:

  上图所示解决方案:faq模块执行完原本的新增操作后,去执行将问题信息发送给kafka(消息队列)的操作,然后不用等待search模块执行完毕,直接进行响应操作,这样就省去了等待过程,提高运行效率。

 

2 kafka和消息队列

  消息队列(Message Queue)简称MQ,作用是能够异步处理微服务间请求,优化Ribbon调用。这里的异步指A服务器向B服务器发送请求后,不等待B服务器给与响应,就继续运行后面代码的情况。

基本作用:

  • 利用异步提高当前服务器的运行效率,减少等待;

  • 削峰去谷:接收kafka信息的服务器能够高峰期间减少服务器压力,而空闲时间也有任务做;

  • (使用的影响):因为和原有的服务器异步执行,可能出现短暂延时才能生效,如果不能容忍这个延时,就不能使用消息队列了。

我们使用的常见的消息队列有:

  • ActiveMQ

  • RabbitMQ

  • RocketMQ

  • Kafka等

2.1 kafka软件结构

  Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。该项目的目标是为处理实时数据提供一个统一、高吞吐、低延迟的平台。Kafka最初是由LinkedIn开发,并随后于2011年初开源。

kafka结构图:

Producer(生产者): 消息的发送方,消息的来源

  达内知道业务中是faq模块

Consumer(消费者):消息的接收方,处理消息的模块

  达内知道业务中是search模块

Topic(主题\话题):消息的生产者和消费者需要指定同一个topic,才能顺利发送和获得正确的消息

Record(消息\记录):生产者和消费者传递的消息对象

  达内知道业务中是一个问题(Question)对象

2.2 下载Kafka

2.3 安装和配置Kafka

  建议在某个根目录下解压kafka,路径尽量短,不要中文空格特殊字符等

创建一个空文件夹data,用于kafka运行的数据文件和临时文件存放

运行前需要配置:E:\kafka\config目录下zookeeper.properties

 # dataDir=/tmp/zookeeper
 
 dataDir=E:/data

E:\kafka\config目录下server.properties

 # log.dirs=/tmp/kafka-logs
 
 log.dirs=E:/data

启动Zookeeper,Win+R 输入cmd

 C:\Users\TEDU>E:
 
 E:\>cd E:\kafka\bin\windows
 
 E:\kafka\bin\windows>zookeeper-server-start.bat ..\..\config\zookeeper.properties

启动Kafka,Win+R 输入cmd

 C:\Users\TEDU>e:
 
 E:\>cd E:\kafka\bin\windows
 
 E:\kafka\bin\windows>kafka-server-start.bat ..\..\config\server.properties

补充:

  要启动kafka,必须先启动zookeeper(动物管理员),早期大数据软件多数使用动物logo,每个大数据软件都有自己的配置信息,我们希望能够在一个地方配置所有软件的信息,这个地方就是zookeeper,kafka就是在这样的环境下诞生的,所以它天生运行需要zookeeper。

Mac系统启动Kafka服务命令(参考):

 # 进入Kafka文件夹
 cd Documents/kafka_2.13-2.4.1/bin/
 # 动Zookeeper服务
 ./zookeeper-server-start.sh -daemon ../config/zookeeper.properties
 # 启动Kafka服务
 ./kafka-server-start.sh -daemon ../config/server.properties

Mac系统关闭Kafka服务命令(参考):

 # 关闭Kafka服务
 ./kafka-server-stop.sh
 # 启动Zookeeper服务
 ./zookeeper-server-stop.sh

如果启动kafka时遇到

 wmic不是内部或外部命令

这样的提示,需要安装wmic命令,安装方式参考:https://zhidao.baidu.com/question/295061710.html

2.4 测试kafka

  为了测试kafka,我们创建一个项目knows-kafka:

 

父子相认

 <module>knows-kafka</module>

子项目依赖

 <?xml version="1.0" encoding="UTF-8"?>
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>cn.tedu</groupId>
         <artifactId>knows</artifactId>
         <version>0.0.1-SNAPSHOT</version>
         <relativePath/> <!-- lookup parent from repository -->
     </parent>
     <groupId>cn.tedu</groupId>
     <artifactId>knows-kafka</artifactId>
     <version>0.0.1-SNAPSHOT</version>
     <name>knows-kafka</name>
     <description>Demo project for Spring Boot</description>
 
     <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter</artifactId>
         </dependency>
         <!-- Google JSON API -->
         <dependency>
             <groupId>com.google.code.gson</groupId>
             <artifactId>gson</artifactId>
         </dependency>
         <!-- Kafka API -->
         <dependency>
             <groupId>org.springframework.kafka</groupId>
             <artifactId>spring-kafka</artifactId>
         </dependency>
     </dependencies>
 </project>

application.properties:

 # 配置kafka的位置
 spring.kafka.bootstrap-servers=localhost:9092
 # 确定当前项目在kafka中的分组名称
 # 这个分组名称在定义主题时会称为前缀,防止和其它项目的主题名重复
 # SpringKafka要求必须配置这个属性
 spring.kafka.consumer.group-id=knows
 
 # 日志门槛
 logging.level.cn.tedu.knows.kafka=debug

SpringBoot启动类:

 @SpringBootApplication
 // 启动kafka的支持
 @EnableKafka
 // 下面的依赖和kafka无关,是一个能够周期调用方法的支持
 @EnableScheduling
 public class KnowsKafkaApplication {
 
     public static void main(String[] args) {
         SpringApplication.run(KnowsKafkaApplication.class, args);
    }
 
 }

2.5 创建值对象

  我们要发送信息到Kafka,这个信息实际开发中是java对象的json格式字符串,创建一个Message类:

 @Data
 @Accessors(chain = true)
 public class Message implements Serializable {
     private Integer id;
     private String content;
     private Long time;
 }

2.6 编写生产者

  编写消息的生产者类(发送消息的代码),创建demo包,包中创建类Producer

 @Component
 @Slf4j
 public class Producer {
     @Resource
     //将操作Kafka的对象从Spring容器中获取
     // KafkaTemplate<[话题名称类型],[传递消息的类型]>
     private KafkaTemplate<String,String> kafkaTemplate;
 
     //发送消息的方法
     // 设计为从SpringBoot项目运行开始,每8秒运行一次这个方法
     // 这个功能可以使用Spring的定时计划完成
     @Scheduled(fixedRate = 8000)
     public void sendMessage(){
         // 实例化message对象
         Message message=new Message()
                .setId(1)
                .setContent("今天天气不错")
                .setTime(System.currentTimeMillis());
         // 将message对象转换为Json格式字符串
         Gson gson=new Gson();
         String json=gson.toJson(message);
         log.debug("即将发送的信息为:{}",json);
         // 发送消息到kafka
         kafkaTemplate.send("myTopic",json);
         log.debug("消息已发送");
    }
 
 }

  如果我们启动本项目的main方法,会发现已经实现每隔8秒发送一次信息。

2.7 编写消费者

  信息已经发送到kafka,下面要由消费者接收,也就是从kafka中获得消息:demo包中创建Consumer类,代码如下:

 @Component
 @Slf4j
 public class Consumer {
     //使用注解来设置Kafka监听器监听的话题名称
     @KafkaListener(topics = "myTopic")
     public void receive(ConsumerRecord<String,String> record){
         // 这个方法会在myTopic话题出现消息时自动运行
         // record就是本次发送到kafka的消息对象
         // ConsumerRecord<[话题名称类型],[传递消息的类型]>
 
         // 获得本次消息内容
         String json=record.value();
         Gson gson=new Gson();
         // 将获得的json格式字符串转换为Message对象
         Message message=gson.fromJson(json,Message.class);
         log.debug("接收到信息:{}",message);
    }
 }

  然后就可以运行测试kafka项目了,每隔8秒钟进行一个消息的发送和接收。

2.8 发布问题同步到ES

转到faq模块:经过上面的测试,我们知道需要发送和接收数据,完成同步过程。

  faq模块发送数据到Kafka,search模块从kafka中接收,我们最好能够约定一个话题名称,不同的模块使用相同的常量,需要将这个常量定义在commons模块中。

转到knows-commons模块:创建一个包vo,在包中创建一个类Topic,代码如下:

 public class Topic {
     public static final String QUESTIONS="question_es";
 }

转回knows-faq模块:添加Kafka依赖

 <!-- Google JSON API -->
 <dependency>
     <groupId>com.google.code.gson</groupId>
     <artifactId>gson</artifactId>
 </dependency>
 <!-- Kafka API -->
 <dependency>
     <groupId>org.springframework.kafka</groupId>
     <artifactId>spring-kafka</artifactId>
 </dependency>

application.properties添加配置:

 # 配置kafka的位置
 spring.kafka.bootstrap-servers=localhost:9092
 # 确定当前项目在kafka中的分组名称
 spring.kafka.consumer.group-id=knows

SpringBoot启动类:

 @SpringBootApplication
 @EnableDiscoveryClient
 @MapperScan("cn.tedu.knows.faq.mapper")
 // ↓↓↓↓↓↓↓↓↓↓
 @EnableKafka
 public class KnowsFaqApplication {
 
     public static void main(String[] args) {
         SpringApplication.run(KnowsFaqApplication.class, args);
    }
 
     //......
 }

  一切准备和配置完成后,编写一个能够发送Question消息到kafka的方法,以备在新增问题的业务逻辑层中调用。创建kafka包,包中创建QuestionProducer类,代码如下:

 @Component
 @Slf4j
 public class QuestionProducer {
     @Resource
     private KafkaTemplate<String,String> kafkaTemplate;
     
     // 将新增完成的问题对象发送到kafka的方法
     public void sendQuestion(Question question){
         Gson gson=new Gson();
         String json=gson.toJson(question);
         log.debug("发送问题信息:{}",json);
         kafkaTemplate.send(Topic.QUESTIONS,json);
    }
 }

  在QuestionServiceImpl业务逻辑层实现类中找到新增问题的方法saveQuestion,在这个方法最后添加代码,调用上面的方法将信息发送到kafka:

 @Resource
 private KafkaProducer kafkaProducer;
 
 //此处省略很多代码...
 
 @Override
 @Transactional
 public void saveQuestion(String username, QuestionVo questionVo) {
     //此处省略将问题保存到数据库的过程 ...
     
     //将刚刚新增完成的问题,发送到kafka
     //以便search模块接收并新增到Es
     questionProducer.sendQuestion(question);
 }

转到search模块:上面faq模块已经能够在用户新增问题时,将用户新增的问题信息发送到Kafka了。

下面我们要在search模块中编写代码,从Kafka中获得这个问题信息,后新增到Es中。

转到search模块:添加依赖

 <!-- Google JSON API -->
 <dependency>
     <groupId>com.google.code.gson</groupId>
     <artifactId>gson</artifactId>
 </dependency>
 <!-- Kafka API -->
 <dependency>
     <groupId>org.springframework.kafka</groupId>
     <artifactId>spring-kafka</artifactId>
 </dependency>

application.properties:

 # 配置kafka的位置
 spring.kafka.bootstrap-servers=localhost:9092
 # 确定当前项目在kafka中的分组名称
 spring.kafka.consumer.group-id=knows

SpringBoot启动类:

 @SpringBootApplication
 @EnableDiscoveryClient
 // ↓↓↓↓↓↓↓↓↓
 @EnableKafka
 public class KnowsSearchApplication {
 
     public static void main(String[] args) {
         SpringApplication.run(KnowsSearchApplication.class, args);
    }
 
     //....
 }

配置完毕

标准开发流程要求我们开发一个业务逻辑层方法,来实现新增问题到ES的方法:

IQuestionService接口中添加一个方法:

 // 新增问题到Es的方法
 void saveQuestion(QuestionVo questionVo);

QuestionServiceImpl实现代码:

 @Override
 public void saveQuestion(QuestionVo questionVo) {
     questionRepository.save(questionVo);
 }

在controller包中创建QuestionConsumer类,代码如下:

 @Component
 @Slf4j
 public class QuestionConsumer {
     @Resource
     private IQuestionService questionService;
     
     // 定义监听器
     @KafkaListener(topics = Topic.QUESTIONS)
     public void questionReceive(ConsumerRecord<String,String> record){
         // 知道kafka中指定话题有消息,这个方法就会自动获取消息并运行
         String json= record.value();
         Gson gson=new Gson();
         QuestionVo questionVo=gson.fromJson(json,QuestionVo.class);
         // 执行新增
         questionService.saveQuestion(questionVo);
         log.debug("新增成功了:{}",questionVo);
    }
 }

启动所有服务,测试新增问题效果。

2.9 Kafka 概念总结

  • Kafka 是消息队列服务器,其核心目的实现业务系统之间进行异步消息通讯;

  • 利用Kafka可以将业务层过程中的分支流程优化为异步处理,提升软件的性能;

  • Kafka数据发送端称为:消息生产者、或者消息发布者;

  • Kafka数据接收端称为:消息消费者、或者消息订阅者;

  • 队列服务器可以解决数据的“生产者和消费者”问题;

  • 队列服务器也有人称为发布于订阅模型;

  • “生产者和消费者”问题:是指数据生产和数据消费速度不一致会造成数据堆积的矛盾,这种矛盾的解决常用方式就是采用队列进行缓存,然后再进行异步处理。也称为“削峰去谷”,也就是把数据高峰缓存到队列中,在一步一步处理平滑处理。

 

3 AOP面向切面编程

3.1 什么是Aop?

  面向切面的程序设计(Aspect-oriented programming,AOP,又译作剖面导向程序设计)是计算机科学中的一种程序设计思想,目标是将横切关注点与业务主体进行进一步分离,以提高程序代码的模块化程度。通过在现有代码基础上增加额外的通知(Advice)机制,能够对被声明为“切点(Pointcut)”的代码块进行统一管理与扩展。

  程序中的"切面"指的实际上就是方法之间的调用。

名词解释:

  • 切面(aspect) : 是一个可以定义的方法互相调用时的特定位置,可以添加各类通知,运行新的代码进行维护和管理。

  • 织入(weaving) : 选定一个切面,我们可以利用动态代理技术,为原有方法的持有者生成动态对象,然后将它和切点关联,在运行原有方法时,就会按照织入后的运行流程运行。

  • 通知(advice) : 通知就是确定织入内容运行的时机

    • 前置通知( before advice)

    • 后置通知(after advice)

    • 环绕通知(around advice)

    • 异常通知(after Throwing advice)

3.2 编写第一个Aop程序

  使用Aop的原因是我们要在不修改现有代码的前提下,经过若干配置,实现或完善原有方法不具备的功能,从而达到维护或扩展代码的目的。我们之前学习使用的过滤器、拦截器、Spring-Security(底层过滤器)和SpringMvc的很多功能都是在Aop的思想下产生的产物,使用Spring框架是实现Aop思想的方法之一。

Spring框架两大功能:

  1. Ioc\DI

  1. Aop

下面我们就使用Spring框架提供的方式实现Aop,首先添加一些支持Aop注解的依赖。

转到Faq模块:添加依赖如下

 <dependency>
     <groupId>org.springframework</groupId>
     <artifactId>spring-aspects</artifactId>
 </dependency>

新建一个aspect包,包中新建一个类DemoAspect,代码如下:

 // 当前配置是配置给Spring容器生效的,所以必须添加@Component,保存到Spring容器
 @Component
 // 当前类不是一般的java对象类型,而是编写Aop代码的,所以添加这个注解
 // Spring容器在解析带有这个注解的类时,会有特殊操作,例如记录织入的信息,变化运行流程
 @Aspect
 public class DemoAspect {
     // 我们实现Aop必须要确定插入通知代码的方法
     // 下面的注解定义切面
     @Pointcut("execution(public * cn.tedu.knows.faq." +
             "controller.TagController.tags(..))")
     // 下面这个方法不写代码, 这个方法单纯的就是一个切面的定义
     // 方法名称就是就是这个切面的名称
     public void pointCut(){}
 
     // 对切面方法织入通知
     // 织入前置通知,意思是在切面方法运行之前会自动运行的代码
     @Before("pointCut()")
     public void before(){
         //方法中的代码会在运行tags方法前执行
         // 此方法的名称随意
         System.out.println("前置通知执行");
    }
 }

示意图:

3.3 常用通知类型

  除了上面示例中的前置通知,实际上还有其它的通知方式,下面代码中包含了其它常用通知类型:

 // 当前配置是配置给Spring容器生效的,所以必修添加@Component,保存到Spring容器
 @Component
 // 当前类不是一般的java对象类型,而是编写Aop代码的,所以添加这个注解
 // Spring容器在解析带有这个注解的类时,
 //             会有特殊操作,例如记录织入的信息,变化运行流程
 @Aspect
 public class DemoAspect {
 
     // 我们实习Aop必须要确定插入通知代码的方法
     // 下面的注解定义切面
     @Pointcut("execution(public * cn.tedu.knows.faq." +
             "controller.TagController.tags(..))")
     // 下面这个方法不写代码, 这个方法单纯的就是一个切面的定义
     // 方法名称就是就是这个切面的名称
     public void pointCut(){}
 
     // 对切面方法织入通知
     // 织入前置通知,意思是在切面方法运行之前会自动运行的代码
     @Before("pointCut()")
     public void before(JoinPoint joinPoint){
         //方法中的代码会在运行tags方法前执行
         // 此方法的名称随意
         System.out.println("前置通知执行");
         //利用joinPoint对象获得当前切面方法的信息
         System.out.println(joinPoint.getSignature().getName());
    }
 
     //后置通知
     @After("pointCut()")
     public void after(){
         System.out.println("后置通知执行");
    }
     // 异常通知
     @AfterThrowing("pointCut()")
     public void throwing(){
         System.out.println("方法发生异常");
    }
 
     // 环绕通知
     @Around("pointCut()")
     // 环绕通知方法必须有参数,否则无法运行目标方法
     // 方法的返回值也要确定,否则原本能接收到返回值的对象(例如页面),就无法接收了
     public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
         System.out.println("环绕通知,方法运行前");
         //执行方法
         Object returning =joinPoint.proceed();
         System.out.println("环绕通知,方法运行后");
         return returning;
    }
 
 }

3.4 切面定义

  上面示例中我们使用

 @Pointcut("execution(public * cn.tedu.knows.faq." +
             "controller.TagController.tags(..))")

  定义了切面,下面我们学习一下切入点定义的格式:

 execution(
 modifier-pattern?
 ret-type-pattern
 declaring-type-pattern?
 name-pattern(param-pattern)
 throws-pattern?)
  • modifier-pattern访问修饰符

  • ret-type-pattern返回值类型

  • declaring-type-pattern全路径类名

  • name-pattern方法名

  • param-pattern参数列表

  • throws-pattern抛出的异常类型

  其中带?的都是可选项,下面分析几个切入点表达式的含义:

 execution(* *(..)): 通配当前Spring容器扫描到的所有方法
 execution(public * com.test.TestController.*(..)):通配com.test.TestController类中的所有public修饰的方法
 execution(* cn.tedu..*.*(..)):匹配cn.tedu包下所有类(包含子孙包中的类)的所有方法

3.5 Aop实现性能记录

转到faq模块:模块中有很多方法,每个方法需要多少时间才能运行完毕,是性能测试的重要指标。

  我们可以使用Spring提供的环绕增强,在方法运行前后计时,然后相减,就会得到时间差,输出它就可以知道每个方法运行的时长了,还是在aspect包,编写TimeAspect类代码如下:

 @Component
 @Aspect
 public class TimeAspect {
 
     @Pointcut("execution(public * cn.tedu.knows.faq.service.*.*(..))")
     public void timer(){}
 
     @Around("timer()")
     public Object timeRecord(ProceedingJoinPoint joinPoint) throws Throwable {
         long start=System.currentTimeMillis();//开始时间
         Object returning=joinPoint.proceed();//调用方法
         long end=System.currentTimeMillis();//结束时间
         long time=end-start;//计算时间差
         //获得方法名
         String methodName=joinPoint.getSignature().getName();
         System.out.println(methodName+"方法运行用时"+time+"ms");
         return returning;//别忘了返回
    }
 
 }

重启faq,运行faq模块的功能,观察控制台输出的时间。

 

4 其它热点知识简介

4.1 断路器\熔断器\hystrix

  SpringCloud中的一个组件

作用: 像电闸一样,在负载超出设计范围时,直接断电,防止更严重后果的发生。

  SpringCloud的hystrix就是在微服务的某个节点发生崩溃时,阻止其它请求继续访问该服务的措施,这么做可以最大化的减少单个节点崩溃带来的其它后果,实现"快速失败"。

4.2 服务降级

  如果一个服务器过于繁忙,可以将服务降级为更简单的运行模式。一个业务有两套解决方案,一套标准的,一套节能的,在服务器压力不同时,可以根据既定的条件进行切换,Alibaba Sentinel就是一个可以实现降级的框架 。

4.3 网站限流原理

  现在比较常见的限流算法叫令牌桶

 

posted @ 2021-09-17 17:50  Coder_Cui  阅读(265)  评论(0编辑  收藏  举报