开放平台 logo

Author: Jackiechan

Version: 9.0.2

开放平台项目笔记

本文件主要是记录一些项目配置文件,工具类等不容易记住或者重复性比较多的内容

写在前面

这里主要是写一些我们的约定

约定名 约定值
工程groupid com.qianfeng
工程artifactId openplatform
module模块名 openapi-xxx xxx代表模块名
包名 com.qianfeng.openplatform.模块名.具体细分

一、 工程pom文件

为了授课编写代码的便捷性,我们的项目采用一个Project,多个module的方式,所以我们会在项目的最外层添加我们的Springboot

和Spring Cloud配置

  1. <!--
  2. SpringBoot 父依赖
  3. -->
  4. <parent>
  5. <groupId>org.springframework.boot</groupId>
  6. <artifactId>spring-boot-starter-parent</artifactId>
  7. <version>2.1.9.RELEASE</version>
  8. </parent>
  9. <!--
  10. SpringCloud工具集
  11. -->
  12. <dependencyManagement>
  13. <dependencies>
  14. <dependency>
  15. <groupId>org.springframework.cloud</groupId>
  16. <artifactId>spring-cloud-dependencies</artifactId>
  17. <version>Greenwich.SR5</version>
  18. <scope>import</scope>
  19. <type>pom</type>
  20. </dependency>
  21. </dependencies>
  22. </dependencyManagement>

二 、日志文件(logback-spring.xml)

我们的日志使用的是Springboot自带的logback,下面为logback的配置文件,我们按照模块区分日志,主要就是保存位置的区别

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <configuration scan="true" scanPeriod="6000000" debug="false">
  3. <property name="LOG_PATTERN" value="%date{HH:mm:ss.SSS} %-5p [%t:%c{1}:%L] - %msg%n"/>
  4. <!--
  5. 将日志文件的保存位置修改为自定义的路径,当前的配置是在项目的目录中新建一个logs目录,在logs中创建具体的模块的日志目录.
  6. -->
  7. <property name="LOG_PATH" value="./logs/config/"/>
  8. <!-- 系统级配置文件 开始 -->
  9. <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
  10. <layout class="ch.qos.logback.classic.PatternLayout">
  11. <Pattern>${LOG_PATTERN}</Pattern>
  12. </layout>
  13. </appender>
  14. <!-- stdout -->
  15. <appender name="rootstdout"
  16. class="ch.qos.logback.core.rolling.RollingFileAppender">
  17. <File>${LOG_PATH}rootstdout.log</File>
  18. <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
  19. <FileNamePattern>${LOG_PATH}rootstdout.%i.log.zip</FileNamePattern>
  20. <MinIndex>1</MinIndex>
  21. <MaxIndex>20</MaxIndex>
  22. </rollingPolicy>
  23. <triggeringPolicy
  24. class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
  25. <MaxFileSize>10MB</MaxFileSize>
  26. </triggeringPolicy>
  27. <layout class="ch.qos.logback.classic.PatternLayout">
  28. <Pattern>${LOG_PATTERN}</Pattern>
  29. </layout>
  30. </appender>
  31. <!-- debug -->
  32. <appender name="rootDebug" class="ch.qos.logback.core.rolling.RollingFileAppender">
  33. <File>${LOG_PATH}root-debug.log</File>
  34. <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
  35. <FileNamePattern>${LOG_PATH}root-debug.%i.log.zip</FileNamePattern>
  36. <MinIndex>1</MinIndex>
  37. <MaxIndex>10</MaxIndex>
  38. </rollingPolicy>
  39. <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
  40. <MaxFileSize>10MB</MaxFileSize>
  41. </triggeringPolicy>
  42. <layout class="ch.qos.logback.classic.PatternLayout">
  43. <Pattern>${LOG_PATTERN}</Pattern>
  44. </layout>
  45. <filter class="ch.qos.logback.classic.filter.LevelFilter">
  46. <level>DEBUG</level>
  47. <onMatch>ACCEPT</onMatch>
  48. <onMismatch>DENY</onMismatch>
  49. </filter>
  50. </appender>
  51. <!-- info -->
  52. <appender name="rootInfo" class="ch.qos.logback.core.rolling.RollingFileAppender">
  53. <File>${LOG_PATH}root-info.log</File>
  54. <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
  55. <FileNamePattern>${LOG_PATH}root-info.%i.log.zip</FileNamePattern>
  56. <MinIndex>1</MinIndex>
  57. <MaxIndex>10</MaxIndex>
  58. </rollingPolicy>
  59. <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
  60. <MaxFileSize>10MB</MaxFileSize>
  61. </triggeringPolicy>
  62. <layout class="ch.qos.logback.classic.PatternLayout">
  63. <Pattern>${LOG_PATTERN}</Pattern>
  64. </layout>
  65. <filter class="ch.qos.logback.classic.filter.LevelFilter">
  66. <level>INFO</level>
  67. <onMatch>ACCEPT</onMatch>
  68. <onMismatch>DENY</onMismatch>
  69. </filter>
  70. </appender>
  71. <!-- warn -->
  72. <appender name="rootWarn" class="ch.qos.logback.core.rolling.RollingFileAppender">
  73. <File>${LOG_PATH}root-warn.log</File>
  74. <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
  75. <FileNamePattern>${LOG_PATH}root-warn.%i.log.zip</FileNamePattern>
  76. <MinIndex>1</MinIndex>
  77. <MaxIndex>10</MaxIndex>
  78. </rollingPolicy>
  79. <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
  80. <MaxFileSize>10MB</MaxFileSize>
  81. </triggeringPolicy>
  82. <layout class="ch.qos.logback.classic.PatternLayout">
  83. <Pattern>${LOG_PATTERN}</Pattern>
  84. </layout>
  85. <filter class="ch.qos.logback.classic.filter.LevelFilter">
  86. <level>WARN</level>
  87. <onMatch>ACCEPT</onMatch>
  88. <onMismatch>DENY</onMismatch>
  89. </filter>
  90. </appender>
  91. <!-- error -->
  92. <appender name="rootError" class="ch.qos.logback.core.rolling.RollingFileAppender">
  93. <File>${LOG_PATH}root-error.log</File>
  94. <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  95. <FileNamePattern>${LOG_PATH}root-error.%d{yyyy-MM-dd}.log</FileNamePattern>
  96. <MaxHistory>30</MaxHistory>
  97. </rollingPolicy>
  98. <layout class="ch.qos.logback.classic.PatternLayout">
  99. <Pattern>${LOG_PATTERN}</Pattern>
  100. </layout>
  101. <filter class="ch.qos.logback.classic.filter.LevelFilter">
  102. <level>Error</level>
  103. <onMatch>ACCEPT</onMatch>
  104. <onMismatch>DENY</onMismatch>
  105. </filter>
  106. </appender>
  107. <springProfile name="local">
  108. <root level="info">
  109. <!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
  110. <appender-ref ref="STDOUT"/>
  111. <appender-ref ref="rootstdout"/>
  112. <appender-ref ref="rootDebug"/>
  113. <appender-ref ref="rootInfo"/>
  114. <appender-ref ref="rootWarn"/>
  115. <appender-ref ref="rootError"/>
  116. </root>
  117. </springProfile>
  118. <springProfile name="dev">
  119. <root level="info">
  120. <!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
  121. <appender-ref ref="rootstdout"/>
  122. <appender-ref ref="rootDebug"/>
  123. <appender-ref ref="rootInfo"/>
  124. <appender-ref ref="rootWarn"/>
  125. <appender-ref ref="rootError"/>
  126. </root>
  127. </springProfile>
  128. <springProfile name="prod">
  129. <root level="info">
  130. <!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
  131. <appender-ref ref="rootstdout"/>
  132. <appender-ref ref="rootDebug"/>
  133. <appender-ref ref="rootInfo"/>
  134. <appender-ref ref="rootWarn"/>
  135. <appender-ref ref="rootError"/>
  136. </root>
  137. </springProfile>
  138. <include resource="org/springframework/boot/logging/logback/base.xml"/>
  139. <jmxConfigurator/>
  140. </configuration>

三、 多配置文件

实际开发中,为了方便测试,我们会有多个不同的配置文件,比如本地的,测试服务器的等,为了减少修改,我们一般会将配置文件按照具体场景单独写,在运行的时候通过指定加载配置文件的方式来执行

比如我们假设我们的项目有 local和prod两个不同的配置,则我们会创建application.yml主文件, application-local.yml和application-prod.yml 三个文件,其中local和prod代表的就是我们的前面的配置

我们有两种方式可以选择,我们在项目中可能会采用两种方式混用的情况

3.1 方式1

此方式是通过在主文件中指定加载文件后缀的方式来进行加载对应的配置文件

3.1.1 application.yml主文件
server:
  port: 12000
spring:
  profiles:
    active: local #通过这个属性指定文件的后缀,则程序会自动加载application-local.yml文件,如果需要prod只需要修改为prod然后启动即可

3.1.2 local文件

此文件仅仅为演示

spring:
  application:
    name: test-profile
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true #显示 ip

3.1.3 prod文件

此文件仅仅为演示

spring:
  application:
    name: test-profile
eureka:
  client:
    service-url:
      defaultZone: http://test.qfjava.cn:20000/eureka
  instance:
    prefer-ip-address: true #显示 ip

3.2 方式2

此方式是通过在对应的项目的pom文件中指定属性名,然后在application.yml中通过变量名引入,在启动时候通过maven属性指定的方式来选择,假设我们的配置文件仍然是local和prod

3.2.1 pom文件
<!--
指定属性,可以让我们的application.yml引入
-->
    <profiles>

        <profile>
            <!--
                当前属性的id,唯一
            -->
            <id>local</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
            <!--
                当前id对应的具体的参数的值,当我们通过选中上面id对应的值时候,就会使用这个值,这两个值可以一样,可以不一样
当使用这个值的时候会加载application-local.yml文件
            -->
            <properties>
                <profileActive>local</profileActive>
            </properties>
        </profile>

        <profile>
            <id>prod</id>
            <activation>
                <activeByDefault>false</activeByDefault>
            </activation>
            <properties>
                <profileActive>prod</profileActive>
            </properties>
        </profile>


    </profiles>

3.2.2 application.yml 主文件
server:
  port: 12000
spring:
  profiles:
    active: '@profileActive@' #设置我们的加载的maven配置文件的后缀为这个属性的值,通过指定maven的启动参数来选中

3.2.3 启动

在启动程序前,在maven的选项中选择要使用的属性的id,然后启动程序即可

选择使用的配置文件
图片.png

四、 注册中心(openapi-eureka)

我们的项目使用的是eureka作为注册中心,其使用相对简单,配置方便

4.1 pom中的依赖

下面的内容主要是依赖相关的内容

<!--
eureka server 的依赖,注意是server
-->

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>

<!--
和安全相关的依赖包,我们访问 eureka的需要密码,此处为了操作方便,不使用密码
-->

<!--        <dependency>-->
<!--            <groupId>org.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-security</artifactId>-->
<!--        </dependency>-->
    </dependencies>

4.2 application.yml

程序启动的主要配置

server:
  port: 20000 #程序运行的端口,可以自定义修改
spring:
  application:
    name: openapi-eureka #程序的名字
    #配置 eureka 页面的登陆的账号和密码,需要配合security依赖使用,为了操作方便,就不配置了
#  security:
#    user:
#      name: admin
#      password: admin
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka #我们的注册中心的地址
    #单机版的配置,集群只需要启动多个eureka并相互作为客户端配置其他eureka地址即可,为了方便演示,我们使用单机版
    fetch-registry: false
    register-with-eureka: false

4.3 安全配置类(可选)

本配置文件取决于是否启用了security密码操作,因为开启了csrf验证,我们使用了端口,所以我们的eureka客户端会无法注册到当前注册中心,本配置是为了让客户端可以注册到eureka,如果没有使用security 可以忽略

@EnableWebSecurity
public class EurekaConfig  extends WebSecurityConfigurerAdapter
{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()//禁用掉 csrf 跨域攻击,以免我们的服务无法注册到 eureka
        .authorizeRequests()//需要认证所有的请求
        .mvcMatchers("/eureka/**").permitAll()//符合以上路径规则的放行
        .mvcMatchers("/actuator/**").permitAll()//放行
        .anyRequest().authenticated().and().httpBasic();//剩余的所有的请求都需要验证
    }
}

4.4 SpringBoot主程序

@SpringBootApplication
//启用eureka 注册中心的注解,必须添加
@EnableEurekaServer
public class EurekaStartApp {
    public static void main (String[] args){
        SpringApplication.run(EurekaStartApp.class,args);
    }
}

五、 统一配置中心(openapi-configserver)

在我们的项目中,我们的一些配置并不是一成不变的,我们可能会经常发生变化, 比如我的redis服务器地址,我们的mq服务器地址等,当我们发生变化的时候,我们需要将所有引入这些内容的服务器都重新替换配置文件并部署,但是我们的服务器是集群,可能会有数量不明确的机器在使用,这样的情况下,维护就变得非常的繁琐,我们通过一个统一的配置中心,让我们所有需要的服务器都从配置中间下载配置文件,这样我们只需要将配置中心对应的配置文件修改即可,这样我们就需要搭建一个统一的配置中心,

我们使用的是SpringCloud ConfigServer来搭建,并将文件保存到git中,通过使用mq来进行批量更新

5.1 pom中的依赖

<dependencies>
    <!--
        注意 config 的server 没有 starter,个人猜测应该是个 bug
        -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-config-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

</dependencies>

5.2 application.yml主配置文件

server:
  port: 12000
spring:
  application:
    name: openapi-configserver #我们的程序在eureka中的名字
  #配置我们保存配置文件的位置
  cloud:
    config:
      server:
        git:
          uri: https://gitee.com/yangl729953102/{application} #我们的配置文件保存的位置,在码云上,{application}是个通配符,代表的是从当前配置中心找配置文件的应用程序的名字
       #   username:
       #   password:
eureka:
  client:
    service-url:
      #这是如果注册中心是带密码的
     # defaultZone: http://admin:admin@localhost:20000/eureka
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true #显示 ip

5.4 SpringBoot主程序

@SpringBootApplication
//开启配置中心服务端
@EnableConfigServer
//开启服务注册,会向eureka中注册当前服务,可以忽略编写,在使用eureka的情况下效果等于@EnableEurekaClient
@EnableDiscoveryClient
public class ConfigServerStartApp {

    public static void main (String[] args){
        SpringApplication.run(ConfigServerStartApp.class,args);
    }
}

六、 缓存服务(openapi-cache)

我们的缓存模块,当前使用的是 redis,缓存模块我们设计为是一个独立的 web 项目

原因是因为如果我们设置为依赖包,如果缓存模块发生变化,需要重新打包发布然后让所有依赖

缓存的功能都需要重新更新依赖以及打包上线,这样就不符合我们的项目的拆分的目的,所以

我们的缓存设计为 web 程序,这样我们的缓存发生变化的时候只要返回结果不变,内部如何变化只需要重启缓存功能就可以

6.1 pom中的依赖

  <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <!--
        操作 redis 的依赖包
        -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!--
        统一配置中心的客户端,注意没有 client
        -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        <!--

        用于做降级的依赖包,当我们的程序内部发生问题的时候快速返回降级数据给调用者,防止我们的程序因等待导致出现问题,最终导致调用者出现级联失败
        -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>
    </dependencies>

<!--
bootstrap.yml 如果无法使用 '@profileActive@'来获取我们的值,添加以下配置来让它可以访问

-->
    <build>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
            </resource>
        </resources>
    </build>

    <profiles>
        <profile>
            <id>local</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
            <properties>
                <profileActive>local</profileActive>
            </properties>
        </profile>

        <profile>
            <id>prod</id>
            <activation>
                <activeByDefault>false</activeByDefault>
            </activation>
            <properties>
                <profileActive>prod</profileActive>
            </properties>
        </profile>
    </profiles>

6.2 bootstrap.yml

因为我们需要从config-server中获取配置,所以我们的一些配置需要放到bootstrap.yml中, application.yml放端口等其他配置,redis的配置可以在git仓库中配置

#因为我们的 redis 的服务器放在 git中,但是我们程序启动起来初始化对象的时候必须有服务器地址了
#当前配置文件的优先级是最高的,在 spring 初始化对象之前就会先加载这个配置文件,所以我们把从git 上面加载配置文件的过程写入到这里面
#来保证我们的程序在初始化对象之前就把服务器信息加载回来了,这样才能保证后续的初始化不会出现错误
spring:
  application:
    name: openapi-cache
# redis:
# host: 127.0.0.1
# port: 6379
  #需要告诉我们的程序,config-server 在 eureka 中叫什么,以及告诉 configserver 我要加载个配置文件
  cloud:
    config:
      discovery:
        enabled: true #开始通过服务发现来找 config server
        service-id: OPENAPI-CONFIGSERVER #设置 config server 的服务的名字
      label: master #设置我们的配置文件在 git 中属于什么分支,属于 master 分支
      profile: '@profileActive@' #因为我们一个仓库里面可以写好多个不同的配置文件,那么这个属性告诉 config server 我们要加载哪个后缀的配置文件

eureka:
  client:
    service-url:
      #这是如果注册中心是带密码的
      # defaultZone: http://admin:admin@localhost:20000/eureka
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true #显示 ip

6.3 application.yml

server:
  port: 21000

编写启动类

@SpringBootApplication
@EnableDiscoveryClient
@EnableCircuitBreaker//熔断降级
public class OpenapiCacheApp {
    public static void main(String[] args) {
        SpringApplication.run(OpenapiCacheApp.class,args);
    }
}

6.4 Redis配置类

设置RedisTemplate的key和value的序列化方式

package com.qf.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    @Override
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> {
            StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append(target.getClass().getName());
            stringBuilder.append(method.getName());

            for (Object param : params) {
                stringBuilder.append(params.toString());
            }

            return stringBuilder.toString();
        };
    }

    @Bean
    public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) {

        RedisCacheWriter writer = RedisCacheWriter.lockingRedisCacheWriter(connectionFactory);
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        RedisCacheManager cacheManager = new RedisCacheManager(writer, config);
        return cacheManager;
    }

    /**
     * springboot 默认帮我们创建的RedisTemplate的key和value的序列化方式是jdk默认的方式,所以呢我们有时候手动向redis中添加的数据可能无法被查询解析出来,所以我们需要修改序列化方式
     *
     * @param connectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer); //设置key的序列化方式
        redisTemplate.setHashKeySerializer(stringRedisSerializer);//设置hash类型的数据的key的序列化方式


        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);//非final类型的数据才会被序列化
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);//设置value的序列化方式为json
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        return redisTemplate;
    }

}

6.5 核心操作

编写redis中常用命令对应的功能代码

package com.qf.service;

import java.util.Map;
import java.util.Set;

/**
 * 我们的缓存的业务定义,用于声明我们当前缓存对外提供的功能
 */
public interface CacheService {

    /**
     * 向redis中保存字符串类型的数据
     *
     * @param key
     * @param value
     * @param expireTime 有效期, 如果是-1代表永久, 其他负数按照我们自己定义的要求,取绝对值,0代表-1,时间单位是毫秒值
     * @return 成功返回true
     * @throws Exception
     */
    boolean save2Redis(String key, String value, long expireTime) throws Exception;

    /**
     * 从redis中查询String类型是数据
     *
     * @param key
     * @return
     * @throws Exception
     */
    String getFromRedis(String key) throws Exception;

    /**
     * 从redis中删除指定的key
     *
     * @param key
     * @return
     * @throws Exception
     */
    boolean deleteKey(String key) throws Exception;

    /**
     * 设置过期时间
     *
     * @param key
     * @param expireTime 有效期, 如果是-1代表永久, 其他负数按照我们自己定义的要求,取绝对值,0代表-1
     * @return
     * @throws Exception
     */
    boolean expire(String key, long expireTime) throws Exception;

    /**
     * 获取一个自增的数字
     *
     * @param key
     * @return
     * @throws Exception
     */
    Long getAutoIncrementId(String key) throws Exception;

    /**
     * 获取指定key的set类型的集合数据
     *
     * @param key
     * @return
     * @throws Exception
     */
    Set<Object> sMembers(String key) throws Exception;

    /**
     * 向redis中指定key的set中添加数据
     *
     * @param key
     * @param member
     * @return
     * @throws Exception
     */
    Long sAdd(String key, String member) throws Exception;


    /**
     * 向redis中指定key的set中添加数据集合
     *
     * @param key
     * @param members
     * @return
     * @throws Exception
     */
    Long sAdd(String key, String... members) throws Exception;

    /**
     * 从redis中移除指定key对应的set中的某个值
     *
     * @param key
     * @param member
     * @return
     * @throws Exception
     */

    Long sRemove(String key, String member) throws Exception;

    /**
     * 向redis中的hash类型数据中添加指定的内容
     *
     * @param key
     * @param field
     * @param value
     * @return
     * @throws Exception
     */
    boolean hSet(String key, String field, String value) throws Exception;

    /**
     * 从hash中获取指定属性的值
     *
     * @param key
     * @param field
     * @return
     * @throws Exception
     */
    String hGet(String key, String field) throws Exception;

    /**
     * 获取指定hash中的所有的数据
     *
     * @param key
     * @return
     * @throws Exception
     */
    Map<Object, Object> hGetAll(String key) throws Exception;

    /**
     * 批量向hash中添加数据
     *
     * @param key
     * @param values
     * @return
     * @throws Exception
     */
    boolean hMSet(String key, Map<Object, Object> values) throws Exception;

    /**
     * 查看符合表达式的key的集合
     *
     * @param partten
     * @return
     * @throws Exception
     */
    Set<String> findKeyByPartten(String partten) throws Exception;

    /**
     * 自增指定步长的数据并返回
     *
     * @param key
     * @param delta
     * @return
     * @throws Exception
     */
    Long getAutoIncrementId(String key, long delta) throws Exception;


    /**
     * 从hash中的指定属性上面获取一个自增数据
     *
     * @param key
     * @param field
     * @param delta
     * @return
     * @throws Exception
     */
    Long hIncrementId(String key, String field, long delta) throws Exception;

    /**
     * setnx的方式存放值
     *
     * @param key
     * @param value
     * @param expireTime
     * @return
     * @throws Exception
     */
    boolean setNx(String key, String value, long expireTime) throws Exception;

}

编写Service对应的实现类

package com.qf.service.impl;

import com.qf.service.CacheService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;


@Service
public class CacheServiceImpl implements CacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;


    @Override
    public boolean save2Redis(String key, String value, long expireTime) throws Exception {
        if (expireTime == 0) {//按照我们自己的要求,如果是0 代表是-1
            expireTime = -1;
        } else if (expireTime < -1) { //如果小于-1 代表的是取绝对值
            expireTime = Math.abs(expireTime);
        }
        redisTemplate.opsForValue().set(key, value);
        if (expireTime > 0) {
            //需要设置有效期,时间单位是毫秒值
            redisTemplate.expire(key, expireTime, TimeUnit.MILLISECONDS);
        }

        return true;
    }

    @Override
    public String getFromRedis(String key) throws Exception {

        return (String) redisTemplate.opsForValue().get(key);
    }

    @Override
    public boolean deleteKey(String key) throws Exception {
        return redisTemplate.delete(key);
    }

    @Override
    public boolean expire(String key, long expireTime) throws Exception {
        if (expireTime == 0) {//按照我们自己的要求,如果是0 代表是-1
            expireTime = -1;
        } else if (expireTime < -1) { //如果小于-1 代表的是取绝对值
            expireTime = Math.abs(expireTime);
        }
        if (expireTime > 0) {
            //代表需要设置有效期
            return redisTemplate.expire(key, expireTime, TimeUnit.MILLISECONDS);
        } else {
            //代表需要的是持久化数据
            return redisTemplate.persist(key);
        }

    }

    @Override
    public Long getAutoIncrementId(String key) throws Exception {
        return redisTemplate.opsForValue().increment(key);
    }

    @Override
    public Set<Object> sMembers(String key) throws Exception {
        return redisTemplate.opsForSet().members(key);
    }

    @Override
    public Long sAdd(String key, String member) throws Exception {
        return redisTemplate.opsForSet().add(key, member);
    }

    @Override
    public Long sAdd(String key, String... members) throws Exception {
        return redisTemplate.opsForSet().add(key, members);
    }

    @Override
    public Long sRemove(String key, String member) throws Exception {
        return redisTemplate.opsForSet().remove(key, member);
    }

    @Override
    public boolean hSet(String key, String field, String value) throws Exception {
        redisTemplate.opsForHash().put(key, field, value);
        return true;
    }

    @Override
    public String hGet(String key, String field) throws Exception {
        Object o = redisTemplate.opsForHash().get(key, field);
        return o == null ? null : o.toString();
    }

    @Override
    public Map<Object, Object> hGetAll(String key) throws Exception {
        return redisTemplate.opsForHash().entries(key);
    }

    @Override
    public boolean hMSet(String key, Map<Object, Object> values) throws Exception {
        redisTemplate.opsForHash().putAll(key, values);
        return true;
    }

    @Override
    public Set<String> findKeyByPartten(String partten) throws Exception {
        return redisTemplate.keys(partten);
    }

    @Override
    public Long getAutoIncrementId(String key, long delta) throws Exception {
        return redisTemplate.opsForValue().increment(key, delta);
    }

    @Override
    public Long hIncrementId(String key, String field, long delta) throws Exception {
        return redisTemplate.opsForHash().increment(key, field, delta);
    }

    @Override
    public boolean setNx(String key, String value, long expireTime) throws Exception {
        if (expireTime == 0) {//按照我们自己的要求,如果是0 代表是-1
            expireTime = -1;
        } else if (expireTime < -1) { //如果小于-1 代表的是取绝对值
            expireTime = Math.abs(expireTime);
        }
        redisTemplate.opsForValue().setIfAbsent(key, value);
        if (expireTime > 0) {
            //需要设置有效期,时间单位是毫秒值
            redisTemplate.expire(key, expireTime, TimeUnit.MILLISECONDS);
        }
        return true;
    }
}

编写Controller

package com.qf.controller;


import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import com.qf.service.CacheService;
import com.qf.utils.RedisUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.Set;

@RestController
@RequestMapping("/cache")
public class CacheController {

    private static Logger logger = LoggerFactory.getLogger(CacheController.class);

    @Autowired
    private CacheService cacheService;

    @PostMapping("/set/{key}/{value}/{expireTime}")
    //降级的配置
    @HystrixCommand(fallbackMethod = "save2RedisFallback")
    public boolean save2Redis(@PathVariable String key, @PathVariable String value, @PathVariable long expireTime) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(value);

        return cacheService.save2Redis(key, value, expireTime);
    }


    @GetMapping("/get/{key}")
    //降级的配置
    @HystrixCommand(fallbackMethod = "getFromRedisFallback")
    public String getFromRedis(@PathVariable String key) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.getFromRedis(key);
    }

    @PostMapping("/delete/{key}")
    public boolean deleteKey(@PathVariable String key) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.deleteKey(key);
    }

    @PostMapping("/expire/{key}/{expireTime}")
    public boolean expire(@PathVariable String key, @PathVariable long expireTime) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.expire(key, expireTime);
    }


    @GetMapping("/getid/{key}")
    public Long getAutoIncrementId(@PathVariable String key) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.getAutoIncrementId(key);
    }

    @GetMapping("/smembers/{key}")
    public Set<Object> sMembers(@PathVariable String key) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.sMembers(key);
    }

    @PostMapping("/sadd/{key}/{member}")
    public Long sAdd(@PathVariable String key, @PathVariable String member) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(member);
        return cacheService.sAdd(key, member);
    }

    @PostMapping("/sadds/{key}")
    public Long sAdd(@PathVariable String key, String[] members) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.sAdd(key, members);
    }


    @PostMapping("/sremove/{key}/{member}")
    public Long sRemove(@PathVariable String key, @PathVariable String member) throws Exception {

        RedisUtil.checkNull(key);
        RedisUtil.checkNull(member);
        return cacheService.sRemove(key, member);
    }


    @PostMapping("/hset/{key}/{field}/{value}")
    public boolean hSet(@PathVariable String key, @PathVariable String field, @PathVariable String value) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(field);
        RedisUtil.checkNull(value);
        return cacheService.hSet(key, field, value);
    }

    @GetMapping("/hget/{key}/{field}")
    public String hGet(@PathVariable String key, @PathVariable String field) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(field);
        return cacheService.hGet(key, field);
    }


    @GetMapping("/hgetall/{key}")
    public Map<Object, Object> hGetAll(@PathVariable String key) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.hGetAll(key);
    }

    @PostMapping("/hmset/{key}")
    public boolean hMSet(@PathVariable String key, @RequestBody Map<Object, Object> values) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.hMSet(key, values);
    }


    @GetMapping("/keys/{partten}")
    public Set<String> findKeyByPartten(@PathVariable String partten) throws Exception {
        RedisUtil.checkNull(partten);
        return cacheService.findKeyByPartten(partten);
    }

    @GetMapping("/increment/{key}/{delta}")
    public Long getAutoIncrementId(@PathVariable String key, @PathVariable long delta) throws Exception {
        RedisUtil.checkNull(key);
        return cacheService.getAutoIncrementId(key, delta);
    }

    @GetMapping("/hIncrementid/{key}/{field}/{delta}")
    public Long hIncrementId(@PathVariable String key, @PathVariable String field, @PathVariable long delta) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(field);
        return cacheService.hIncrementId(key, field, delta);
    }

    @PostMapping("/setnx/{key}/{value}/{expireTime}")
    public boolean setNx(@PathVariable String key, @PathVariable String value, @PathVariable long expireTime) throws Exception {
        RedisUtil.checkNull(key);
        RedisUtil.checkNull(value);
        return cacheService.setNx(key, value, expireTime);
    }

    /**
     * 保存数据到缓存中的降级的方法
     *
     * @param key
     * @param value
     * @param expireTime
     * @return
     * @throws Exception
     */
    public boolean save2RedisFallback(String key, String value, long expireTime) throws Exception {
        logger.error("save2Redis 出现异常,进行降级,{}:{}",key,value);
        return false;
    }


    @HystrixCommand(fallbackMethod = "getFromRedisFallback")
    public String getFromRedisFallback(String key) throws Exception {
        logger.error("getFromRedi 出现异常,进行降级,{}",key);
        return null;
    }
}

编写Utils

package com.qf.utils;

import com.qf.constans.ExceptionDict;
import com.qf.exception.RedisException;
import org.springframework.util.StringUtils;

public class RedisUtil {
    /**
     * 用于判断指定内容是不是为空
     * @param source
     */
    public static void checkNull(String source) {
        if (StringUtils.isEmpty(source)) {
            throw  new RedisException("参数为空", ExceptionDict.CONTENT_NULL_EXCEPTION);
        }
    }
}

编写Exception

package com.qf.exception;

public class RedisException extends RuntimeException {

    public RedisException() {
    }

    private String message;
    private String code;

    public RedisException(String message, String code) {
        this.message = message;
        this.code = code;
    }

    @Override
    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }
}

创建openapi-commons子工程(步骤省略,直接拷贝过来即可)

在openapi-cache工程的pom.xml文件中,导入openapi-commons

<dependency>
       <groupId>com.qf</groupId>
       <artifactId>openapi-commons</artifactId>
       <version>1.0-SNAPSHOT</version>
</dependency>

6.6 其他内容

我们的服务需要包含降级操作,当程序内部出现问题的时候快速失败,返回数据给调用者,因此注意,我们的controller代码中需要配置hystrix

七、 运营管理平台(openapi-web-master)

在之前的时候我们通过ssm将我们的管理平台进行了代码编写,当整合到我们的大工程中的时候,因为现在使用的是springboot,所以我们需要将项目进行改造

7.1 pom依赖替换

<!--
我们将依赖替换为各种starter
-->
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.21</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
            <version>5.1.47</version>
        </dependency>
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.2.10</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.12</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--
        用于发送 mq 消息的
        -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-stream-rabbit</artifactId>
        </dependency>

        <dependency>
            <groupId>com.qianfeng</groupId>
            <artifactId>openapi-commons</artifactId>
            <version>1.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>
    </dependencies>

7.2 application配置文件

因为使用的是springboot,会简化大量的配置文件,因此删除之前的所有的和ssm相关的配置文件,通过一个application.yml来进行配置

spring:
  datasource:
    password: qishimeiyoumima
    username: root
    driver-class-name: org.gjt.mm.mysql.Driver
    url: jdbc:mysql://localhost:3306/openapi-admin?characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
  application:
    name: openapi-web-master
    #redis服务器的设置应当通过config server来获取,此处为了方便代码编写,故此直接写在这里,具体的配置可以参考 cache模块的配置
  rabbitmq:
    host: 192.168.3.29
    port: 8800
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true #显示 ip
#别名
mybatis:
  type-aliases-package: com.qianfeng.openapi.web.master.pojo
 #开启feign的降级 
feign:
  hystrix:
    enabled: true
### Ribbon 配置
ribbon:
  # 连接超时
  ConnectTimeout: 2000
  # 响应超时
  ReadTimeout: 5000
hystrix:
  shareSecurityContext: true
  command:
    default:
      execution:
        isolation:
          thread:
            # 熔断器超时时间,默认:1000/毫秒
            timeoutInMilliseconds: 5000

7.3 CacheService Feign

因为我们的缓存中的数据是通过管理平台添加到缓存中的,因此我们需要调用缓存服务,需要引入feign

当前文档中 不具体写明降级的方法的具体实现

//fallback为降级类,需要实现当前接口
@FeignClient(value = "OPENAPI-CACHE", fallback = CacheServiceFallback.class)
public interface CacheService {
    @RequestMapping(value = "/cache/get/{key}", method = RequestMethod.GET)
    String get(@PathVariable("key") String key);

    @RequestMapping(value = "/cache/setnx/{key}/{value}", method = RequestMethod.POST)
    boolean setnx(@PathVariable("key") String key, @PathVariable("value") String value, @RequestParam("expireSecond") long expireSecond);

    @RequestMapping(value = "/cache/delete/{key}", method = RequestMethod.POST)
    boolean del(@PathVariable("key") String key);

    @RequestMapping(value = "/cache/hmset/{key}", method = RequestMethod.POST)
    boolean hmset(@PathVariable("key") String key, @RequestBody Map<String, Object> map);

    @RequestMapping(value = "/cache/set/{key}/{value}/{expiretime}", method = RequestMethod.POST)
    void set(@PathVariable("key") String key, @PathVariable("value") String value, @PathVariable("expiretime") long expireSecond);

    @RequestMapping(value = "/cache/reverseRangeWithScores", method = RequestMethod.POST)
    List<String> reverseRangeWithScores(@RequestParam("key") String key, @RequestParam("start") int start, @RequestParam("end") int end);


    @PostMapping("/cache/hset/{key}/{field}/{value}")
    void hSet(@PathVariable String key, @PathVariable String field, @PathVariable String value) throws Exception;

    @GetMapping("/cache/hget/{key}/{field}")
    String hGet(@PathVariable String key, @PathVariable String field) throws Exception;

    @PostMapping("/cache/sadd/{key}/{value}")
    Long sAdd(@PathVariable String key,@PathVariable  String value, @RequestParam(defaultValue = "-1") long expireTime) throws Exception;

    @PostMapping("/cache/sremove/{key}/{value}")
    public Long sRemove(@PathVariable("key") String key,@PathVariable("value")  String value) throws Exception;

     /**
     * 效果和上面的hmset 一样,只不过当我们存放的数据是对象的时候 也是自动转换为json
     * @param key
     * @param data
     * @return
     */
    @RequestMapping(value = "/cache/hmset/{key}", method = RequestMethod.POST)
    boolean hmset(@PathVariable("key") String key, @RequestBody Object data);
}

7.3 service修改

我们的service的事务修改为注解模式,并且在我们的业务中,需要将原先的增删改代码中添加修改redis的操作

此处以ApiMappingService为例子

@Service
@Transactional
public class ApiMappingServiceImpl implements ApiMappingService {
    @Autowired
    private ApiMappingMapper apiMappingMapper;
    @Autowired
    private CacheService cacheService;
    @Autowired
    private SendAPIRoutingChangeStream sendAPIRoutingChangeStream;
    //数据在redis中的key的前缀,后续可以通过统一的常量类来设置
    private final String GATEWAY_REDIS_KEY = "APINAME:";

    @Override
    public void addApiMapping(ApiMapping mapping) {
        apiMappingMapper.addApiMapping(mapping);
        cacheService.hmset(GATEWAY_REDIS_KEY + mapping.getGatewayApiName(), mapping);
       //此处后续可以添加mq来发送更新数据到网关,减少网关请求缓存的频率,后续添加代码
    }

    @Override
    public void updateApiMapping(ApiMapping mapping) {
       apiMappingMapper.updateApiMapping(mapping);
             if (mapping.getState() == 1) {
            //如果状态还是1 则是更新数据
                cacheService.hmset(GATEWAY_REDIS_KEY + mapping.getGatewayApiName(),mapping);
            }else{
                //如果状态是0 则代表禁用,因此从缓存中删除
                cacheService.del(GATEWAY_REDIS_KEY + mapping.getGatewayApiName());
            }
        //发送消息
        //此处后续可以添加mq来发送更新数据到网关,减少网关请求缓存的频率,后续添加代码
    }

    @Override
    public PageInfo<ApiMapping> getMappingList(ApiMapping criteria, int page, int pageSize) {
        PageHelper.startPage(page, pageSize);
        return new PageInfo<>(apiMappingMapper.getMappingList(criteria));
    }

    @Override
    public ApiMapping getMappingById(int id) {
        return apiMappingMapper.getMappingById(id);
    }

    @Override
    public void deleteMapping(int[] ids) {
        if (ids == null || ids.length == 0) {
            return;
        }
        for (int id : ids) {
            ApiMapping mapping = apiMappingMapper.getMappingById(id);
            if (mapping != null) {
                mapping.setState(0);
                apiMappingMapper.updateApiMapping(mapping);
                cacheService.del(GATEWAY_REDIS_KEY + mapping.getGatewayApiName());
                //发送消息
               //此处后续可以添加mq来发送更新数据到网关,减少网关请求缓存的频率,后续添加代码
            }
        }

    }

}

7.3 主程序

@SpringBootApplication
@MapperScan("com.qianfeng.openapi.web.master.mapper")
@EnableEurekaClient
@EnableFeignClients
@EnableTransactionManagement//开启事务管理
public class WebMasterStartApp {
    public static void main (String[] args){
        SpringApplication.run(WebMasterStartApp.class,args);
    }
}

八、 网关中心

网关的主要作用是对请求进行校验,鉴权,转发,记录日志等功能

8.1 动态路由

8.1.1 介绍

我们知道, zuul是一个反向代理网关,可以代理我们的微服务,但是在实际开发中,我们不会让网关直接显式的代理我们的服务,而是通过动态路由的方式来进行代理,而我们也知道,所谓代理服务就是最终知道请求哪个服务中的哪个地址,参数是什么,所以我们只要想办法告诉zuul这些内容即可

那我们怎么知道用户要请求的是哪个服务的哪个地址呢,同时zuul又通过什么方式来进行转发的呢

zuul中主要是通过RequestContext这个类来进行处理的

我们通过下面的两个方法来指定我们的 服务id和内部的请求地址,其中请求地址包含参数或者是是rest风格地址

那么上面的两个参数如何获得呢, 我们可以给每个服务的每个地址指定一个标识,用户传递标识后,我们通过标识来获取到服务id和请求地址,然后设置到过去,这个就是我们管理平台中定义的路由映射

按照我们的管理平台的逻辑,它将数据放到了redis中,所以我们只要从redis中根据用户传递的标识来进行获取数据,然后设置过去即可

context.put(FilterConstants.SERVICE_ID_KEY, serviceId);
context.put(FilterConstants.REQUEST_URI_KEY, url);

8.1.2 关键代码

创建openapi-zuul工程,导入依赖

 <dependencies>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>


        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>

        <dependency>
            <groupId>com.qf</groupId>
            <artifactId>openapi-commons</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!--actuator是监控系统健康,http://ip:port/actuator/routes获取监控端点信息 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!--jwt-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

    </dependencies>

创建application.yml文件

spring:
  application:
    name: openapi-zuul
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true
server:
  port: 31000
management:
  endpoints:
    web:
      exposure:
        include: '*' #打开所有的监控管理地址
zuul:
  ignored-services: '*' #忽略所有的服务,不在代理列表中显示
  routes:
    openapi-cache: '/*' #配置将所有的请求都转发到openapi-cache缓存服务,目的是保证我们的zuul可以使用,但是实际开发中我们不会将请求转到缓存,可以写一个没有任何功能的测试服务来进行代理

创建启动类

package com.qf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
@EnableFeignClients
public class OpenapiZuulApp {

    public static void main (String[] args){
        SpringApplication.run(OpenapiZuulApp.class,args);
    }
}

创建路由过滤器

package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Enumeration;
import java.util.Map;

/**
 * 当前过滤器的主要作用是根据用户传递的标识来获取到用户实际想要访问的服务的id和地址,然后再进行转发
 */
@Component
public class RouterFilter extends ZuulFilter {
    @Autowired
    private CacheService cacheService;
    @Autowired
    private ObjectMapper objectMapper;

    /**
     * 当前过滤器是在我们请求的时候执行的,而我们所有的请求都是转到了缓存服务,
     * 我们应该在转发之前就将请求转到另外的服务,所以是前置过滤器
     *
     * @return
     */
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        //我们刚才分析过,我们这个过滤器是在前面的各种校验过滤器成功之后才执行的,
        // 所以它理论上是最后一个前置过滤器,所以order稍微高一些
        return 100;
    }

    @Override
    public boolean shouldFilter() {
        return true;//是否启用
        //return RequestContext.getCurrentContext().sendZuulResponse();// 根据之前的过滤器结果来决定是否启用当前过滤器
    }

    @Override
    public Object run() throws ZuulException {
        //如何拿到请求对象
        RequestContext currentContext = RequestContext.getCurrentContext();
        HttpServletRequest request = currentContext.getRequest();
        //从请求中获取用户的标识数据
        //我们应该从用户的请求参数中获取这个数据,那么请求的参数名是什么,
        // 我们怎么知道,所以我们作为服务端就要定义规则,我们要求用户必须通过method参数来传递
        String gatewayApiName = request.getParameter("gatewayApiName");
        //从redis根据用户传递的标识获取路由信息
        try {
            //获取当前参数的路由映射关系map,这里面放的就是我们的标识对应的服务的id和地址等信息,
            // 根据这个信息我们可以拿到要访问的地址和服务id,然后进行服务的跳转
            Map<Object, Object> apiMapingInfo = cacheService.hGetAll(SystemParams.METHOD_REDIS_PRE + gatewayApiName);
            //获取服务id和地址
            if (apiMapingInfo != null&&apiMapingInfo.size()>0) {
                Object serviceId = apiMapingInfo.get("serviceId");//我们要访问的服务的id
                String insideApiUrl = (String) apiMapingInfo.get("insideApiUrl");//我们要访问的地址
                //通过requestcontext设置我们的请求服务id和地址即可
                //设置我们要访问的地址
                currentContext.put(FilterConstants.SERVICE_ID_KEY, serviceId);
                //我们请求中传递的普通参数会一起跟随转发过去
                //但是经过我们的测试,我们的rest风格的请求是无法实现的转发的,因为我们在定义请求映射的时候就通过占位符来声明了请求的路径, 比如 test01_02 对应的是/testservice01/test02/{name}这个地址
                //我们应该将/testservice01/test02/{name}中的{name}实际替换为我们的真正的参数,否则我们的请求就会吧{name}作为真正的参数传递到下游的服务中,导致参数出现问题
                //我们应该想办法替换掉参数,怎么替换呢?我们需要知道用户请求的参数的中一一对应的关系,比如用户传递了name age两个参数,我们如何区分哪个对应哪个
                //所以我们作为服务端要开始定义规则,我们定义的规则是我们占位符中的名字和请求参数中的名字必须保持一致,这个我们只需要将请求参数中对应的值替换掉占位符就可以了
                //比如我们服务请求的地址是/testservice01/test02/{name},那么用户必须传递一个叫name的参数比如name=lisi,我们只需要将lisi替换掉{name}就可以
                //因为我们不清楚到底有什么占位符,所以比较难替换,所以我们使用逆向思维,我们看看用户传递了什么请求参数,将请求参数进行遍历,然后看看这个参数有没有对应的占位符,有的话就替换掉
                Enumeration<String> parameterNames = request.getParameterNames();//获取所有的参数名

                while (parameterNames.hasMoreElements()) {
                    String paramName = parameterNames.nextElement();//获取当前的参数名
                    insideApiUrl = insideApiUrl.replace("{" + paramName + "}", request.getParameter(paramName));//将符合当前遍历的参数名的占位符替换为请求参数的值
                }

                currentContext.put(FilterConstants.REQUEST_URI_KEY, insideApiUrl);
                return null;//返回值没有任何意义
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        //说明路由失败,拦截请求,返回错误信息
        currentContext.setSendZuulResponse(false);
        HttpServletResponse response = currentContext.getResponse();
        //   response.setContentType("text/html;charset=utf-8");
        //currentContext.setResponseBody("路由失败,检查参数");
        BaseResultBean bean = new BaseResultBean();
        bean.setCode(ExceptionDict.ROUTING_ERROR);
        bean.setMsg("与 "+gatewayApiName+" 相关的服务没有找到,请确认后再重试");
        response.setContentType("appliaction/json;charset=utf-8");
        try {
            currentContext.setResponseBody(objectMapper.writeValueAsString(bean));
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return null;
    }
}

8.2 请求参数校验

8.2.1 介绍

通过上面的功能,我们发现我们要求用户必须传递mehtod参数来获取请求的数据,但是如果用户没有传递的话,我们去查询缓存也没有任何意义,所以我们在查询之前要求用户必须传递这个参数, 所以我们必须校验用户有没有传递,校验的方式很简单,我们只要查询下这个参数有没有值即可, 但是我们怎么知道我们需要校验哪些参数呢,如果需要校验的参数发生变化怎么办,我们需要知道发生了变化, 所以我们在运营管理平台中将这些参数同步到了缓存中,所以我们只需要从缓存中获取即可,然后遍历获取数据即可

8.2.2 关键代码

关键代码,主要是说明逻辑

package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Set;


/**
 * 当前过滤器主要针对的是整个网关中公共的系统参数的校验,判断用户有没有按照我们的要求传递必须的参数
 */
@Component
public class SystemParamsFilter extends ZuulFilter {
    @Autowired
    private CacheService cacheService;

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    /**
     * 我们应该先判断用户有没有传递参数,没有的话就返回错误数据
     *
     * @return
     */
    @Override
    public int filterOrder() {
        return 10;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        RequestContext context=RequestContext.getCurrentContext();
        //我们要做的事情是判断有没有传递我们要求的参数
        //需要的数据内容: 1 我们要求传递的参数是什么
        //我们如何知道我们要求传递的参数
        HttpServletRequest request = context.getRequest();
        try {
            //我们保存在了redis中,所以从redis中查询即可
            Set<Object> systemparamsSet = cacheService.sMembers(SystemParams.SYSYTEMPARAMS);

            //当我们有了数据之后,怎么知道用户有没有传递呢?
            //只要遍历我们的参数,挨个获取一次数据,没有值的都是没有传递的
            if (systemparamsSet != null) {
                for (Object param : systemparamsSet) {
                    String value = request.getParameter(param.toString());//获取当前参数的值
                    if (StringUtils.isEmpty(value)) {
                        //代表当前参数没有传递
                        context.setSendZuulResponse(false);
                        HttpServletResponse response = context.getResponse();
                        response.setContentType("appliaction/json;charset=utf-8");
                        BaseResultBean bean = new BaseResultBean();
                        bean.setCode(ExceptionDict.SYSTEMPARAM_MISSED);
                        bean.setMsg("必须传递参数名为:"+param+"的数据");

                        try {
                            context.setResponseBody(objectMapper.writeValueAsString(bean));
                        } catch (JsonProcessingException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }

        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

8.3 服务参数校验

除了所有服务都共同的参数之外,还有一些是每个服务必须传递的参数,我们也需要对这些参数进行校验,方式和系统参数一致,只不过系统参数在redis中的key是固定的,每个服务的参数和具体服务标识相关,所以只需要根据用户传递的标识找到这个服务需要的参数进行校验即可,此处我们不对代码进行编写,大家自己按照上面的思路编写

8.4 时间戳校验

8.4.1介绍

我们的服务为了防止请求被拦截后对数据进行修改,所以我们会要求用户传递时间戳过来,我们会对时间戳的有效期进行校验,比如我们要求有效期为1分钟,当用户传递的有效期和服务器收到的i系统时间差超过1分钟的时候,我们将认为请求无效

8.4.2 核心代码
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * 当前过滤器主要是对用户请求的时间进行校验,判断是否在我们要求的时间范围内
 */
@Component
public class TimestampFilter extends ZuulFilter {

    private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 20;
    }

    @Override
    public boolean shouldFilter() {
        return RequestContext.getCurrentContext().sendZuulResponse();
    }

    @Override
    public Object run() throws ZuulException {
        //获取到用户传递的时间戳,我们要求的是到秒级别,同时我们要求传递的时间戳格式为yyyy-MM-dd HH:mm:ss
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String timestamp = request.getParameter("timestamp");
        //获取当前的系统时间
        //比较两个时间是不是在允许的范围差内
        try {
            Date requestDate = simpleDateFormat.parse(timestamp);//转换请求时间为date对象
            System.out.println(requestDate+"-----------");
            long currentTimeMillis = System.currentTimeMillis();//获取当前时间的毫秒值
            long requestDateTime = requestDate.getTime();
            if (currentTimeMillis - requestDateTime < 0 || currentTimeMillis - requestDateTime > 60000) {
                //如果当前服务器的时间小于用户的传递时间或者是当前服务器的时间和用户的请求时间差大于1分钟,则代表请求无效
                //抛出异常的原因是因为我们可能会出现日期转换异常,也需要给用户提示相应的错误信息,所以在这里直接抛出异常,下面捕获即可
                throw new RuntimeException();
            }

        } catch (Exception e) {
            e.printStackTrace();
            context.setSendZuulResponse(false);
            HttpServletResponse response = context.getResponse();
            response.setContentType("appliaction/json;charset=utf-8");
            BaseResultBean bean = new BaseResultBean();
            bean.setCode(ExceptionDict.SYSTEMPARAM_TIME_STAMP_ERROR);
            bean.setMsg("时间戳格式不对");
            try {
                context.setResponseBody(objectMapper.writeValueAsString(bean));
            } catch (JsonProcessingException ex) {
                ex.printStackTrace();
            }
        }

        return null;
    }
}

8.5 签名校验

8.5.1 介绍

因为每次请求执行的操作可能包含敏感操作,比如牵扯到扣费等,因此我们需要对用户的请求进行验证,以防止被他人非法请求,我们使用的方式是通过签名进行校验,通过比较用户在请求时候生成传递的签名和服务器计算生成的签名进行比较,一致则通过,不一致则不通过,

8.5.2 规则描述

因为牵扯到数据的计算,所以必须存在一定的规则,并且规则要保证双方采用的一致,我们的规则如下:


  1. 将请求参数按照参数名的字典顺序进行组合 比如传递的参数是method=taobao.order,get&appkey=abcdef

  2. 组合后的参数为appkeyabcdefmethodtaobao.order,get

  3. 在上面的数据前面拼上用户的appsecret 比如用户的appsecret为asdfghj 则结果为asdfghjappkeyabcdefmethodtaobao.order,get

  4. 将上面的结果生成MD5值如 dasdasdasdasd,并将md5值以 sign为参数名添加到请求参数中

  5. 最终的请求参数为method=taobao.order,get&appkey=abcdef&sign=dasdasdasdasd

  6. 服务端收到参数后,将除了sign外的参数按照上面的顺序再次生成sign值,并和用户传递的比较,一致则通过

8.5.3 工具类
package com.qf.utils;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;

public class Md5Util {
    /**
     * 二行制转字符串
     */
    private static String byte2hex(byte[] b) {
        StringBuffer hs = new StringBuffer();
        String stmp = "";
        for (int n = 0; n < b.length; n++) {
            stmp = (Integer.toHexString(b[n] & 0XFF));
            if (stmp.length() == 1)
                hs.append("0").append(stmp);
            else
                hs.append(stmp);
        }
        return hs.toString().toUpperCase();
    }

    /***
     * 对请求的参数排序,生成定长的签名
     * @param  paramsMap  排序后的字符串
     * @param secret 密钥
     * */
    public static String md5Signature(Map<String, String> paramsMap, String secret) {
        String result = "";
        StringBuilder sb = new StringBuilder();
        Map<String, String> treeMap = new TreeMap<String, String>();
        treeMap.putAll(paramsMap);
        sb.append(secret);
        Iterator<String> iterator = treeMap.keySet().iterator();
        while (iterator.hasNext()) {
            String name = (String) iterator.next();
            sb.append(name).append(treeMap.get(name));
        }
        sb.append(secret);
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");             /**MD5加密,输出一个定长信息摘要*/
            result = byte2hex(md.digest(sb.toString().getBytes("utf-8")));

        } catch (Exception e) {
            throw new RuntimeException("sign error !");
        }
        return result;
    }

    /**
     * Calculates the MD5 digest and returns the value as a 16 element
     * <code>byte[]</code>.
     *
     * @param data Data to digest
     * @return MD5 digest
     */
    public static byte[] md5(String data) {
        return md5(data.getBytes());
    }

    /**
     * Calculates the MD5 digest and returns the value as a 16 element
     * <code>byte[]</code>.
     *
     * @param data Data to digest
     * @return MD5 digest
     */
    public static byte[] md5(byte[] data) {
        return getDigest().digest(data);
    }

    /**
     * Returns a MessageDigest for the given <code>algorithm</code>.
     *
     * @param
     * @return An MD5 digest instance.
     * @throws RuntimeException when a {@link NoSuchAlgorithmException} is
     *                          caught
     */

    static MessageDigest getDigest() {
        try {
            return MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

8.5.4 核心代码
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qf.utils.Md5Util;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;


/**
 * 当前的过滤器主要作用是用于校验用户的请求中的签名是否正确,来判断是否是合法的请求
 */

@Component
public class SignFilter extends ZuulFilter {

    @Autowired
    private CacheService cacheService;

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 30;
    }

    @Override
    public boolean shouldFilter() {
        return RequestContext.getCurrentContext().sendZuulResponse();
    }

    @Override
    public Object run() throws ZuulException {
        /**
         * 首先回顾下我们的规则, 我们的规则是将用户传递的参数中除了签名之外的数据 按照key的字典顺序进行排序,然后将排序后数据按照keyvaluekeyvalue的方式进行拼接字符串
         * 然后再获取用户的签名的秘钥,进行拼接,得到一个新的字符串,然后计算md5值,将计算的结果和用户传递的数据进行比较,一直则放行
         * 我们需要的数据就是用户传递的参数, 用户的app的秘钥,用户传递的签名
         */
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        Enumeration<String> parameterNames = request.getParameterNames();//获取所有的参数的名字
        Map<String, String> signMap = new HashMap<>();//用于保存除了签名之前的数据
        while (parameterNames.hasMoreElements()) {
            String name = parameterNames.nextElement();//获取每一个参数的名字
            if (!"sign".equalsIgnoreCase(name)) {
                signMap.put(name, request.getParameter(name));//保存除了sign之外的数据
            }
        }
        //我们现在要获取appsecret
        String app_key = request.getParameter("app_key");//获取应用的app key
        try {
//            Map<Object, Object> appInfoMap = cacheService.hGetAll(SystemParams.APPKEY_REDIS_PRE + app_key);
//            if (appInfoMap != null) {
//                Object appSecret = appInfoMap.get("appSecret");
//            }
            //和上面获取方式一样
            String appSecret = cacheService.hGet(SystemParams.APPKEY_REDIS_PRE + app_key, "appSecret");

            String md5Signature = Md5Util.md5Signature(signMap, appSecret);//计算用户传递的参数应该得到的签名

            String sign = request.getParameter("sign");//用户传递的签名

            System.err.println("系统计算的签名:" + md5Signature);
            System.err.println("用户传递的签名:" + sign);

            if (md5Signature.equalsIgnoreCase(sign)) {
                return null;//校验通过
            }

        } catch (Exception e) {
            e.printStackTrace();
        }

        //不管发生什么情况导致的校验失败,最终都会来这里执行代码
        context.setSendZuulResponse(false);
        HttpServletResponse response = context.getResponse();
        response.setContentType("appliaction/json;charset=utf-8");
        BaseResultBean bean = new BaseResultBean();
        bean.setCode(ExceptionDict.SYSTEMPARAM_SIGN_ERROR);
        bean.setMsg("签名校验失败");
        try {
            context.setResponseBody(objectMapper.writeValueAsString(bean));
        } catch (JsonProcessingException ex) {
            ex.printStackTrace();
        }

        return null;
    }
}

8.6 幂等性校验

8.6.1 介绍

我们的很多请求不允许用户重复发起请求,一般情况下所有的修改类型的操作都不允许,比如添加,删除,更新操作,但是查询操作一般都可以重复请求,这就是请求的幂等性,对于有幂等性要求的服务每次都需要发起新请求,即便是请求参数一致,但是时间戳肯定也不一致,最终签名也不一致,对于幂等性的校验也非常简单,因为幂等性的请求签名一定是一致的,所以我们只要判断当前传递的签名有没有出现过即可,我们只要以签名作为key,随便放个数据到redis中,判断的时候从redis之前key获取数据,如果有则代表重复,没有则代表没有请求过,不重复,然后放入到redis中一份即可

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...这就没有保证接口的幂等性

8.6.2 核心代码
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 这个是针对所有需要幂等性校验的服务进行的过滤器
 */
@Component
public class IdempotentsFilter extends ZuulFilter {
    @Autowired
    private CacheService cacheService;
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 40;
    }

    /**
     * 如果前面的过滤器拦截了请求,则不执行,同时如果当前请求的服务是一个非幂等性的服务,也不需要进行拦截,所以此处需要两个判断条件
     */
    @Override
    public boolean shouldFilter() {
        //根据用户请求的服务,获取到服务的信息,然后看看服务是不是需要幂等性的要求
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String gatewayApiName = request.getParameter("gatewayApiName");//用户要请求的服务的名字
        //通过查询redis来获取这个服务的幂等性
        boolean isIdempotents = true;//幂等性默认是true
        try {
            String idempotents = cacheService.hGet(SystemParams.METHOD_REDIS_PRE + gatewayApiName,"idempotents");
            isIdempotents = "1".equals(idempotents);//判断是不是幂等性的,1表示幂等,0表示非幂等
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("isIdempotents:"+isIdempotents);
        return context.sendZuulResponse() && isIdempotents;//启用的条件中包含了是不是幂等性要求的服务
    }

    @Override
    public Object run() throws ZuulException {
        //我们当前的过滤器是要判断请求是不是已经执行过一次了
        //根据我们的要求我们需要什么数据来进行这个判断
        //当前的请求是什么?我们如何区分的
        //我们如何知道请求已经执行过一次了,大家注意,我们遇到这个如何判断,如何区分,怎么样xxx之类的需求的时候一般都是需要数据去做比较,那么数据一定是保存在某个地方,比如某个变量,放在数据库,放在缓存,或者是在什么容器中等等
        //我们想一下,要想判断请求是不是执行过,需要请求有一个唯一的可以区分的标识,签名,签名是唯一的,因为不同的参数会有不同的签名,不同的app会有不同的appkey,相同的app不同的服务会有不同的method,相同的服务会有不同的参数,相同的参数中会有不同的时间戳
        //如果一切都一样,说明就是一个一样的请求
        //请求被执行过一次之后我们可以将标识保存起来,当下次请求再来的时候我们拿到签名去看看这个标识存在还是不存在,不存在则代表没有执行过,存在则代表执行过
        //实际上我们可以签名作为key向redis中保存任意一个数据,我们首先获取,没有就代表没有,有就代表执行过
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String sign = request.getParameter("sign");//获取用户传递的签名
        try {
            String idempotents = cacheService.getFromRedis(SystemParams.IDEMPOTENTS_REDIS_PRE + sign);//获取当前签名对应的数据
            //如果不为空则代表是已经请求过了,如果为空则代表没有请求国
            if (idempotents != null) {
                context.setSendZuulResponse(false);
                HttpServletResponse response = context.getResponse();
                response.setContentType("appliaction/json;charset=utf-8");
                BaseResultBean bean = new BaseResultBean();
                bean.setCode(ExceptionDict.SYSTEMPARAM_IDEMPOTENTS_ERROR);
                bean.setMsg("当前服务不允许重复提交数据");
                try {
                    context.setResponseBody(objectMapper.writeValueAsString(bean));
                } catch (JsonProcessingException ex) {
                    ex.printStackTrace();
                }
            }else{
                //有效期不会是永久的,因为一旦时间戳过期了,用户一定要传递新的时间,那么签名一定会发生变化,所以我们的有效期和时间戳的有效期保持一致即可
                cacheService.save2Redis(SystemParams.IDEMPOTENTS_REDIS_PRE + sign, System.currentTimeMillis()+"",60000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

8.7 限流

8.7.1 介绍

对于开放平台中的一些免费的接口或者是针对一个用户方位所有的免费接口会存在限制次数的问题,比如我们当前平台的规则是每个用户每天可以访问所有免费接口多少次,因此,当用户访问免费接口的时候我们需要对他现在剩余的次数进行校验,如果还有免费次数,则允许访问,所以我们只要知道当前服务是不是免费的,以及用户有没有剩余次数即可

8.7.2 核心代码
/**
 * 
 * 此过滤器的主要作用是针对所有免费的服务进行统一次数的访问限制
 * 需要注意:我们的免费接口的限制次数一般不会是永久的,一般比如一天限制多少次,或者是一周或者是 1 个月等等,所以每次进入新的统计周日的时候需要恢复用户的限制次数
 * 比如一天 10 万次,当天用完了,当晚上 0 点的时候需要给用户恢复次数,所以需要一个定时任务,但是也要注意,这个定时任务应该要保证一定能执行,所以必须是保证高可用的分布式任务
 *
 * @Author jackiechan
 */
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


/**
 * 当前过滤器的主要作用是对免费的接口进行限流,判断用户是否还有免费次数可以访问
 *
 */
@Component
public class LimitFilter extends ZuulFilter {


    @Autowired
    private CacheService cacheService;
    @Autowired
    private ObjectMapper objectMapper;


    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    /**
     * 当前过滤器的级别比路由的低一些,原因是我们必须要保证用户访问的是一个正确的服务才进行次数扣除,所以我们通过路由的过滤器来进行了这个合法性的判断
     * 大家注意并不是说路由的过滤器执行了就代表服务请求了,而是要等所有的前置过滤器都执行完成后才会请求服务,路由过滤器只是用来设置要请求什么服务
     * @return
     */
    @Override
    public int filterOrder() {
        return 110;
    }

    @Override
    public boolean shouldFilter() {
        //当前的过滤器针对的是免费的服务,所以还是要先判断接口的收费性
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String gatewayApiName = request.getParameter("gatewayApiName");//用户要请求的服务的名字
        //通过查询redis来获取这个服务的needfee,默认设置true表示免费
        boolean isFree = true;
        try {
            String free = cacheService.hGet(SystemParams.METHOD_REDIS_PRE + gatewayApiName,"needfee");
            isFree = "0".equals(free);//0表示免费,1表示收费
        } catch (Exception e) {
            e.printStackTrace();
        }
        return context.sendZuulResponse() && isFree;
    }

    @Override
    public Object run() throws ZuulException {
        //我们的目标是判断当前用户是否还有剩余的次数访问这个服务
        //我们只需要知道剩余次数是多少就可以了
        //我们只需要从redis中自减1得到的结果只要大于等于0就可以了
        //获取用户传递的app_key
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String app_key = request.getParameter("app_key");
       // String limit = cacheService.hGet(SystemParams.APPKEY_REDIS_PRE + app_key, "limit");
        Long times = null;//自减后的剩余次数
        try {
            times = cacheService.hIncrementId(SystemParams.APPKEY_REDIS_PRE + app_key, "limit", -1L);
            //TODO 将剩余的次数同步到数据库中
            if (times < 0) {
                //不能访问了
                context.setSendZuulResponse(false);
                HttpServletResponse response = context.getResponse();
                response.setContentType("appliaction/json;charset=utf-8");
                BaseResultBean bean = new BaseResultBean();
                bean.setCode(ExceptionDict.LIMIT_ERROR);
                bean.setMsg("超出本日的免费次数限制");
                try {
                    context.setResponseBody(objectMapper.writeValueAsString(bean));
                } catch (JsonProcessingException ex) {
                    ex.printStackTrace();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

8.8 计费

8.8.1 规则

对于收费的接口,我们需要进行计费扣除,如果用户有钱才会继续访问,没有钱的话就不能访问,本过滤器主要是给用户扣钱的,扣钱后剩余的钱数大于0则即可,不大于0则需要给用户加回去

/**
 * 此过滤器是针对收费的服务进行计费使用的,关于计费的策略问题,有可能是按照套餐扣除的,有的时候按照次数计费的,所以我应该是针对不同的情况不同处理
 * 假设我们当前的过滤器是按照次数计费的
 * 考虑到我们的每次请求的费用很低,所以呢我们在保存用户的钱数的时候我们需要的计量单位也要非常精确,比如我们使用毫来保存 ,1块钱=10000毫
 */
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


/**
 * 当前过滤器的主要作用是对收费接口的计费处理
 */
@Component
public class FeeFilter extends ZuulFilter {

    @Autowired
    private CacheService cacheService;
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 111;
    }

    @Override
    public boolean shouldFilter() {
        //当前的过滤器针对的是免费的服务,所以还是要先判断接口的收费性
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String gatewayApiName = request.getParameter("gatewayApiName");//用户要请求的服务的名字
        //通过查询redis来获取这个服务needfee
        boolean isFree = true;
        try {
            String free = cacheService.hGet(SystemParams.METHOD_REDIS_PRE + gatewayApiName, "needfee");
            isFree = "1".equals(free);//判断是不是收费的,1表示收费
        } catch (Exception e) {
            e.printStackTrace();
        }
        return context.sendZuulResponse() && isFree;
    }

    @Override
    public Object run() throws ZuulException {

        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String app_key = request.getParameter("app_key");
        //根据appkey找到所属的客户id
        try {
            String cusId = cacheService.hGet(SystemParams.APPKEY_REDIS_PRE + app_key, "cusId");
            if (cusId != null) {
                Long money = cacheService.hIncrementId(SystemParams.CUSTOMER_REDIS_PRE + cusId, "money", -2);
                if (money < 0) {
                    //代表用户没钱了,我们要把上面扣除的给加回去,以免用户本来剩余1或者0的时候被我们减成了负数,这不合理
                    cacheService.hIncrementId(SystemParams.CUSTOMER_REDIS_PRE + cusId, "money", 2);

                }else{
                    //有钱的情况下是不拦截的
                    return null;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        //根据客户id对客户的钱进行计费处理
        context.setSendZuulResponse(false);
        HttpServletResponse response = context.getResponse();
        response.setContentType("appliaction/json;charset=utf-8");
        BaseResultBean bean = new BaseResultBean();
        bean.setCode(ExceptionDict.FEE_ERROR);
        bean.setMsg("你个QB,赶紧充钱");
        try {
            context.setResponseBody(objectMapper.writeValueAsString(bean));
        } catch (JsonProcessingException ex) {
            ex.printStackTrace();
        }
        return null;
    }
}
//TODO 计费可能会分为很多情况,我们这个主要针对的就是按次固定扣费, 用户可能针对某个服务或者是针对所有服务有包月或者套餐服务
//TODO 实际应该根据服务的状态或者是客户的现在的套餐状况,以及每个服务的具体的钱数来进行代码编写

8.9 二级缓存动态更新(了解)

8.9.1 介绍

我们的参数过滤,动态路由等数据发生变化的次数比较少,所以没有必要每次都从redis中获取的话会导致redis负载较高,所以我们可以在网关本地存放一次,当数据发生变化的时候动态更新一次网关即可,这样既可以在网关内部将请求处理掉,而不用每次都从redis中获取,本例子以路由映射进行动态更新

8.9.2 本次缓存

我们的网关在程序启动的时候先缓存一份数据到本地,这样请求来的时候就可以直接使用了

8.9.2.1 方式1

我们可以利用servlet的listener来监听程序的启动,在启动的时候从redis中获取一次数据缓存到本地

@WebListener
public class CacheInitListener implements ServletContextListener {
    @Autowired
    private CacheService cacheService;

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        System.err.println("程序启动了");
        //找到所有的和我们的映射相关的数据,保存起来
        Set<String> set = cacheService.findKeyByPartten(SystemParams.METHOD_REDIS_PRE+"*");
        if (set != null) {
            for (String key : set) {
                try {
                    Map<Object, Object> apiInfoMap = cacheService.hGetAll(key);//根据每一个 key 获取到对应的数据
                    SystemParams.API_ROUTING_MAP.put(key, apiInfoMap);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

8.9.2.2 方式2

我们知道 spring也是通过listener来监听程序启动并初始化对象的,所以我们也可以认为这些对象就是在程序启动的时候创建并初始化的,所以我们可以利用spring创建对象的初始化方法来从redis中查询数据缓存到本地

/**
 * spring 有一个 listener,用于初始化对象的,当前对象会在里面被初始化,是不是相当于在 spring 的 listener 内部创建了当前对象
 * 这个时候我们再执行一个PostConstruct对应的方法,是不是相当于在 spring 的 listener 中执行了这个方法
 * @Author jackiechan
 */
@Component
public class InitApiRouting {
    @Autowired
    private CacheService cacheService;

    @PostConstruct //在构造方法执行之后执行
    public void init() {
        System.err.println("init方法执行");
        Set<String> set = cacheService.findKeyByPartten(SystemParams.METHOD_REDIS_PRE+"*");
        if (set != null) {
            for (String key : set) {
                try {
                    Map<Object, Object> apiInfoMap = cacheService.hGetAll(key);//根据每一个 key 获取到对应的数据
                    SystemParams.API_ROUTING_MAP.put(key, apiInfoMap);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

8.9.3 动态更新

8.9.3.1 介绍

当我们在管理平台更新了数据后,我们需要同步到网关中,实时同步的最好方式是由管理平台来主动告诉网关,因此我们通过mq发送消息的方式来通知网关,网关收到消息后查询最新数据即可,我们使用的是springcloud stream的方式

8.9.3.2 依赖导入

在webmaster和zuul中都导入以下依赖

<!--
用于发送 mq 消息的
-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>

8.9.3.3 stream接口定义

webmaster stream

public interface SendAPIRoutingChangeStream {

    @Output("apiroutingchange")//output 声明当前是生产者,会将消息发送到apiroutingchange交换机
    MessageChannel message_channel();
}

zuul stream

public interface ReceviedAPIRoutingChangeStream {

    @Input("apiroutingchange")//声明当前是消费者,监听是是apiroutingchange交换机,spring 会自动帮我们创建消息队列并绑定到交换机上
    SubscribableChannel subscribable_channel();
}

8.9.3.4 消息对象定义

消息类型枚举对象

public enum  APIRoutingType {
    UPDATE,DELETE //定义了更新和删除路由的类型
}

消息对象

public class APIRoutingMQBean implements Serializable {

    private String key;
    private APIRoutingType type;

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public APIRoutingType getType() {
        return type;
    }

    public void setType(APIRoutingType type) {
        this.type = type;
    }

    @Override
    public String toString() {
        return "APIRoutingMQBean{" +
                "key='" + key + '\'' +
                ", type=" + type +
                '}';
    }
}

8.9.3.5webmaster 服务修改

我们只需要在webmaster的service的增删改操作中发送消息即可

8.9.3.6 zuul stream listener
@Component
public class APIRoutingStreamListener {
    @Autowired
    private CacheService cacheService;

    @StreamListener("apiroutingchange")//监听对应的交换机,实际上是自动创建了一个队列,绑定了这个交换机,监听的实际上是队列
    public void onMessage(APIRoutingMQBean bean) {
        System.err.println("收到了一个消息" + bean);

        switch (bean.getType()) {
            case UPDATE:
                //从redis 中重新获取当前数据,然后替换掉 map 中的数据或者是添加到 map 中取决于webmster 执行的是添加还是更新操作
                String key = bean.getKey();//拿到要获取的数据的 key
                try {
                    Map<Object, Object> apiInfoMap = cacheService.hGetAll(key);
                    SystemParams.API_ROUTING_MAP.put(key, apiInfoMap);//添加或者替换数据
                } catch (Exception e) {
                    e.printStackTrace();
                }
                break;

            case DELETE:
                SystemParams.API_ROUTING_MAP.remove(bean.getKey());//从本地删除
                break;
        }
    }
}

8.10 登陆鉴权

8.10.1 介绍

  • 我们的开放平台一般不会需要用户必须登陆,但是如果需要的话,我们需要对用户的登陆信息进行校验,那么如何校验用户的登陆信息呢,类似于淘宝的登陆系统并不是简单的只给淘宝用的,阿里巴巴旗下的绝大部分功能都可以使用这一个帐号登陆,如果我们给每个系统都写一套登陆系统的话,代码是一样的出现功能重写,那么我们想办法只写一个登陆系统,然后进行统一的验证,只需要在任意其他系统中对登陆返回的数据进行校验即可,我们称之为单点登录

  • 单点登录实现的方式有很多,其本质就是数据的共享,之前大部分的方式都是将帐号系统独立出来,用户访问授权系统登陆,登陆系统会返回一个验证信息给用户, 用户访问A功能的时候将验证信息带过去,A服务器在内部验证,如果无法验证就会在内部请求授权服务器进行验证,成功后在A保存一份,并让用户继续访问,下次的话就可以直接内部验证了,这时候如果用户要访问B地址按照i相同的流程再来一次, 这样我们在授权系统进行一次登陆后就可以i在多个系统实现登陆

  • 在开放平台中,登陆授权并不属于其中的一部分,登陆系统一般已经有人写好了,我们只需要按照他们定义的规范使用数据就是了,其实就是登陆系统返回的数据我们保存起来,下次按照服务器的要求通过对应的方式传递过去,比如cookie, header,请求参数等方式

8.10.2 JWT介绍

在上面的方式中,授权系统会做很多操作,包括登陆,校验等,所以需要的资源比较多,可能会出现系统瓶颈的问题,现在一般流行简化的验证方式, 还是上面的那个功能,如果我们的AB服务自己知道如何校验的话,就不需要去授权系统进行请求校验了,而是自己直接校验就可以了,但是呢如果校验的安全级别不够的话比较容易被人伪造信息,这里就可以使用我们上面的签名的方式来提高安全度,那可不可以这样呢,我们的授权系统将授权信息签名后发给客户,客户下次带着数据过来,我们进行签名校验就可以了,如果可以,说明没有问题,这种技术我们称之为令牌Token

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。

一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。

头部(Header)

头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。

{“typ”:”JWT”,”alg”:”HS256”}

在头部指明了签名算法是HS256算法。 我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下:

载荷(playload)

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

(1)标准中注册的声明(建议但不强制使用)

(2)公共的声明

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

(3)私有的声明

私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

这个指的就是自定义的claim。比如前面那个结构举例中的admin和name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。

定义一个payload:

然后将其进行base64加密,得到Jwt的第二部分。

签证(signature)

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:


  • header (base64后的)

  • payload (base64后的)

  • secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token。
{"sub":"1234567890","name":"John Doe","admin":true}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

8.10.3 发送Toekn 核心代码

Token生成发送是在授权系统中的,所以授权系统内部主要是判断你账号和密码,成功后返回一个token,比如我们这里是通过header来返回,并且用户在请求其他服务的时候也是通过header来发送的,下面使用案例演示:

创建openapi-auth工程,并导入依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>

    <dependency>
        <groupId>com.qf</groupId>
        <artifactId>openapi-commons</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
</dependencies>

创建启动类

package com.qf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
public class OpenapiAuthApp {

    public static void main(String[] args) {
        SpringApplication.run(OpenapiAuthApp.class, args);
    }
}

配置application.yml

server:
  port: 44000
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true
spring:
  application:
    name: openapi-auth

创建User以及Controller,拷贝CacheService以及CacheServiceFallback

package com.qf.pojo;

import lombok.Data;

@Data
public class User {

    private String username;
    private String password;

}
package com.qf.controller;

import com.qf.feign.CacheService;
import com.qf.pojo.User;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletResponse;
import java.time.Instant;
import java.util.Date;

@RestController
@RequestMapping("auth")
public class AutherController {
    @Autowired
    private CacheService cacheService;

    @RequestMapping("login")
    public BaseResultBean auther(@RequestBody User user, HttpServletResponse response) {
        BaseResultBean bean = new BaseResultBean();

        if ("jack".equals(user.getUsername()) && "123".equals(user.getPassword())) {
            //帐号信息是对的,直接给用户返回JWT相关的信息
            bean.setCode("200");
            bean.setMsg("登陆成功");
            Instant now = Instant.now();
            String jwt = Jwts.builder()
                    .setSubject("jack")//设置当前的用户是谁,当然任何信息都可以随便写,只不过你后续拿到之后再进行处理
                    .setIssuedAt(Date.from(now))//设置开始的有效期
                    .setExpiration(Date.from(now.plusSeconds(3600)))//设置过期时间我当前时间顺眼一小时
                    .claim("id", 1001)//可以随便内容,主要是键值对,可以在需要的地方拿出来
                    .claim("permission", "user:add")
                    .signWith(SignatureAlgorithm.HS256,"jwt-password".getBytes())//设置签名的算法和秘钥值
                    .compact();
            System.err.println(jwt);
            //我们自己定义一个规范,将token放到响应头中返回
            response.addHeader("token",jwt);
            try {
                cacheService.save2Redis(SystemParams.JWT_TOKEN_REDIS_PRE + user.getUsername(), jwt,3600*1000);//向redis中保存用户的jwt
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else{
            bean.setCode("0");
            bean.setMsg("帐号密码错误");
        }

        return bean;
    }
}

在openapi-zuul工程中创建JwtFilter

package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.feign.CacheService;
import com.qianfeng.openplatform.commons.beans.BaseResultBean;
import com.qianfeng.openplatform.commons.constans.ExceptionDict;
import com.qianfeng.openplatform.commons.constans.SystemParams;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


@Component
public class JwtFilter extends ZuulFilter {
    @Autowired
    private CacheService cacheService;

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    /**
     * 理论上它是第一个需要校验的filter
     */
    @Override
    public int filterOrder() {
        return 9;
    }

    @Override
    public boolean shouldFilter() {
         return RequestContext.getCurrentContext().sendZuulResponse();
    }

    @Override
    public Object run() throws ZuulException {
        //拿到用户传递的jwt
        //我们要求用户通过token这个header来传递
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        String jwt = request.getHeader("token");//拿到我们的JWT
        System.out.println("jwt:"+jwt);
        //进行校验
        try {
            //用户可能会多次登陆,或者是在多个设备上登陆,如果我们的平台不允许用户多设备登陆,代表一旦在某个设备上登陆后,之前的token就要失效
            //在此处我们应该要判断当前的用户的最新token和用户传递的token是不是一样.所以我一定要在某个地方保存有用户最新的token
            //我们选择保存在数据库加redis,在这里应该是从redis进行查询看看用户的最新的token是什么
            //校验JWT.如何失败会抛出异常
            //"jwtpassword".getBytes()要和之前AutherController中对应,如果是字节,两遍存的时候都要是字节
            Claims body = Jwts.parser().setSigningKey("jwt-password".getBytes()).parseClaimsJws(jwt).getBody();
            //我们可以拿到用户信息以及之前存储的键值对
            String subject = body.getSubject();
            Object id = body.get("id");
            System.err.println("subject:"+subject);
            System.err.println("id:"+id);
            //如何知道用户最新的token,也就是redis中的key应该和什么有关,我们可以和用户的id或者是名字有关即可
            //我们可以根据用户传递过来的token中的id或者名字去查询一下它对应的最新的token
            String jwtfromRedis = cacheService.getFromRedis(SystemParams.JWT_TOKEN_REDIS_PRE + subject);
            System.out.println("jwtfromRedis:"+jwtfromRedis);
            //如果没有异常代表校验成功,直接放行
            if (jwt != null&&!jwt.equals(jwtfromRedis)) {
                //缓存中放的最新的和用户传递的不一致的情况下,就直接扔出异常,给用户返回认证失败
                throw new RuntimeException();
            }
            return null;
        }catch (Exception e){
            //不能访问了
            context.setSendZuulResponse(false);
            HttpServletResponse response = context.getResponse();
            response.setContentType("appliaction/json;charset=utf-8");
            BaseResultBean bean = new BaseResultBean();
            bean.setCode(ExceptionDict.UNLOGIN);
            bean.setMsg("尚未登陆认证");
            try {
                context.setResponseBody(objectMapper.writeValueAsString(bean));
            } catch (JsonProcessingException ex) {
                ex.printStackTrace();
            }
        }

        return null;
    }
}

先访问AutherController生成token,再访问服务进行测试

九、 日志系统


  • 我们的系统内部应当对用户的操作进行日志处理,以用于后面的不时之需,记录日志本质上就是保存一些特定格式的数据,然后通过某种方式进行查询,比如保存到数据库,或者保存到文件等,我们此处选择的是保存到es中

  • 按照功能划分,日志是一套独立的系统,我们此处通过对网关的访问进行日志保存,其他地方的日志可参考自行编码

  • 我们如何将网关中的日志写入到日志系统中呢, 我们将日志封装到搜索服务,通过MQ将日志从网关发送到搜素,用MQ的原因是日志不属于用户操作流程中的一部分,日志的有无不应该影响用户的请求结果,所以我们使用异步来执行操作

9.1 实现思路

通过网关调用服务时,编写过滤器把日志信息封装成对象传到消息队列中,在搜索模块中拿到消息队列中的传过来的json数据,然后存储到elasticsearch中,后台管理系统可以查询所有日志信息

9.2核心代码

1.在openapi-zuul网关模块中,导入MQ依赖,创建实体类,过滤器,MQ

package com.qf.bean;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.Date;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoggerBean implements Serializable {

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date requestTime;//开始时间
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date responseTime;//结束时间
    private long totalTime;//服务共用时

    private String gatewayApiName;//路由名称
    private String serverIp;//192.168.25.1
    //0:0:0:0:0:0:0:1是ipv6的表现形式,对应ipv4来说相当于127.0.0.1,也就是本机
    private String remoteIp;//0:0:0:0:0:0:0:1
    private String content;//请求体

}
package com.qf.filters;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import java.util.Date;


/**
 * 这个过滤器就是为了保存我们的开始时间
 */
@Component
public class LoggerPreFilter extends ZuulFilter {
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 9;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        RequestContext.getCurrentContext().put("startTime", new Date());//保存了开始时间
        return null;
    }
}
package com.qf.filters;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.qf.bean.LoggerBean;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Date;


@Component
public class LoggerFilter extends ZuulFilter {
    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 我们应该在给用户返回数据的时候计算响应时间,所以这个filter应该是post类型(后置)
     */
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }

    @Override
    public int filterOrder() {
        return 50;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {

        //把我们要的数据找到,然后通过日志发送出去
        LoggerBean loggerBean = new LoggerBean();

        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        Date responeDate = new Date();
        loggerBean.setResponseTime(responeDate);//设置响应时间
        Date startTime = (Date) context.get("startTime");
        loggerBean.setRequestTime(startTime);//设置请求时间
        loggerBean.setTotalTime(responeDate.getTime() - startTime.getTime());//设置总时间

        loggerBean.setGatewayApiName(request.getParameter("gatewayApiName"));//路由名称

        try {
            loggerBean.setServerIp(InetAddress.getLocalHost().getHostAddress());//192.168.25.1
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        loggerBean.setRemoteIp(request.getRemoteAddr());//0:0:0:0:0:0:0:1(127.0.0.1)
        loggerBean.setContent(request.getQueryString());//请求体内容

        String loggerbean = null;
        try {
            loggerbean = objectMapper.writeValueAsString(loggerBean);
            System.out.println("loggerbean:"+loggerbean);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        //发送到消息队列中
        rabbitTemplate.convertAndSend("","simpleQueue",loggerbean);

        return null;
    }
}
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
package com.qf.mq;

import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SimpleQueueConfig {

    @Bean
    public Queue simple(){
        return new Queue("simpleQueue");
    }
}

2.创建openapi-search搜索模块,导入依赖,修改elasticsearch版本

<!-- 手动定义 elasticsearch 版本 -->
<properties>
    <elasticsearch.version>7.6.2</elasticsearch.version>
</properties>


<dependencies>

    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

3.创建application.yml

server:
  port: 38000
spring:
  elasticsearch:
    rest:
      uris: 39.105.189.141:9200
  rabbitmq:
    host: 39.105.189.141
    port: 5672
    username: guest
    password: guest
  application:
    name: openapi-search
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka

elasticsearch:
  index: openapilog

4.编写启动类,mq,pojo,controller,service…

package com.qf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class OpenapiSearchApp {
    public static void main (String[] args){
        SpringApplication.run(OpenapiSearchApp.class,args);
    }
}
package com.qf.mq;

import com.qf.service.SearchService;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;


@Component
@RabbitListener(queues="simpleQueue")//监听指定的消息队列
public class SimpleQueueCustomer {

    @Autowired
    private SearchService searchService;

    //@RabbitHandler修饰的方法中实现接受到消息后的处理逻辑
    @RabbitHandler
    public void receive(String content){
        System.out.println("来SimpleQueueProducer的信息:"+content);

        try {
            searchService.addDoc(content);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}
package com.qf.pojo;

import lombok.Data;

import java.util.List;

@Data
public class TableData<T> {
    private int code=0;
    private String msg;
    private long count;
    private List<T> data;

    public TableData() {
    }

    public TableData(long count, List<T> data) {
        this.count = count;
        this.data = data;
    }
}
package com.qf.controller;

import com.qf.pojo.TableData;
import com.qf.service.SearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("log")
public class SearchController {

    @Autowired
    private SearchService searchService;

    @RequestMapping("createIndex")
    public String createIndex() throws Exception {

        String result = searchService.createIndex();

        if(result.equals("success")){
            return "success";
        }

        return "fail";
    }

    @RequestMapping("searchlog")
    public TableData searchLog() {

        try {
            TableData tableData = searchService.matchAllQuery();

            return tableData;
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }

}
package com.qf.service;

import com.qf.pojo.TableData;

import java.util.List;
import java.util.Map;

public interface SearchService {
    /**
     * 创建index
     */
    String createIndex() throws Exception;

    /**
     * 添加数据
     */
    void addDoc(String json) throws Exception;

    /**
     * 查询数据
     */
    TableData matchAllQuery() throws Exception;

}
package com.qf.service.impl;

import com.qf.pojo.TableData;
import com.qf.service.SearchService;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.indices.CreateIndexRequest;
import org.elasticsearch.client.indices.CreateIndexResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;


@Service
public class SearchServiceImpl implements SearchService {

    @Value("${elasticsearch.index}")
    private String index;

    @Autowired
    private RestHighLevelClient elasticsearchClient;

    @Override
    public String createIndex() throws Exception {
        //1. 准备关于索引的settings
        Settings.Builder settings = Settings.builder()
                .put("number_of_shards", 3)
                .put("number_of_replicas", 1);

        //2. 准备关于索引的结构mappings
        XContentBuilder mappings = JsonXContent.contentBuilder()
                .startObject()
                .startObject("properties")

                .startObject("requestTime")
                .field("type","date")
                .field("format","yyyy-MM-dd HH:mm:ss")
                .endObject()

                .startObject("responseTime")
                .field("type","date")
                .field("format","yyyy-MM-dd HH:mm:ss")
                .endObject()

                .startObject("totalTime")
                .field("type","long")
                .endObject()

                .startObject("gatewayApiName")
                .field("type","keyword")
                .endObject()

                .startObject("serverIp")
                .field("type","keyword")
                .endObject()

                .startObject("remoteIp")
                .field("type","keyword")
                .endObject()

                .startObject("content")
                .field("type","keyword")
                .endObject()

                .endObject()
                .endObject();

        //3. 将settings和mappings封装到一个Request对象
        CreateIndexRequest request = new CreateIndexRequest(index)
                .settings(settings)
                .mapping(mappings);

        //4. 通过client对象去连接ES并执行创建索引
        CreateIndexResponse createIndexResponse = elasticsearchClient.indices().create(request, RequestOptions.DEFAULT);

        //5. 输出
        System.out.println(createIndexResponse);

        if(createIndexResponse!=null){
            return "success";
        }

        return null;
    }


    @Override
    public void addDoc(String json) throws Exception {

        IndexRequest request = new IndexRequest(index);
        request.source(json, XContentType.JSON);

        //3. 通过client对象执行添加
        IndexResponse resp = null;
        try {
            resp = elasticsearchClient.index(request, RequestOptions.DEFAULT);
        } catch (IOException e) {
            e.printStackTrace();
        }

        //4. 输出返回结果
        System.out.println(resp.toString());
    }

    @Override
    public TableData matchAllQuery() throws Exception {

        //1. 创建Request
        SearchRequest request = new SearchRequest(index);

        //2. 指定查询条件
        SearchSourceBuilder builder = new SearchSourceBuilder();
        builder.query(QueryBuilders.matchAllQuery());
        builder.size(20);// ES默认只查询10条数据,如果想查询更多,添加size
        request.source(builder);

        //3. 执行查询
        SearchResponse resp = elasticsearchClient.search(request, RequestOptions.DEFAULT);

        List<Map> list = new ArrayList<>();

        //4. 输出结果
        for (SearchHit hit : resp.getHits().getHits()) {
            //System.out.println(hit.getSourceAsMap());
            list.add(hit.getSourceAsMap());
        }

        int count = resp.getHits().getHits().length;

        System.out.println(count);

        return new TableData(count,list);
    }
}

十、 监控系统

我们的服务有很多状态需要监控,包括运行状态等等,其实监控就是不断获取数据来进行比较判断,在我们的开放平台中,我们以监控服务的平均运行时间为例子,对监控进行代码编写,使用的技术就是定时任务,需要解决的问题是我们的监控平台是集群,我们要解决分布式任务的问题,我们使用的技术是elastic-job,使用的注册中心是zookeeper

10.1 pom 依赖

核心依赖

    <dependencies>


        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            <exclusions>
                <exclusion>
                    <artifactId>guava</artifactId>
                    <groupId>com.google.guava</groupId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

<!--
分布式任务核心依赖

-->
        <dependency>
            <groupId>com.dangdang</groupId>
            <artifactId>elastic-job-lite-core</artifactId>
            <version>2.1.5</version>
<!--
我们使用的是 ZK 做锁,但是内部的依赖版本在此处不符合我们安装的 zk 版本,我们安装的 ZK 是 3.4.13 版本
-->
            <exclusions>
                <exclusion>
                    <artifactId>curator-framework</artifactId>
                    <groupId>org.apache.curator</groupId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>com.dangdang</groupId>
            <artifactId>elastic-job-lite-spring</artifactId>
            <version>2.1.5</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>


<!--
根据我们的 ZK 版本导入对一个的 curator,因为内置的zookeeper低于我们的 3.4.13 所以我们排除然后导入自己的版本

-->
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-framework</artifactId>
            <version>2.13.0</version>
            <exclusions>
                <exclusion>
                    <artifactId>zookeeper</artifactId>
                    <groupId>org.apache.zookeeper</groupId>
                </exclusion>
                <exclusion>
                    <artifactId>guava</artifactId>
                    <groupId>com.google.guava</groupId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>2.13.0</version>
        </dependency>
<!--
根据我们的 ZK 版本导入自己的依赖
-->

        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.4.13</version>
        </dependency>
<!--
邮件报警的依赖
-->
        <dependency>
            <groupId>javax.mail</groupId>
            <artifactId>mail</artifactId>
            <version>1.4.7</version>
        </dependency>
    </dependencies>

10.2 application.yml
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20000/eureka
  instance:
    prefer-ip-address: true
spring:
  application:
    name: openapi-monitor
#和分布式任务相关的配置,此处这些配置是我们自己声明的,在内部自己调用
elasticjob:
  corn: 0/10 * * * * ? #任务的表达式
  count: 1 #任务分片数量
  shardingparamters: null # 任务的分片标记
  regcenter: #注册中心的配置
    serverList: zookeeper.qfjava.cn:8601,zookeeper.qfjava.cn:8602,zookeeper.qfjava.cn:8603
    namespace: openapimonitor
server:
  port: 39000

10.3 核心配置类文件

分布式任务相关的配置

@Configuration
public class MonitorConfig {


    /**
     * 用作分布式锁的注册中心
     * @param serverList
     * @param nameSpace
     * @return
     */
    @Bean(initMethod = "init") //需要调用对象内部的初始化方法
    public ZookeeperRegistryCenter zookeeperRegistryCenter(@Value(("${elasticjob.regcenter.serverList}")) String serverList,@Value(("${elasticjob.regcenter.namespace}")) String nameSpace) {
        ZookeeperConfiguration configuration=new ZookeeperConfiguration(serverList,nameSpace);
        configuration.setConnectionTimeoutMilliseconds(10000);
        configuration.setMaxRetries(5);
        ZookeeperRegistryCenter zookeeperRegistryCenter=new ZookeeperRegistryCenter(configuration);
        return zookeeperRegistryCenter;
    }

    /**
     * 配置任务的触发器
     * @param corn
     * @param shardingTotalCount
     * @param shardingparamters
     * @return
     */

    @Bean
    public LiteJobConfiguration liteJobConfiguration(@Value(("${elasticjob.corn}")) String corn,@Value(("${elasticjob.count}")) int shardingTotalCount,@Value(("${elasticjob.shardingparamters}")) String shardingparamters) {

        JobCoreConfiguration coreConfiguration= JobCoreConfiguration.newBuilder(AvgJob.class.getName(),corn,shardingTotalCount).shardingItemParameters(shardingparamters).build();
        LiteJobConfiguration configuration = LiteJobConfiguration.newBuilder(new SimpleJobConfiguration(coreConfiguration, AvgJob.class.getCanonicalName())).overwrite(true).build();
        return configuration;
    }

    @Bean(initMethod = "init") //调度器
    public SpringJobScheduler springJobScheduler(AvgJob job, ZookeeperRegistryCenter registryCenter, LiteJobConfiguration configuration) {
        return new SpringJobScheduler(job, registryCenter, configuration);
    }
}

10.3 Job 任务

定时任务要做的事情

@Component
public class AvgJob  implements SimpleJob {
    @Autowired
    private ApplicationContext context;

    @Autowired
    private SearchService searchService;

    private SimpleDateFormat simpleDateFormate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    //每个服务的阈值其实可以保存在服务路由信息中,此处为了简化代码编写,我们使用本地写死
    private static Map<String, Integer> maxAvgTimeMap = new HashMap<>();

    static {
        maxAvgTimeMap.put("order.get", 10);
        maxAvgTimeMap.put("order.cancel", 90);

    }

    @Override
    public void execute(ShardingContext shardingContext) {

        //我们当前的任务是看看平均时间有没有超出阈值
        //阈值在哪?
        //我们的任务是每隔10秒执行一次,所以我们要做的事情是拿到当前时间,减去10秒,作为开始时间,然后去查询这个时间范围的平均值
        Instant now = Instant.now();
        //因为es所在的服务器不是中国时区,所以会有8小时的误差
        Date to = Date.from(now.plusSeconds(-3600*8));
        Date from = Date.from(now.plusSeconds(-3600 * 8 - 10));

        Map<String, Integer> apiCountAndAvg = searchService.statApiCountAndAvg(simpleDateFormate.format(from), simpleDateFormate.format(to));
        for (Map.Entry<String, Integer> entry : apiCountAndAvg.entrySet()) {
            String key = entry.getKey();//服务的名字
            Integer avg = entry.getValue();//平均时间
            Integer maxValue = maxAvgTimeMap.get(key);//获取我们当前服务允许的阈值

            if (avg < maxValue) {
                System.err.println("服务" + key + "没有超出阈值");

            }else{
                System.err.println("服务" + key + "超出阈值,阈值为" + maxValue + "  当前为:" + avg);
                //告警,通知开发或者维护人员,但是问题是我们的通知方式是什么,可能是邮件,可能是电话,可能是短信,可能是app,可能是以上几个同时
                EventBean eventBean = new EventBean();
                eventBean.setMsg("服务" + key + "超出阈值,阈值为" + maxValue + "  当前为:" + avg);
                eventBean.setEventType(EventType.OVERTIME);
                context.publishEvent(eventBean);

            }
        }

    }
}

10.4 发送邮件

此处我们使用的是发送邮件的方式来进行告警

@Component
public class MailEventListener {

    @EventListener
    public void onEvent(EventBean bean) throws Exception {
        //当前的处理方式是发送邮件
        //当前 listener 的主要作用是发送邮件给指定的人  5433708  sgcdmbyxwpglbiic
        //登陆账号,我们必须知道登录的是什么邮箱,用的什么账号

        switch (bean.getEventType()) {
            case OVERTIME:
                Properties properties = new Properties();
                properties.put("mail.host", "smtp.qq.com");//设置我们的服务器地址
                properties.put("mail.transport.protocol", "smtp");//设置邮箱发送的协议
                properties.put("mail.smtp.auth", "true");//设置需要认证
                MailSSLSocketFactory sf = new MailSSLSocketFactory();//创建 ssl 连接的工厂对象
                sf.setTrustAllHosts(true);//信任所有主机
                properties.put("mail.smtp.ssl.enable", "true");//设置开启 ssl
                properties.put("mail.smtp.ssl.socketFactory", sf);//设置对应的工厂对象

                Session session = Session.getDefaultInstance(properties, new Authenticator() {
                    @Override
                    protected PasswordAuthentication getPasswordAuthentication() {
                        return new PasswordAuthentication("xxxx@qq.com", "zprhlwcnhevnbhei"); //返回认证信息
                    }
                });//创建会话

                session.setDebug(true);//控制台会显示一些调试的日志
                Transport transport = session.getTransport();//类似于我们的 sql 中的 statement
                transport.connect("smtp.qq.com", "xxxx@qq.com", "zprhlwcnhevnbhei");//通过邮箱和提供的密码来进行登录
                Message message = new MimeMessage(session);//创建消息对象,也就是邮件内容对象

                message.setFrom(new InternetAddress("xxxx@qq.com"));//设置对方显示的来自于谁的邮件
                //设置收件人
                message.setRecipients(Message.RecipientType.TO, new InternetAddress[]{new InternetAddress("xxxxx@qq.com"), new InternetAddress("xxxxx@hotmail.com")});
//                message.setRecipient(Message.RecipientType.TO, new InternetAddress("xxxxx@qq.com"));//设置收件人
                message.setSubject("接口超时预警邮件");//标题
                message.setContent(bean.getMsg(), "text/html;charset=utf-8");//正文

                transport.sendMessage(message, message.getAllRecipients());//发送

                transport.close();
                break;


        }
    }
}

十一 、 微信支付


11.1 介绍

在我们的开放平台中,因为部分api 需要付费,所以用户需要充值,我们此处使用的方式是微信支付,其他支付方式都类似,基本流程就是按照微信要求 ,传递参数过去发起支付,就相当于我们的网关一样,别人要访问我们的网关就要按照我们的要求传递参数

11.2 开发流程

11.2.1 时序图
原生支付模式时序图
图片.png

11.2.2 业务流程
  1. 商户后台系统根据用户选购的商品生成订单。
  2. 用户确认支付后调用微信支付【统一下单API】生成预支付交易;
  3. 微信支付系统收到请求后生成预支付交易单,并返回交易会话的二维码链接code_url。
  4. 商户后台系统根据返回的code_url生成二维码。
  5. 用户打开微信“扫一扫”扫描二维码,微信客户端将扫码内容发送到微信支付系统。
  6. 微信支付系统收到客户端请求,验证链接有效性后发起用户支付,要求用户授权。
  7. 用户在微信客户端输入密码,确认支付后,微信客户端提交授权。
  8. 微信支付系统根据用户授权完成支付交易。
  9. 微信支付系统完成支付交易后给微信客户端返回交易结果,并将交易结果通过短信、微信消息提示用户。微信客户端展示支付交易结果页面。
  10. (微信支付系统通过发送异步消息通知商户后台系统支付结果。商户后台系统需回复接收情况,通知微信后台系统不再发送该单的支付通知。
  11. 未收到支付通知的情况,商户后台系统调用【查询订单API】。
  12. 商户确认订单已支付后给用户发货。

11.3 统一下单

通过统一下单地址向微信发起支付请求,并获取支付连接信息,然后生成二维码进行扫码支付,参数和返回值信息详情参考

统一下单API

11.4 签名规则

与我们的网关一样,客户调用我们的网关的时候需要传递签名,我们在发起支付请求的时候微信也会要求我们传递签名,因此我们需要在我们的程序中按照微信的要求生成签名传递过去,同样,客户在请求我们的网关的时候也需要在他们那边代码生成签名

微信支付的签名规则参考微信安全规范

11.5 核心代码

此处主要展示的是发起请求的核心工具类代码和生成二维码的核心代码

11.5.1 pom 主要依赖
 <!--解析 xml-->
    <dependency>
      <groupId>org.jdom</groupId>
      <artifactId>jdom</artifactId>
      <version>1.1</version>
    </dependency>
    <dependency>
      <groupId>jaxen</groupId>
      <artifactId>jaxen</artifactId>
      <version>1.1.6</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.google.zxing/core 
    用于生成二维码图片的依赖-->
    <dependency>
      <groupId>com.google.zxing</groupId>
      <artifactId>core</artifactId>
      <version>3.3.2</version>
    </dependency>
    <dependency>
      <groupId>com.google.zxing</groupId>
      <artifactId>javase</artifactId>
      <version>3.3.2</version>
    </dependency>

11.5.2 商户信息配置

配置和商户相关的信息

public class PayConfigUtil {
    public static String APP_ID = "wx632c8f211f8122c6";//我们的微信公众号 id
    public static String MCH_ID = "1497984412";//我们的商户 id
    public static String API_KEY = "sbNCm1JnevqI36LrEaxFwcaT0hkGxFnC";//我们的 API_KEY 用于生成签名
    public static String UFDOOER_URL = "https://api.mch.weixin.qq.com/pay/unifiedorder";//微信统一下单地址
    public static String NOTIFY_URL = "http://ceshi.qfjava.cn/payment/result";//我们的回调地址,用于接收微信告诉我们的支付结果
    public static String CREATE_IP = "114.242.26.51"; //发起请求的地址,可以写我们的服务器地址,也可以传递客户的 ip
}

11.5.3 签名工具类

此工具类是用于生成 MD5 签名的

public class MD5Util {
    /**
     * 编码,将字节数组转成可识别字符串
     * @param b
     * @return
     */
    private static String byteArrayToHexString(byte b[]) {
        StringBuffer resultSb = new StringBuffer();
        for (int i = 0; i < b.length; i++)
            resultSb.append(byteToHexString(b[i]));

        return resultSb.toString();
    }

    /**
     * 将自己转成可识别字符串
     * @param b
     * @return
     */
    private static String byteToHexString(byte b) {
        int n = b;
        if (n < 0)
            n += 256;
        int d1 = n / 16;
        int d2 = n % 16;
        return hexDigits[d1] + hexDigits[d2];
    }

    /**
     * 获取指定内容的 MD5值
     * @param origin 被转换的内容
     * @param charsetname 字符集
     * @return
     */
    public static String MD5Encode(String origin, String charsetname) {
        String resultString = null;
        try {
            resultString = new String(origin);
            MessageDigest md = MessageDigest.getInstance("MD5");
            if (charsetname == null || "".equals(charsetname))
                resultString = byteArrayToHexString(md.digest(resultString
                        .getBytes()));
            else
                resultString = byteArrayToHexString(md.digest(resultString
                        .getBytes(charsetname)));
        } catch (Exception exception) {
        }
        return resultString;
    }

    private static final String hexDigits[] = {"0", "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"
    };

    public static String UrlEncode(String src)  throws UnsupportedEncodingException {
        return URLEncoder.encode(src, "UTF-8").replace("+", "%20");
    }
}

11.5.4 网络请求工具类

此工具类主要是发起网络请求的,可以通过此工具类向微信统一下单地址发起请求

public class HttpUtil {
    private final static int CONNECT_TIMEOUT = 5000; // in milliseconds
    private final static String DEFAULT_ENCODING = "UTF-8";

    public static String postData(String urlStr, String data){
        return postData(urlStr, data, null);
    }

    public static String postData(String urlStr, String data, String contentType){
        BufferedReader reader = null;
        try {
            URL url = new URL(urlStr);
            URLConnection conn = url.openConnection();
            conn.setDoOutput(true);
            conn.setConnectTimeout(CONNECT_TIMEOUT);
            conn.setReadTimeout(CONNECT_TIMEOUT);
            if(contentType != null)
                conn.setRequestProperty("content-type", contentType);
            OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream(), DEFAULT_ENCODING);
            if(data == null)
                data = "";
            writer.write(data);
            writer.flush();
            writer.close();

            reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), DEFAULT_ENCODING));
            StringBuilder sb = new StringBuilder();
            String line = null;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
                sb.append("\r\n");
            }
            return sb.toString();
        } catch (IOException e) {
            System.err.println("Error connecting to " + urlStr + ": " + e.getMessage());
        } finally {
            try {
                if (reader != null)
                    reader.close();
            } catch (IOException e) {
            }
        }
        return null;
    }
}

11.5.5 XML 解析工具类

因为腾讯返回的是 XML 数据,因此我们需要解析 XML 数据

public class XMLUtil {
    /**
     * 解析xml,返回第一级元素键值对。如果第一级元素有子节点,则此节点的值是子节点的xml数据。
     * @param strxml
     * @return
     * @throws JDOMException
     * @throws IOException
     */
    public static Map doXMLParse(String strxml) throws JDOMException, IOException {
        strxml = strxml.replaceFirst("encoding=\".*\"", "encoding=\"UTF-8\"");

        if(null == strxml || "".equals(strxml)) {
            return null;
        }

        Map m = new HashMap();

        InputStream in = new ByteArrayInputStream(strxml.getBytes("UTF-8"));
        SAXBuilder builder = new SAXBuilder();
        Document doc = builder.build(in);
        Element root = doc.getRootElement();
        List list = root.getChildren();
        Iterator it = list.iterator();
        while(it.hasNext()) {
            Element e = (Element) it.next();
            String k = e.getName();
            String v = "";
            List children = e.getChildren();
            if(children.isEmpty()) {
                v = e.getTextNormalize();
            } else {
                v = XMLUtil.getChildrenText(children);
            }

            m.put(k, v);
        }

        //关闭流
        in.close();

        return m;
    }

    /**
     * 获取子结点的xml
     * @param children
     * @return String
     */
    public static String getChildrenText(List children) {
        StringBuffer sb = new StringBuffer();
        if(!children.isEmpty()) {
            Iterator it = children.iterator();
            while(it.hasNext()) {
                Element e = (Element) it.next();
                String name = e.getName();
                String value = e.getTextNormalize();
                List list = e.getChildren();
                sb.append("<" + name + ">");
                if(!list.isEmpty()) {
                    sb.append(XMLUtil.getChildrenText(list));
                }
                sb.append(value);
                sb.append("</" + name + ">");
            }
        }

        return sb.toString();
    }
}

11.5.6 统一调用工具类

此工具类主要是用于校验腾讯返回给我们的支付结果信息以及将上面工具类封装为一个统一请求的方法在内部调用

public class PayCommonUtil {
    /**
     * 用于校验腾讯给我们返回的支付结果数据是否正确,规则是:按参数名称a-z排序,遇到空值的参数不参加签名。
     * @return boolean
     */
    public static boolean isTenpaySign(String characterEncoding, SortedMap<Object, Object> packageParams, String API_KEY) {
        StringBuffer sb = new StringBuffer();
        Set es = packageParams.entrySet();
        Iterator it = es.iterator();
        while(it.hasNext()) {
            Map.Entry entry = (Map.Entry)it.next();
            String k = (String)entry.getKey();
            String v = (String)entry.getValue();
            if(!"sign".equals(k) && null != v && !"".equals(v)) {
                sb.append(k + "=" + v + "&");
            }
        }

        sb.append("key=" + API_KEY);

        //算出摘要
        String mysign = MD5Util.MD5Encode(sb.toString(), characterEncoding).toLowerCase();
        String tenpaySign = ((String)packageParams.get("sign")).toLowerCase();

        //System.out.println(tenpaySign + "    " + mysign);
        return tenpaySign.equals(mysign);
    }

    /**
     * @Description:sign签名,生成签名的,用于向腾讯发送签名和生成腾信返回数据的校验签名
     * @param characterEncoding
     *            编码格式
     *            请求参数
     * @return
     */
    public static String createSign(String characterEncoding, SortedMap<Object, Object> packageParams, String API_KEY) {
        StringBuffer sb = new StringBuffer();
        Set es = packageParams.entrySet();
        Iterator it = es.iterator();
        while (it.hasNext()) {
            Map.Entry entry = (Map.Entry) it.next();
            String k = (String) entry.getKey();
            String v = (String) entry.getValue();
            if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) {
                sb.append(k + "=" + v + "&");
            }
        }
        sb.append("key=" + API_KEY);
        String sign = MD5Util.MD5Encode(sb.toString(), characterEncoding).toUpperCase();
        return sign;
    }

    /**
     * @Description:将请求参数转换为xml格式的string,然后传递到微信服务器
     * @param parameters
     *            请求参数
     * @return
     */
    public static String getRequestXml(SortedMap<Object, Object> parameters) {
        StringBuffer sb = new StringBuffer();
        sb.append("<xml>");
        Set es = parameters.entrySet();
        Iterator it = es.iterator();
        while (it.hasNext()) {
            Map.Entry entry = (Map.Entry) it.next();
            String k = (String) entry.getKey();
            String v = (String) entry.getValue();
            if ("attach".equalsIgnoreCase(k) || "body".equalsIgnoreCase(k) || "sign".equalsIgnoreCase(k)) {
                sb.append("<" + k + ">" + "<![CDATA[" + v + "]]></" + k + ">");
            } else {
                sb.append("<" + k + ">" + v + "</" + k + ">");
            }
        }
        sb.append("</xml>");
        return sb.toString();
    }

    /**
     * 取出一个指定长度大小的随机正整数.
     *
     * @param length
     *            int 设定所取出随机数的长度。length小于11
     * @return int 返回生成的随机数。
     */
    public static int buildRandom(int length) {
        int num = 1;
        double random = Math.random();
        if (random < 0.1) {
            random = random + 0.1;
        }
        for (int i = 0; i < length; i++) {
            num = num * 10;
        }
        return (int) ((random * num));
    }

    /**
     * 获取当前时间 yyyyMMddHHmmss
     *
     * @return String
     */
    public static String getCurrTime() {
        Date now = new Date();
        SimpleDateFormat outFormat = new SimpleDateFormat("yyyyMMddHHmmss");
        String s = outFormat.format(now);
        return s;
    }

    /**
     * 统一下单,获取二维码字符串,最终,通过调用此方法就可以下单
     * @param order_price 价格
     * @param body 商品描述
     * @param out_trade_no 订单号
     * @return
     * @throws Exception
     */
    public static String weixin_pay( String order_price,String body,String out_trade_no) throws Exception {
        // 账号信息
        String appid = PayConfigUtil.APP_ID;  // appid
        //String appsecret = PayConfigUtil.APP_SECRET; // appsecret
        String mch_id = PayConfigUtil.MCH_ID; // 商业号
        String key = PayConfigUtil.API_KEY; // key

        String currTime = PayCommonUtil.getCurrTime();
        String strTime = currTime.substring(8, currTime.length());
        String strRandom = PayCommonUtil.buildRandom(4) + "";
        String nonce_str = strTime + strRandom;

       /* String order_price = "1"; // 价格   注意:价格的单位是分
        String body = "goodssssss";   // 商品名称
        String out_trade_no = "11111338"; // 订单号*/

        // 获取发起电脑 ip
        String spbill_create_ip = PayConfigUtil.CREATE_IP;
        // 回调接口
        String notify_url = PayConfigUtil.NOTIFY_URL;
        String trade_type = "NATIVE";

        SortedMap<Object,Object> packageParams = new TreeMap<Object,Object>();
        packageParams.put("appid", appid);
        packageParams.put("mch_id", mch_id);
        packageParams.put("nonce_str", nonce_str);
        packageParams.put("body", body);
        packageParams.put("out_trade_no", out_trade_no);
        packageParams.put("total_fee", order_price);
        packageParams.put("spbill_create_ip", spbill_create_ip);
        packageParams.put("notify_url", notify_url);
        packageParams.put("trade_type", trade_type);

        String sign = PayCommonUtil.createSign("UTF-8", packageParams,key);
        packageParams.put("sign", sign);

        String requestXML = PayCommonUtil.getRequestXml(packageParams);
        System.out.println(requestXML);

        String resXml = HttpUtil.postData(PayConfigUtil.UFDOOER_URL, requestXML);

        System.out.println(resXml);
        Map map = XMLUtil.doXMLParse(resXml);
        //String return_code = (String) map.get("return_code");
        //String prepay_id = (String) map.get("prepay_id");

        String urlCode = (String) map.get("code_url");

        return urlCode; //返回下单的 url 地址用于生成二维码
    }
}

11.5.7 二维码生成工具

二维码其实就是将一段字符串通过特定的方式转成一张图片,此处我们使用的是 google 提供的 ZXing 来进行处理

public class ZxingUtil {
    /**
     * Zxing图形码生成工具
     *
     * @param contents
     *            内容
     * @param format
     *            图片格式,可选[png,jpg,bmp]
     * @param width
     *            宽
     * @param height
     *            高
     * @param saveImgFilePath
     *            存储图片的完整位置,包含文件名
     * @return
     */
    public static Boolean encode(String contents, String format, int width, int height, String saveImgFilePath) {
        Boolean bool = false;
        BufferedImage image = createImage(contents,width,height);
        if (image != null) {
            bool = writeToFile(image, format, saveImgFilePath);
        }
        return bool;
    }

    public static void encode(String contents, int width, int height) {
        createImage(contents,width, height);
    }

    public static BufferedImage createImage(String contents ,int width, int height) {
        BufferedImage bufImg=null;
        Map<EncodeHintType, Object> hints = new HashMap<EncodeHintType, Object>();
        // 指定纠错等级
        hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
        hints.put(EncodeHintType.MARGIN, 10);
        hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
        try {
            // contents = new String(contents.getBytes("UTF-8"), "ISO-8859-1");
            BitMatrix bitMatrix = new MultiFormatWriter().encode(contents, BarcodeFormat.QR_CODE, width, height, hints);
            MatrixToImageConfig config = new MatrixToImageConfig(0xFF000001, 0xFFFFFFFF);
            bufImg = MatrixToImageWriter.toBufferedImage(bitMatrix, config);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return bufImg;
    }


    /**
     * 将BufferedImage对象写入文件
     *
     * @param bufImg
     *            BufferedImage对象
     * @param format
     *            图片格式,可选[png,jpg,bmp]
     * @param saveImgFilePath
     *            存储图片的完整位置,包含文件名
     * @return
     */
    @SuppressWarnings("finally")
    public static Boolean writeToFile(BufferedImage bufImg, String format, String saveImgFilePath) {
        Boolean bool = false;
        try {
            bool = ImageIO.write(bufImg, format, new File(saveImgFilePath));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            return bool;
        }
    }

}

11.6 其他内容

其他内容就是编写页面,将需要的商品信息价格等拿到然后发起支付,另外再写一个用于接收微信返回结果的 servlet 处理结果,并将最终处理结果返回给微信,要想收支付结果必须将程序运行到线上服务器,返回结果格式参考支付结果通知

十二、 WebSocket


  • 当我们扫码完成后,可能会需要跳转页面,因此页面必须知道支付结果,在我们的生活中还有其他的场景如扫码登陆等都是页面需要知道结果的
  • 页面知道结果的方式主要是两种:第一种页面不断轮训服务器获取结果.第二种服务器知道结果后主动通知页面
  • 主动通知页面的技术我们选择使用 WebSocket,通过和服务器的长连接会话技术来让服务器主动通知页面

12.1 WebSocket 服务端搭建

所谓的长连接就是和服务器建立一个连接,所以我们需要一个服务端程序并指定连接地址,由于页面跳转只是 Websocket 能实现的功能之一,并不是全部,所以我们将 Websocket单独抽离出来作为一个服务端,当有其他功能需要的时候可以直接访问

12.1.1 pom 主要依赖

我们使用的是 springboot 方式搭建

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.springframework/spring-websocket 
        spring 整合websocket
        -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
        </dependency>

12.1.2 Spring拦截器

本拦截器的主要功能是拦截请求后将我们需要的数据进行获取保存,方便后面使用

/**
 *
 * WebSocket握手请求的拦截器. 检查握手请求和响应, 对WebSocketHandler传递属性
 */
@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    /**
     * 在握手之前执行该方法, 继续握手返回true, 中断握手返回false. 通过attributes参数设置WebSocketSession的属性
     * @param request
     * @param response
     * @param wsHandler
     * @param attributes
     * @return
     * @throws Exception
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) throws Exception {
        //为了方便区分来源以及在后面的时候方便找到会话主动向客户端发送消息,我们需要区分每个会话并保存,在此以用户的名字来区分,名字我们通过要求用输入进行传递,所以在这里先从请求中获取到用户输入的名字,因为是使用的rest 风格,所以规定路径的最后一个字符串是名字
        System.out.println("握手之前");
        String s = request.getURI().toString();
        String s1 = s.substring(s.lastIndexOf("/") + 1);
        attributes.put("username", s1);//给当前连接设置属性,其实就是为了保存我们用于区分连接唯一性的参数,username 这个属性可以随便改,只要唯一即可

        return super.beforeHandshake(request, response, wsHandler, attributes);
    }

    /**
     * 在握手之后执行该方法. 无论是否握手成功都指明了响应状态码和相应头.
     * @param request
     * @param response
     * @param wsHandler
     * @param ex
     */
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                               Exception ex) {
        System.out.println("After Handshake");
        super.afterHandshake(request, response, wsHandler, ex);
    }

}

10.2.3 消息处理类

本类主要是对消息进行处理,如收到客户端发送的消息,由于我们的目标是主动向客户端发消息,不需要客户端向服务端发消息,所以本类的主要作用是保存请求的连接

/**
 * 文本消息的处理器
 */
@Component
public class ChatMessageHandler extends TextWebSocketHandler {

    private static final Map<String,WebSocketSession> allClients;//用于缓存所有的用户和连接之间的关系

    static {
        allClients = new ConcurrentHashMap();//初始化连接
    }

    /**
     * 当和用户成功建立连接的时候会调用此方法,在此方法内部应该保存连接
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("建立连接成功");
        String name = (String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME);//将在拦截器中保存的用户的名字取出来,然后作为 key 存到 map 中
        if (name != null) {
            allClients.put(name, session);//保存当前的连接和用户之间的关系
        }
        // 这块会实现自己业务,比如,当用户登录后,会把离线消息推送给用户

    }

    /**
     * 收到消息的时候会触发该方法,此处是模拟处理消息,比如要求用户传递 json 数据,然后解析
     * 注意服务端收到用户消息到底是做什么的,需要根据我们的业务来处理,比如就是单纯收客户端数据的,或者可能是在线客服聊天的,在线客服聊天这种一般会要求页面传递发送者和接收者是谁等相关信息,具体怎么传递怎么解析业务具体自由处理
     * @param session 发送消息的用户的 session
     * @param message  发送的内容
     * @throws Exception
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //此处请根据自己的具体业务逻辑做处理
        JSONObject jsonObject= JSONObject.fromObject(new String(message.asBytes()));//将用户发送的消息转换为 json,实际开发中请根据自己的需求处理
        String toName = jsonObject.getString("toName");//获取数据中的收消息人的名字
        String content = jsonObject.getString("content");//获取到发送的内容
        String fromName = (String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME);//获取当前发送消息的人的名字
        content = "收到来自:" +fromName+ "的消息,内容是:" + content;
        //拼接内容转发给接收者,实际开发中请参考自己的需求做处理
        TextMessage textMessage = new TextMessage(content);//将内容转换为 TextMessage
        sendMessageToUser(toName,textMessage);// 发送给指定的用户
        //sendMessageToUsers(message);//给所有人发送
        //super.handleTextMessage(session, message);
    }

    /**
     * 给某个用户发送消息,自己封装的方法,username 就是连接的标识
     *
     * @param userName
     * @param message
     */
    private static void sendMessageToUser(String userName, TextMessage message) {
        WebSocketSession webSocketSession = allClients.get(userName);//根据接收方的名字找到对应的连接
        if (webSocketSession != null&& webSocketSession.isOpen()) {//如果没有离线,如果离线,请根据实际业务需求来处理,可能会需要保存离线消息
            try {
                webSocketSession.sendMessage(message);//发送消息
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
  /**
     * 给某个用户发送消息,自己封装的方法,username 就是连接的标识
     *
     * @param userName
     * @param message
     */
    private static void sendMessageToUser(String userName, String content) {
        TextMessage textMessage = new TextMessage(content);//将内容转换为 TextMessage
           sendMessageToUser(userName,  message) 
    }
    /**
     * 给所有在线用户发送消息,此处以文本消息为例子
     *
     * @param message
     */
    public static void sendMessageToUsers(TextMessage message) {
        for (Map.Entry<String, WebSocketSession> webSocketSessionEntry : allClients.entrySet()) {//获取所有的连接

            WebSocketSession session = webSocketSessionEntry.getValue();//找到每个连接
            if (session != null&& session.isOpen()) {
                try {
                    session.sendMessage(message);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * 出现异常的时候
     * @param session
     * @param exception
     * @throws Exception
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        String name = (String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME);
        if (session.isOpen()) {
            session.close();
        }
        logger.debug("连接关闭");
        allClients.remove(name);//移除连接
    }

    /**
     * 连接关闭后
     * @param session
     * @param closeStatus
     * @throws Exception
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        logger.debug("连接关闭");
        String name = (String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME);//找到用户对应的连接
        allClients.remove(name);//移除
    }
         /**
     * 必须保证消息完整性,不完整的消息不接受,返回 true 接收
     */
    @Override
    public boolean supportsPartialMessages() {
        return false;
    }

}

10.2.4 WebSocket 配置类

主要是配置我们的连接地址,以及将拦截器和处理器配置上

@Configuration //声明为配置文件
@EnableWebSocket//启用 websocket
public class WebSocketConfig implements WebSocketConfigurer {
  @Autowired
  private ChatHandshakeInterceptor chatHandshakeInterceptor;
  @Autowired
  private TextWebSocketHandler textWebSocketHandler;
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
        System.out.println("初始化路径拦截");//指定所有/websocket开头的路径会被 websocket 拦截,设置处理器和拦截器,*代表唯一表示,注意这是我们自己定义的规则
       webSocketHandlerRegistry.addHandler(textWebSocketHandler,"/websocket/*").addInterceptors(chatHandshakeInterceptor);
    }

}

10.2.5 测试 html

本 html 是测试 websocket 的,核心代码主要是 websocket 的相关操作回调, 只需要根据具体业务修改具体操作即可

比如我们的微信支付的付款页面,只需要和 websocket 建立连接并处理返回消息即可,别的不需要

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script type="text/javascript">
        var websocket = null;
        function abc() {

            //var username = localStorage.getItem("name");
            var username=document.getElementById("me").value;
            //判断当前浏览器是否支持WebSocket
            if ('WebSocket' in window) {
              //和 websocket 服务器建立连接,此处根据实际情况填写具体的地址
                websocket = new WebSocket("ws://" + document.location.host + "/websocket/"+username);
            } else {
                alert('当前浏览器 Not support websocket')
            }

            //连接发生错误的回调方法
            websocket.onerror = function() {
                setMessageInnerHTML("WebSocket连接发生错误");
            };

            //连接成功建立的回调方法
            websocket.onopen = function() {
                setMessageInnerHTML("WebSocket连接成功");
            }

            //接收到消息的回调方法
            websocket.onmessage = function(event) {
                setMessageInnerHTML(event.data);
            }

            //连接关闭的回调方法
            websocket.onclose = function() {
                setMessageInnerHTML("WebSocket连接关闭");
            }

            //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
            window.onbeforeunload = function() {
                closeWebSocket();
            }
        }

        /**
         * 发送消息,此处是测试消息代码,和支付没关系
         */
        function sendmessage() {
            var toName=document.getElementById("to").value;
            if (websocket!=null) {
                var content=document.getElementById("content").value;

                var message='{"toName":"'+toName+'","content":"'+content+'"}';//将发送的内容拼接为 json 字符串,服务端用于解析好处理
                websocket.send(message);
            }
        }

        //关闭WebSocket连接
        function closeWebSocket() {
            if (websocket!=null) {

                websocket.close();
            }
        }
        function setMessageInnerHTML(data) {
            document.getElementById("neirong").innerHTML = data;
        }
    </script>
</head>
<body>
用户名:<input type="text" id="me" /> <button onclick="abc()"> 连接</button><br>
<!--实际接收者应该由用户选择,或者由系统安排,比如客服的话,应该是服务端已经存储了所有在线的客服,用户只需要发送消息即可,如果是两个用户聊天,则应该有用户列表,选择后指定目标-->
接收者:<input type="text" id="to" /><br>
内容:<input type="text" id="content" /><br>
<button onclick="sendmessage()">发送</button><br>
<br>
<br>
<br>
<span id="neirong"></span>
</body>
</html>

12.2 整合 MQ

由于我们的 websocket 是独立的服务端,我们的支付系统的页面链接到我们的 websocket,我们通过 websocket 通知页面结果,但是 websocket 并不知道支付结果,我们需要在支付系统中将结果通知 websocket,因为可能还会有其他地方需要知道支付结果,比如需要要给用户加钱等操作,所以我们在支付完成后发送 MQ 消息来通知所有的消费者成功,这样我们的 websocket 就拿到结果了,就可以通知页面了

12.2.1 websocket 中 mq 核心代码
@Component
public class PaymentMQListener {
    @Autowired
    private ObjectMapper objectMapper;

    @StreamListener("paysuccessexchange")
    public void onMessage(String payJson) {

      try {
          System.err.println("websocket收到来自支付的消息");

          Map map = objectMapper.readValue(payJson, Map.class);
          String oid = (String) map.get("oid");

        ChatMessageHandler.sendMessageToUser(oid,"1");//此处应该发送类似于 json 格式的数据,交给前端解析,我们模拟发个 1,1 代表成功

      }catch (Exception e){
         e.printStackTrace();
      }
    }

}