文章目录
-
-
- 零、 写在前面
- 一、 工程pom文件
- 二 、日志文件(logback-spring.xml)
- 三、 多配置文件
- 四、 注册中心(openapi-eureka)
- 五、 统一配置中心(openapi-configserver)
- 六、 缓存服务(openapi-cache)
- 七、 运营管理平台(openapi-web-master)
- 八、 网关
-
零、 写在前面
这里主要是写一些我们的约定
约定名 | 约定值 |
---|---|
工程groupid | com.qianfeng |
工程artifactId | openplatform |
module模块名 | openapi-xxx xxx代表模块名 |
包名 | com.qianfeng.openplatform.模块名.具体细分 |
一、 工程pom文件
为了授课编写代码的便捷性,我们的项目采用一个Project,多个module的方式,所以我们会在项目的最外层添加我们的Springboot和Spring Cloud配置
<packaging>pom</packaging>
<!--
SpringBoot 父依赖
-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
</parent>
<!--
SpringCloud工具集
-->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR5</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
二 、日志文件(logback-spring.xml)
我们的日志使用的是Springboot自带的logback,下面为logback的配置文件,我们按照模块区分日志,主要就是保存位置的区别
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="6000000" debug="false">
<property name="LOG_PATTERN" value="%date{HH:mm:ss.SSS} %-5p [%t:%c{1}:%L] - %msg%n"/>
<!--
将日志文件的保存位置修改为自定义的路径,当前的配置是在项目的目录中新建一个logs目录,在logs中创建具体的模块的日志目录.
-->
<property name="LOG_PATH" value="./logs/config/"/>
<!-- 系统级配置文件 开始 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
</appender>
<!-- stdout -->
<appender name="rootstdout"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_PATH}rootstdout.log</File>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<FileNamePattern>${LOG_PATH}rootstdout.%i.log.zip</FileNamePattern>
<MinIndex>1</MinIndex>
<MaxIndex>20</MaxIndex>
</rollingPolicy>
<triggeringPolicy
class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>10MB</MaxFileSize>
</triggeringPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
</appender>
<!-- debug -->
<appender name="rootDebug" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_PATH}root-debug.log</File>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<FileNamePattern>${LOG_PATH}root-debug.%i.log.zip</FileNamePattern>
<MinIndex>1</MinIndex>
<MaxIndex>10</MaxIndex>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>10MB</MaxFileSize>
</triggeringPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- info -->
<appender name="rootInfo" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_PATH}root-info.log</File>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<FileNamePattern>${LOG_PATH}root-info.%i.log.zip</FileNamePattern>
<MinIndex>1</MinIndex>
<MaxIndex>10</MaxIndex>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>10MB</MaxFileSize>
</triggeringPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- warn -->
<appender name="rootWarn" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_PATH}root-warn.log</File>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<FileNamePattern>${LOG_PATH}root-warn.%i.log.zip</FileNamePattern>
<MinIndex>1</MinIndex>
<MaxIndex>10</MaxIndex>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>10MB</MaxFileSize>
</triggeringPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- error -->
<appender name="rootError" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>${LOG_PATH}root-error.log</File>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<FileNamePattern>${LOG_PATH}root-error.%d{yyyy-MM-dd}.log</FileNamePattern>
<MaxHistory>30</MaxHistory>
</rollingPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>${LOG_PATTERN}</Pattern>
</layout>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>Error</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<springProfile name="local">
<root level="info">
<!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
<appender-ref ref="STDOUT"/>
<appender-ref ref="rootstdout"/>
<appender-ref ref="rootDebug"/>
<appender-ref ref="rootInfo"/>
<appender-ref ref="rootWarn"/>
<appender-ref ref="rootError"/>
</root>
</springProfile>
<springProfile name="dev">
<root level="info">
<!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
<appender-ref ref="rootstdout"/>
<appender-ref ref="rootDebug"/>
<appender-ref ref="rootInfo"/>
<appender-ref ref="rootWarn"/>
<appender-ref ref="rootError"/>
</root>
</springProfile>
<springProfile name="prod">
<root level="info">
<!-- 本地测试时使用,将日志打印到控制台,实际部署时请注释掉 -->
<appender-ref ref="rootstdout"/>
<appender-ref ref="rootDebug"/>
<appender-ref ref="rootInfo"/>
<appender-ref ref="rootWarn"/>
<appender-ref ref="rootError"/>
</root>
</springProfile>
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<jmxConfigurator/>
</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,然后启动程序即可
选择使用的配置文件 |
---|
![]() |
四、 注册中心(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主配置文件
application.yml文件内容一样
server:
port: 12000
spring:
application:
name: openapi-configserver #我们的程序在eureka中的名字
#配置我们保存配置文件的位置
cloud:
config:
server:
git:
uri: https://gitee.com/chenzetao666/{
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中,但是我们程序启动起来初始化对象的时候必须有服务器地址了
#当前配置文件的优先级是最高的,在 spring 初始化对象之前就会先加载这个配置文件,所以我们把从git 上面加载配置文件的过程写入到这里面
#来保证我们的程序在初始化对象之前就把服务器信息加载回来了,这样才能保证后续的初始化不会出现错误
spring:
application:
name: openapi-cache
#需要告诉我们的程序,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 我们要加载哪个后缀的配置文件
# name: 如果不设置值,则使用 spring.application.name 的值
profiles:
active: '@profileActive@'
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
在gitee中创建openapi-cache仓库
6.4 Redis配置类
用来配置我们redis数据的缓存方式
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
//这个是 key 的生成方式,指的是我们放的 key 可以做具体的区分,不重要
@Override
@Bean
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(param.toString());
}
return stringBuilder.toString();
};
}
//,不重要,指的是我们缓存的写入方式
@Bean
public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) {
//以锁写入的方式创建我们的写入对象
RedisCacheWriter writer = RedisCacheWriter.lockingRedisCacheWriter(connectionFactory);
//创建默认的缓存配置对象
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();
//根据我们的默认配置和写入方式创建缓存的管理器
RedisCacheManager manager = new RedisCacheManager(writer, cacheConfiguration);
return manager;
}
@Bean//创建我们的模板对象
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);//设置要连接的 redis
//设置 key 的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();//创建一个字符串的序列化方式,所有的数据会被序列为字符串
redisTemplate.setKeySerializer(stringRedisSerializer);//设置 key 的序列化方式为字符串
redisTemplate.setHashKeySerializer(stringRedisSerializer);//设置 hash 的 key 序列化方式为 string
//我们的 value 使用什么方式来进行序列化,因为 value是任意的对象,所以我们使用 json
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);//创建一个解析为 json 的序列化对象
ObjectMapper objectMapper = new ObjectMapper();//因为 jackson 中是使用ObjectMapper来进行序列化的,所以我们需要设置给ObjectMapper
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);//设置所有非 final 修饰的变量都可以被序列化
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);//指定我们的 objectmapper
//设置 value 的序列化方式
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);//设置值的序列化方式为 json
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
6.5 核心操作
redis中的一些核心操作
public interface CacheService {
/**
* 保存数据到 redis
* @param key
* @param value
* @param expireTime
* @return
* @throws Exception
*/
boolean save2Redis(String key, String value, long expireTime) throws Exception;
/**
* 从 redis 中获取数据
* @param key
* @return
* @throws Exception
*/
String getFromRedis(String key) throws Exception;
/**
* 删除一个 key
* @param key
* @return
* @throws Exception
*/
boolean deleteKey(String key) throws Exception;
/**
* 给指定的 key 设置过期时间
* @param key
* @param expireTime
* @return
* @throws Exception
*/
boolean expire(String key, long expireTime) throws Exception;
/**
* 获取自增 id
* @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;
/**
* 向 set 中添加数据
* @param key
* @param value
* @return
* @throws Exception
*/
Long sAdd(String key, String value) throws Exception;
/**
* 批量向 set 中添加数据
* @param key
* @param value
* @return
* @throws Exception
*/
Long sAdd(String key, String[] value) throws Exception;
/**
* 删除指定数据
* @param key
* @param value
* @return
* @throws Exception
*/
Long sRemove(String key, String value) throws Exception ;
/**
* 向hash中存放某个数据
* @param key
* @param field
* @param value
* @throws Exception
*/
void hSet(String key, String field, String value) throws Exception;
/**
* 从hash中获取指定field的数据
* @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;
/**
* setnx操作
* @param key
* @param value
* @param expireSecond
* @return
* @throws Exception
*/
boolean setNX(String key, String value, long expireSecond) throws Exception;
/**
* 批量添加
* @param key
* @param map
* @return
* @throws Exception
*/
boolean hMset(String key, Map<String,Object> map) throws Exception;
/**
* 查找符合表达式的 key keys*
* @param partten
* @return
* @throws Exception
*/
Set<String> findKeyByPartten(String partten) throws Exception;
/**
* redis中hash数据的自增操作
* @param key
* @param field
* @param delta 自增步长
* @return
* @throws Exception
*/
Long hIncrement(String key, String field, long delta) throws Exception;
}
6.6 Service实现类
package com.alan.openplatform.cache.service.impl;
import com.alan.openplatform.cache.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;
/**
* @Author Alan Ture
* @Description
*/
@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){
expireTime = Math.abs(expireTime);//小于-1,则取绝对值
}
redisTemplate.opsForValue().set(key,value);
if(expireTime>0){
redisTemplate.expire(key,expireTime, TimeUnit.SECONDS);//时间为秒
}
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){
expireTime = Math.abs(expireTime);//小于-1,则取绝对值
}
if(expireTime>0){
return redisTemplate.expire(key,expireTime, TimeUnit.SECONDS);//时间为秒
}else{
//设置为-1的情况
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 value) throws Exception {
return redisTemplate.opsForSet().add(key,value);
}
@Override
public Long sAdd(String key, String[] value) throws Exception {
return redisTemplate.opsForSet().add(key,value);
}
@Override
public Long sRemove(String key, String value) throws Exception {
return redisTemplate.opsForSet().remove(key,value);
}
@Override
public void hSet(String key, String field, String value) throws Exception {
redisTemplate.opsForHash().put(key,field,value);
}
@Override
public String hGet(String key, String field) throws Exception {
return (String) redisTemplate.opsForHash().get(key,field);
}
@Override
public Map<Object, Object> hGetAll(String key) throws Exception {
return redisTemplate.opsForHash().entries(key);
}
@Override
public boolean setNX(String key, String value, long expireTime) throws Exception {
if(expireTime==0){
//按我们自己的要求,如果是0,那就设置为-1
expireTime = -1;
}else if(expireTime<-1){
expireTime = Math.abs(expireTime);//小于-1,则取绝对值
}
redisTemplate.opsForValue().setIfAbsent(key,value);
if(expireTime>0){
redisTemplate.expire(key,expireTime, TimeUnit.SECONDS);//时间为毫秒
}
return true;
}
@Override
public boolean hMset(String key, Map<String, Object> map) throws Exception {
redisTemplate.opsForHash().putAll(key,map);
return true;
}
@Override
public Set<String> findKeyByPartten(String pattern) throws Exception {
return redisTemplate.keys(pattern);
}
@Override
public Long hIncrement(String key, String field, long delta) throws Exception {
return redisTemplate.opsForHash().increment(key,field,delta);
}
}
6.7 CacheController的实现
@RestController
@RequestMapping("/cache")
public class CacheController {
@Autowired
private CacheService cacheService;
@PostMapping("/set/{key}/{value}/{expireTime}")
public boolean set2redis(@PathVariable String key, @PathVariable String value, @PathVariable long expireTime) throws Exception {
return cacheService.save2Redis(key,value,expireTime);
}
@GetMapping("/get/{key}")
public String getFromRedis(@PathVariable String key) throws Exception {
return cacheService.getFromRedis(key);
}
@PostMapping("/delete/{key}")
public boolean deleteKey(@PathVariable String key) throws Exception {
return cacheService.deleteKey(key);
}
@PostMapping("/expire/{key}/{expireTime}")
public boolean expire(@PathVariable String key,@PathVariable long expireTime) throws Exception {
return cacheService.expire(key,expireTime);
}
@GetMapping("/getIncrement/{key}")
public Long getIncrementId(@PathVariable String key) throws Exception {
return cacheService.getAutoIncrementId(key);
}
@PostMapping("/smembers/{key}")
public Set<Object> sMembers(@PathVariable String key) throws Exception {
return cacheService.sMembers(key);
}
@PostMapping("/sadd/{key}/{value}")
public Long sAdd(@PathVariable String key,@PathVariable String value) throws Exception {
return cacheService.sAdd(key,value);
}
@PostMapping("/sadds/{key}")
public Long sAdd(@PathVariable String key, String[] value) throws Exception {
return cacheService.sAdd(key,value);
}
@PostMapping("/sremove/{key}/{value}")
public long sRemove(@PathVariable String key, @PathVariable String value) throws Exception {
return cacheService.sRemove(key,value);
}
@PostMapping("/hset/{key}/{field}/{value}")
public void hSet(@PathVariable String key,@PathVariable String field,@PathVariable String value) throws Exception {
cacheService.hSet(key,field,value);
}
@GetMapping("/hget/{key}/{field}")
public String hget(@PathVariable String key,@PathVariable String field) throws Exception {
return cacheService.hGet(key,field);
}
@GetMapping("/hgetall/{key}")
public Map<Object, Object> hGetAll(@PathVariable String key) throws Exception {
return cacheService.hGetAll(key);
}
@PostMapping("/setnx/{key}/{value}/{expireTime}")
public boolean setNx(@PathVariable String key,@PathVariable String value,@PathVariable long expireTime) throws Exception {
return cacheService.setNX(key,value,expireTime);
}
@PostMapping("/hmset/{key}")
public boolean hMset(@PathVariable String key,@RequestBody Map<String, Object> map) throws Exception {
return cacheService.hMset(key,map);
}
@GetMapping("/keys/{pattern}")
public Set<String> selectByPattern(@PathVariable String pattern) throws Exception {
return cacheService.findKeyByPartten(pattern);
}
@GetMapping("/hincrement/{key}/{field}/{delta}")
public Long hIncrement(@PathVariable String key, @PathVariable String field,@PathVariable long delta) throws Exception {
return hIncrement(key,field,delta);
}
}
6.7 服务降级
我们的服务需要包含降级操作,当程序内部出现问题的时候快速失败,返回数据给调用者,因此注意,我们的controller代码中需要配置hystrix
private static Logger logger = LoggerFactory.getLogger(CacheController.class);
@PostMapping("/set/{key}/{value}/{expireTime}")
@HystrixCommand(fallbackMethod = "set2redisFallback") //降级处理
public boolean set2redis(@PathVariable String key, @PathVariable String value, @PathVariable long expireTime) throws Exception {
RedisUtils.checkNull(key);
RedisUtils.checkNull(value);
return cacheService.set2redis(key,value,expireTime);
}
@GetMapping("/get/{key}")
@HystrixCommand(fallbackMethod = "getFromRedisFallback")
public String getFromRedis(@PathVariable String key) throws Exception {
RedisUtils.checkNull(key);
return cacheService.getFromRedis(key);
}
//set2redis方法的降级方法
public boolean set2redisFallback(String key,String value,long expireTime) throws Exception {
logger.error("set2redis方法出现出错了!{},{}",key,value);
return false;
}
//getFromRedis方法的降级方法
public String getFromRedisFallback(String key) throws Exception {
logger.error("getFromRedis方法出现出错了!{}",key);
return null;
}
6.8 controller方法参数的非空判断
自定义异常
public class RedisException extends RuntimeException{
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服务,并在服务中创建一个CommonConstant接口
public interface CommonConstant {
//内容为null的异常错误码
String CONSTANT_NULL_EXCEPTION = "10001";
}
写一个RedisUtils工具类来做非空判断,并抛出自定义异常
public class RedisUtils {
public static void checkNull(String resource){
if(StringUtils.isEmpty(resource)){
throw new RedisException("参数值为NULL!", CommonConstant.CONSTANT_NULL_EXCEPTION);
}
}
}
CacheController中的使用
@PostMapping("/set/{key}/{value}/{expireTime}")
@HystrixCommand(fallbackMethod = "set2redisFallback") //降级处理
public boolean set2redis(@PathVariable String key, @PathVariable String value, @PathVariable long expireTime) throws Exception {
RedisUtils.checkNull(key);
RedisUtils.checkNull(value);
return cacheService.set2redis(key,value,expireTime);
}
七、 运营管理平台(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>
build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
<filtering>true</filtering>
</resource>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*.*</include>
</includes>
<filtering>true</filtering>
</resource>
</resources>
</build>
7.2 application配置文件
因为使用的是springboot,会简化大量的配置文件,因此删除之前的所有的和ssm相关的配置文件,通过一个application.yml来进行配置
server:
port: 8080
spring:
datasource:
password: 123
username: root
driver-class-name: org.gjt.mm.mysql.Driver
url: jdbc:mysql://localhost:3306/openapi?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 CacheFeign
因为我们的缓存中的数据是通过管理平台添加到缓存中的,因此我们需要调用缓存服务,需要引入feign
当前文档中 不具体写明降级的方法的具体实现
@FeignClient("OPENAPI-CACHE")
public interface CacheFegin {
@PostMapping("/cache/set/{key}/{value}/{expireTime}")
boolean set2redis(@PathVariable(value = "key") String key, @PathVariable(value = "value") String value, @PathVariable(value = "expireTime") long expireTime) throws Exception;
@GetMapping("/cache/get/{key}")
String getFromRedis(@PathVariable(value = "key") String key) throws Exception;
@PostMapping("/cache/delete/{key}")
boolean deleteKey(@PathVariable(value = "key") String key) throws Exception;
@PostMapping("/cache/expire/{key}/{expireTime}")
boolean expire(@PathVariable(value = "key") String key, @PathVariable(value = "expireTime") long expireTime) throws Exception;
@GetMapping("/cache/getIncrement/{key}")
Long getIncrementId(@PathVariable(value = "key") String key) throws Exception;
@PostMapping("/cache/smembers/{key}")
Set<Object> sMembers(@PathVariable(value = "key") String key) throws Exception;
@PostMapping("/cache/sadd/{key}/{value}")
Long sAdd(@PathVariable(value = "key") String key, @PathVariable(value = "value") String value) throws Exception;
@PostMapping("/cache/sadds/{key}")
Long sAdd(@PathVariable(value = "key") String key,@RequestParam(value = "members") String[] members) throws Exception;
@PostMapping("/cache/sremove/{key}/{value}")
long sRemove(@PathVariable(value = "key") String key, @PathVariable(value = "value") String value) throws Exception;
@PostMapping("/cache/hset/{key}/{field}/{value}")
void hSet(@PathVariable(value = "key") String key, @PathVariable(value = "field") String field, @PathVariable(value = "value") String value) throws Exception;
@GetMapping("/cache/hget/{key}/{field}")
String hget(@PathVariable(value = "key") String key, @PathVariable(value = "field") String field) throws Exception;
@GetMapping("/cache/hgetall/{key}")
Map<Object, Object> hGetAll(@PathVariable(value = "key") String key) throws Exception;
@PostMapping("/cache/setnx/{key}/{value}/{expireTime}")
boolean setNx(@PathVariable(value = "key") String key, @PathVariable(value = "value") String value, @PathVariable(value = "expireTime") long expireTime) throws Exception;
@PostMapping("/cache/hmset/{key}")
boolean hMset(@PathVariable(value = "key") String key, @RequestBody Map<String, Object> map) throws Exception;
@GetMapping("/cache/keys/{pattern}")
Set<String> selectByPattern(@PathVariable(value = "pattern") String pattern) throws Exception;
@GetMapping("/cache/hincrement/{key}/{field}/{delta}")
Long hIncrement(@PathVariable(value = "key") String key, @PathVariable(value = "field") String field, @PathVariable(value = "delta") long delta) throws Exception;
/**
* 和上面的hmset一样,都是返回json数据,为了后续使用方便
* @param key
* @param value
* @return
* @throws Exception
*/
@PostMapping("/cache/hmset/{key}")
boolean hMset(@PathVariable(value = "key") String key, @RequestBody Object value) throws Exception;
}
在openapi-commons服务中添加SystemParams接口,并设置一些常量
public interface SystemParams {
String METHOD_REDIS_PRE = "APINAME:"; //路由信息在redis中的key
String SYSTEMPARAMS = "SYSTEMPARAMS:KEYS";//系统参数信息在redis中的key,用set存储数据
String APPKEY_REDIS_PRE = "APPKEY:";//应用信息在redis中的key
String CUSTOMER_REDIS_PRE = "APICUSTOMER:";//客户信息在redis中的key
}
7.4 ApiMappingService修改
我们的service的事务修改为注解模式,并且在我们的业务中,需要将原先的增删改代码中添加修改redis的操作
@Service
@Transactional
public class ApiMappingServiceImpl implements ApiMappingService {
@Autowired
private ApiMappingMapper apiMappingMapper;
@Autowired
private CacheFegin cacheFegin;
@Override
public void addApiMapping(ApiMapping mapping) {
apiMappingMapper.addApiMapping(mapping);
//判断当前的数据是否是有效的
try{
if(mapping.getState()==1){
// 有效,同步数据到redis缓存中
cacheFegin.hMset(SystemParams.METHOD_REDIS_PRE+mapping.getGatewayApiName(),mapping);
}
}catch(Exception e){
e.printStackTrace();
}
}
@Override
public void updateApiMapping(ApiMapping mapping) {
apiMappingMapper.updateApiMapping(mapping);
//判断是改为有效还是无效
try{
if(mapping.getState()==1){
//有效,则同步数据到redis缓存
cacheFegin.hMset(SystemParams.METHOD_REDIS_PRE+mapping.getGatewayApiName(),mapping);
}else{
//无效,则删除redis缓存的数据
cacheFegin.deleteKey(SystemParams.METHOD_REDIS_PRE+mapping.getGatewayApiName());
}
}catch (Exception e){
e.printStackTrace();
}
}
@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);
//从redis缓存中删除数据
try {
cacheFegin.deleteKey(SystemParams.METHOD_REDIS_PRE+mapping.getGatewayApiName());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
7.5 ApiSystemparamService修改
@Service
@Transactional
public class ApiSystemparamServiceImpl implements ApiSystemparamService {
@Autowired
private ApiSystemparamMapper systemparamDao;
@Autowired
private CacheFegin cacheFegin;
@Override
public void addApiSystemparam(ApiSystemparam apiSystemparam) throws Exception {
systemparamDao.insertApiSystemparam(apiSystemparam);
cacheFegin.sAdd(SystemParams.SYSTEMPARAMS,apiSystemparam.getName());
}
@Override
public PageInfo<ApiSystemparam> getSystemparamList(ApiSystemparam criteria, int page, int pageSize) {
PageHelper.startPage(page, pageSize);
return new PageInfo<>(systemparamDao.queryApiSystemparam(criteria));
}
@Override
public void updateApiSystemparam(ApiSystemparam systemparam) {
//先查询
ApiSystemparam mapping = systemparamDao.getMappingById(systemparam.getId());
if(mapping!=null){
//不管如何更新先删除之前的
try {
cacheFegin.sRemove(SystemParams.SYSTEMPARAMS,mapping.getName());
} catch (Exception e) {
e.printStackTrace();
}
}
systemparamDao.updateApiSystemparam(systemparam);// 更新数据
if(systemparam.getState()==1){
//如果是有效的数据,则再添加到 redis 中
try {
cacheFegin.sAdd(SystemParams.SYSTEMPARAMS,mapping.getName());
} catch (Exception e) {
e.printStackTrace();
}
}
}
@Override
public void deleteSystemparam(int[] ids) {
if (ids == null || ids.length == 0) {
return;
}
for (int id : ids) {
ApiSystemparam systemparam = systemparamDao.getMappingById(id);
if (systemparam != null) {
systemparam.setState(0);
systemparamDao.updateApiSystemparam(systemparam);
try {
cacheFegin.sRemove(SystemParams.SYSTEMPARAMS,systemparam.getName());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
@Override
public ApiSystemparam getSystemparamById(int id) {
return systemparamDao.getMappingById(id);
}
}
7.6 AppInfoService修改
@Service
@Transactional
public class AppInfoServiceImpl implements AppInfoService {
@Autowired
private AppInfoMapper appInfoMapper;
@Autowired
private CustomerMapper customerMapper;
@Autowired
private CacheFegin cacheFegin;
@Override
public List<AppInfo> getSimpleInfoList() {
return appInfoMapper.getSimpleInfoList();
}
@Override
public void updateAppInfo(AppInfo info) {
Customer customer = customerMapper.getCustomerById(info.getCusId());
info.setCorpName(customer == null ? null : customer.getNickname());
appInfoMapper.updateAppInfo(info);
try {
//同步数据到redis
cacheFegin.hMset(SystemParams.APPKEY_REDIS_PRE+info.getAppKey(),info);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public PageInfo<AppInfo> getInfoList(AppInfo info,Integer page, Integer limit) {
PageHelper.startPage(page, limit);
List<AppInfo> infoList = appInfoMapper.getInfoList(info);
return new PageInfo<>(infoList);
}
@Override
public AppInfo getInfoById(int id) {
return appInfoMapper.getInfoById(id);
}
@Override
public void add(AppInfo appInfo) {
Customer customer = customerMapper.getCustomerById(appInfo.getCusId());
appInfo.setCorpName(customer == null ? null : customer.getNickname());
appInfoMapper.add(appInfo);
try {
//同步数据到redis
cacheFegin.hMset(SystemParams.APPKEY_REDIS_PRE+appInfo.getAppKey(),appInfo);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void deleteAppInfos(int[] ids) {
if (ids == null || ids.length == 0) {
return;
}
for (int id : ids) {
AppInfo appInfo = appInfoMapper.getInfoById(id);
if (appInfo != null) {
appInfo.setState(0);
appInfoMapper.updateAppInfo(appInfo);
try {
cacheFegin.deleteKey(SystemParams.APPKEY_REDIS_PRE+appInfo.getAppKey());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
7.7 CustomerService修改
@Service
@Transactional
public class CustomerServiceImpl implements CustomerService {
@Autowired
private CustomerMapper customerMapper;
@Autowired
private CacheFegin cacheFegin;
@Override
public void addCustomer(Customer customer) throws Exception {
customerMapper.insertCustomer(customer);
cacheFegin.hMset(SystemParams.CUSTOMER_REDIS_PRE+customer.getId(),customer);
}
@Override
public PageInfo<Customer> getCustomerList(Customer criteria, int page, int limit) {
PageHelper.startPage(page, limit);
return new PageInfo<>(customerMapper.queryCustomer(criteria));
}
@Override
public void updateCustomer(Customer customer) {
customerMapper.updateCustomer(customer);
try {
cacheFegin.hMset(SystemParams.CUSTOMER_REDIS_PRE+customer.getId(),customer);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void deleteCustomer(int[] ids) {
if (ids == null || ids.length == 0) {
return;
}
for (int id : ids) {
Customer customer = customerMapper.getCustomerById(id);
if (customer != null) {
customer.setState(0);
customerMapper.updateCustomer(customer);
try {
cacheFegin.deleteKey(SystemParams.CUSTOMER_REDIS_PRE+customer.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
@Override
public Customer getCustomerById(int id) {
return customerMapper.getCustomerById(id);
}
@Override
public List<Customer> getAllCustomer() {
return customerMapper.getAllCustomer();
}
@Override
public void addMoney(Integer money, int id) {
Customer customer = customerMapper.getCustomerById(id);
if (customer != null) {
customer.setMoney(customer.getMoney() + money);
customerMapper.updateCustomer(customer);
}
}
}
7.8 主程序
@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);
}
}
7.9 测试用例
新建openapi-testservice01工程,并编写相应的测试方法
@RestController
@RequestMapping("/testservice01")
public class TestServiceController {
@GetMapping("/test01")
public String test01(String name){
return "Testservice01====>test01收到的name:"+name;
}
@GetMapping("/test02/{name}")
public String test02(@PathVariable String name){
return "Testservice01====>test02 rest 收到的name:"+name;
}
@GetMapping("/test03/{name}/{age}")
public String test03(@PathVariable String name,@PathVariable int age){
return "Testservice01====>test03 rest 收到的name:"+name+" age="+age;
}
}
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>
</dependencies>
application.yml文件
server:
port: 30000
spring:
application:
name: openapi-testservice01
eureka:
client:
service-url:
defaultZone: http://localhost:20000/eureka
instance:
prefer-ip-address: true
八、 网关
网关的主要作用是对请求进行校验,鉴权,转发,记录日志等功能
搭建openapi-gateway网关服务,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>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</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-actuator</artifactId>
</dependency>
</dependencies>
application.yml文件
server:
port: 31000
spring:
application:
name: openapi-gateway
eureka:
client:
service-url:
defaultZone: http://localhost:20000/eureka
instance:
prefer-ip-address: true
management:
endpoints:
web:
exposure:
include: "*" #打开所有的监控管理地址
zuul:
ignored-services: "*" #忽略所有的默认的服务路由信息
routes:
openapi-cache: "/*" #所有的请求都访问openapi-cache服务.实际开发不会这样做,可以找一个没有任何实际功能的服务来接收所有请求
feign:
hystrix:
enabled: true
ribbon:
ConnectTimeout: 2000
ReadTimeout: 5000
hystrix:
shareSecurityContext: true
command:
default:
execution:
isolation:
thread:
# 熔断器超时时间,默认:1000/毫秒
timeoutInMilliseconds: 5000
在网关服务中引入CacheFegin,并实现Fegin的降级
@FeignClient(value = "OPENAPI-CACHE-DEMO",fallback = CacheFeginFallBack.class)
public interface CacheFegin {
@PostMapping("/cache/set/{key}/{value}/{expireTime}")
boolean set2redis(@PathVariable(value = "key") String key, @PathVariable(value = "value") String value, @PathVariable(value = "expireTime") long expireTime) throws Exception;
@GetMapping("/cache/get/{key}")
String getFromRedis(@PathVariable(value = "key") String key) throws Exception;
}
CacheFeginFallBack降级处理
@Component
public class CacheFeginFallBack implements CacheFegin {
@Override
public boolean set2redis(String key, String value, long expireTime) throws Exception {
return false;
}
@Override
public String getFromRedis(String key) throws Exception {
return null;
}
}
8.1 动态路由
8.1.1 介绍
我们知道, zuul是一个反向代理网关,可以代理我们的微服务,但是在实际开发中,我们不会让网关直接显式的代理我们的服务,而是通过动态路由的方式来进行代理,而我们也知道,所谓代理服务就是最终知道请求哪个服务中的哪个地址,参数是什么,所以我们只要想办法告诉zuul这些内容即可
那我们怎么知道用户要请求的是哪个服务的哪个地址呢,同时zuul又通过什么方式来进行转发的呢
zuul中主要是通过RequestContext这个类来进行处理的
我们通过下面的两个方法来指定我们的 服务id和内部的请求地址,其中请求地址包含参数或者是是rest风格地址
context.put(FilterConstants.SERVICE_ID_KEY, serviceId); context.put(FilterConstants.REQUEST_URI_KEY, url);
那么上面的两个参数如何获得呢, 我们可以给每个服务的每个地址指定一个标识,用户传递标识后,我们通过标识来获取到服务id和请求地址,然后设置到过去,这个就是我们管理平台中定义的路由映射
按照我们的管理平台的逻辑,它将数据放到了redis中,所以我们只要从redis中根据用户传递的标识来进行获取数据,然后设置过去即可
8.1.2 关键代码
关键代码,主要是说明逻辑
@Component
public class RoutingFilter extends ZuulFilter {
@Autowired
private CacheFegin cacheFegin;
/**
* 当前过滤器是在我们请求的时候执行的,而我们所有的请求都是转到了缓存服务,我们应该在转发之前就将请求转到另外的服务,所以是前置过滤器
*
* @return
*/
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
//我们刚才分析过,我们这个过滤器是在前面的各种校验过滤器成功之后才执行的,所以它理论上是最后一个前置过滤器,所以order稍微高一些
return 88;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
//获取ReuquestContext对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取用户请求携带的标识,我们约定标识为method
String method = request.getParameter("method");
//通过标识到redis中获取serviceId和url的数据
try {
Map<Object, Object> apiMappingInfo = cacheFegin.hGetAll(SystemParams.METHOD_REDIS_PRE + method);
if (apiMappingInfo != null && apiMappingInfo.size()>0) {
Object serviceId = apiMappingInfo.get("serviceId");//我们要访问的服务ID
Object insideApiUrl = apiMappingInfo.get("insideApiUrl"); //我们要访问的服务地址
//动态路由到serviceId服务的url地址上
currentContext.put(FilterConstants.SERVICE_ID_KEY,serviceId);
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("路由失败,检查参数!!");
return null;
}
}
8.1.3 关键代码
改造RoutingFilter,解决动态路由参数问题
@Component
public class RoutingFilter extends ZuulFilter {
@Autowired
private CacheFegin cacheFegin;
/**
* 当前过滤器是在我们请求的时候执行的,而我们所有的请求都是转到了缓存服务,我们应该在转发之前就将请求转到另外的服务,所以是前置过滤器
*
* @return
*/
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
//我们刚才分析过,我们这个过滤器是在前面的各种校验过滤器成功之后才执行的,所以它理论上是最后一个前置过滤器,所以order稍微高一些
return 88;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
//获取ReuquestContext对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取用户请求携带的标识,我们约定标识为method
String method = request.getParameter("method");
//通过标识到redis中获取serviceId和url的数据
try {
Map<Object, Object> apiMappingInfo = cacheFegin.hGetAll(SystemParams.METHOD_REDIS_PRE + method);
if (apiMappingInfo != null && apiMappingInfo.size()>0) {
Object serviceId = apiMappingInfo.get("serviceId");//我们要访问的服务ID
String insideApiUrl = (String) apiMappingInfo.get("insideApiUrl"); //我们要访问的服务地址
//我们请求中传递的普通参数会一起跟随转发过去
//但是经过我们的测试,我们的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 parameterName = parameterNames.nextElement();
//把{name}替换成具体这个name参数的值
insideApiUrl = insideApiUrl.replace('{'+parameterName+'}',request.getParameter(parameterName));
}
//动态路由到serviceId服务的url地址上
currentContext.put(FilterConstants.SERVICE_ID_KEY,serviceId);
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("路由失败,检查参数!!");
return null;
}
}
8.1.4 编写openapi-testservice02服务,在不重启网关的情况下,测试网关的动态路由功能
@RestController
@RequestMapping("/testservice02")
public class TestServiceController {
@GetMapping("/test01")
public String test01(String name){
return "Testservice02====>test01收到的name:"+name;
}
@GetMapping("/test02/{name}")
public String test02(@PathVariable String name){
return "Testservice02====>test02 rest 收到的name:"+name;
}
@GetMapping("/test03/{name}/{age}")
public String test03(@PathVariable String name,@PathVariable int age){
return "Testservice02====>test03 rest 收到的name:"+name+" age="+age;
}
}
8.1.5 修改返回结果
在openapi-commons服务中创建一个BaseResultBean类
public class BaseResultBean {
private String msg;
private String code;
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}
在openapi-commons服务中的CommonConstant接口中添加常量
public interface CommonConstant {
//内容为null的异常错误码
String CONSTANT_NULL_EXCEPTION = "10001";
//路由错误异常
String ROUTING_ERROR = "20001";
}
修改网关统一返回结果
/**
* 当前过滤器的主要作用是根据用户传递的标识来获取到用户实际想要访问的服务的id和地址,然后再进行转发
*/
@Component
public class RoutingFilter 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 88;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
//如何拿到请求对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//从请求中获取用户的标识数据
//我们应该从用户的请求参数中获取这个数据,那么请求的参数名是什么,我们怎么知道,所以我们作为服务端就要定义规则,我们要求用户必须通过method参数来传递
String method = request.getParameter("method");
//从rendis根据用户传递的标识获取路由信息
try {
//获取当前参数的路由映射关系map,这里面放的就是我们的标识对应的服务的id和地址等信息,根据这个信息我们可以拿到要访问的地址和服务id,然后进行服务的跳转
Map<Object, Object> apiMapingInfo = cacheService.hGetAll(SystemParams.METHOD_REDIS_PRE + method);
//获取服务id和地址
if (apiMapingInfo != null&&apiMapingInfo.size()>0) {
Object serviceId = apiMapingInfo.get("serviceId");//我们要访问的服务的id
String insideApiUrl = (String) apiMapingInfo.get("insideApiUrl");//我们要访问的地址
//通过reqyestcontext设置我们的请求服务id和地址即可
//设置我们要访问的地址
currentContext.put(FilterConstants.SERVICE_ID_KEY, serviceId);
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 resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.ROUTING_ERROR);
resultBean.setMsg("与"+method+" 相关的服务没有找到,请确认后再重试");
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}
8.2 请求参数校验
8.2.1 介绍
通过上面的功能,我们发现我们要求用户必须传递mehtod参数来获取请求的数据,但是如果用户没有传递的话,我们去查询缓存也没有任何意义,所以我们在查询之前要求用户必须传递这个参数, 所以我们必须校验用户有没有传递,校验的方式很简单,我们只要查询下这个参数有没有值即可, 但是我们怎么知道我们需要校验哪些参数呢,如果需要校验的参数发生变化怎么办,我们需要知道发生了变化, 所以我们在运营管理平台中将这些参数同步到了缓存中,所以我们只需要从缓存中获取即可,然后遍历获取数据即可
8.2.2 关键代码
在openapi-commons服务中的CommonConstant接口中添加常量
public interface CommonConstant {
//内容为null的异常错误码
String CONSTANT_NULL_EXCEPTION = "10001";
//路由错误异常
String ROUTING_ERROR = "20001";
//没有携带必要的公共参数
String PARAMETER_MISSED = "20002";
}
关键代码,主要是说明逻辑
@Component
public class SystemParamsFilter extends ZuulFilter {
@Autowired
private CacheService cacheService;
@Autowired
private ObjectMapper objectMapper;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 10;//我们这个过滤器要在接近最前面执行,所以设置的稍微小一些
}
@Override
public boolean shouldFilter() {
// return true;
return RequestContext.getCurrentContext().sendZuulResponse();
}
@Override
public Object run() throws ZuulException {
//我们现在的需求是要判断我们要求必须传递的参数用户传递了没有,为了判断这些我们需要什么数据
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
// //我首先要 获取用户传递的参数名
// Enumeration<String> parameterNames = request.getParameterNames();
//其次 获取我们需要的参数名,这个数据我们可以保存在本地,保存在数据库,保存在 redis,比如此处我们保存在 redis
//在 redis 中到底以什么格式存这个数据,以后不管是什么东西要想往 redis 中放就考虑以下几点: 数据有几条? 数据是不是有多个字段,数据是否要考虑去重
//
try {
Set<Object> systemParamSet = cacheFegin.sMembers(SystemParams.SYSTEMPARAMS);
if(systemParamSet!=null){
//遍历出每一个参数
for (Object member : systemParamSet) {
//获取参数值
String value = request.getParameter(member.toString());
if (StringUtils.isEmpty(value)) {
//拦截请求,返回一些错误提示
currentContext.setSendZuulResponse(false);
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.PARAMETER_MISSED);
resultBean.setMsg("必须传递参数名为:"+member+"的数据");
currentContext.getResponse().setContentType("application/json;charset=utf-8");
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
//最后比较我们需要的参数名是不是都在用户传递的里面
return null;
}
}
//TODO 参考RoutingFilter实现动态刷新需要参数列表,通过本地缓存+mq 实现, webmaster 中需要写一个增删改查的接口来对这个必须的参数进行修改
8.3 服务参数校验
除了所有服务都共同的参数之外,还有一些是每个服务必须传递的参数,我们也需要对这些参数进行校验,方式和系统参数一致,只不过系统参数在redis中的key是固定的,每个服务的参数和具体服务标识相关,所以只需要根据用户传递的标识找到这个服务需要的参数进行校验即可,此处我们不对代码进行编写,大家自己按照上面的思路编写
8.4 时间戳校验
8.4.1介绍
我们的服务为了防止请求被拦截后对数据进行修改,所以我们会要求用户传递时间戳过来,我们会对时间戳的有效期进行校验,比如我们要求有效期为1分钟,当用户传递的有效期和服务器收到的i系统时间差超过1分钟的时候,我们将认为请求无效
8.4.2 核心代码
在openapi-commons服务的CommonConstant接口中添加常量
//时间戳错误异常
String PARAMETER_TIMESTAMP_ERROR= "20003";
TimestampFilter中的实现
@Component
public class TimestampFilter extends ZuulFilter {
SimpleDateFormat dateFormat = 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 30;
}
@Override
public boolean shouldFilter() {
return RequestContext.getCurrentContext().sendZuulResponse();
}
@Override
public Object run() throws ZuulException {
//获取用户传递的时间戳
//获取当前服务器的时间
//比较两个时间是不是在允许的范围内
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String timestamp = request.getParameter("timestamp");//获取用户传递的时间戳
try {
Date userDate = dateFormat.parse(timestamp);//将用户传递的时间转成日期对象
long time = userDate.getTime();//时间的毫秒值
long currentTimeMillis = System.currentTimeMillis();//获取系统的时间值
//我们要求用户传递的时间不能比我们的服务器时间要大,同时比我们的服务器时间不能晚 1 分钟(具体值可以动态改变)
//此处我们直接写死
if (currentTimeMillis - time < 0 || currentTimeMillis - time > 600000) {
//不在允许的范围差
throw new RuntimeException();//抛出异常
}
} catch (Exception e) {
e.printStackTrace();
try {
//代表用户传递的时间戳格式不对
context.setSendZuulResponse(false);//拦截请求
BaseResultBean baseResultBean = new BaseResultBean();
baseResultBean.setCode(CommonConstant.PARAMETER_TIMESTAMP_ERROR);
baseResultBean.setMsg("时间戳格式不对");
String json = objectMapper.writeValueAsString(baseResultBean);
context.getResponse().setContentType("application/json;charset=utf-8");
context.setResponseBody(json);
} catch (Exception e1) {
e1.printStackTrace();
}
}
return null;
}
}
8.5 签名校验
8.5.1 介绍
因为每次请求执行的操作可能包含敏感操作,比如牵扯到扣费等,因此我们需要对用户的请求进行验证,以防止被他人非法请求,我们使用的方式是通过签名进行校验,通过比较用户在请求时候生成传递的签名和服务器计算生成的签名进行比较,一致则通过,不一致则不通过,
8.5.2 规则描述
因为牵扯到数据的计算,所以必须存在一定的规则,并且规则要保证双方采用的一致,我们的规则如下:
将请求参数按照参数名的字典顺序进行组合 比如传递的参数是method=taobao.order,get&appkey=abcdef
组合后的参数为appkeyabcdefmethodtaobao.order,get
在上面的数据前面拼上用户的appsecret 比如用户的appsecret为asdfghj 则结果为asdfghjappkeyabcdefmethodtaobao.order,get
将上面的结果生成MD5值如 dasdasdasdasd,并将md5值以 sign为参数名添加到请求参数中
最终的请求参数为method=taobao.order,get&appkey=abcdef&sign=dasdasdasdasd
服务端收到参数后,将除了sign外的参数按照上面的顺序再次生成sign值,并和用户传递的比较,一致则通过
8.5.3 工具类
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 核心代码
@Component
public class SignFilter extends ZuulFilter {
@Autowired
private CacheFegin cacheFegin;
@Autowired
private ObjectMapper objectMapper;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 35;
}
@Override
public boolean shouldFilter() {
return RequestContext.getCurrentContext().sendZuulResponse();
}
@Override
public Object run() throws ZuulException {
//得到request对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取所有的参数名称
Enumeration<String> parameterNames = request.getParameterNames();
//定义一个treeMap来装请求携带的参数
Map<String,String> paramMap = new TreeMap<>();
while(parameterNames.hasMoreElements()){
String parameterName = parameterNames.nextElement();
//不是sign参数,则都加到treeMap中进行字典排序
if(!"sign".equalsIgnoreCase(parameterName)){
paramMap.put(parameterName,request.getParameter(parameterName));
}
}
try {
//得紧参数加携带的app_key
String appKey = request.getParameter("appkey");
//得到密钥
String appSecret = cacheFegin.hget(SystemParams.APPKEY_REDIS_PRE + appKey, "appSecret");
//通过MD5算法得到签名数据
String md5Signature = Md5Util.md5Signature(paramMap, 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();
}
//签名校验失败
currentContext.setSendZuulResponse(false);
HttpServletResponse response = currentContext.getResponse();
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.PARAMETER_SIGN_ERROR);
resultBean.setMsg("签名检验失败!!");
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}
8.6 幂等性校验
8.6.1 介绍
我们的很多请求不允许用户重复发起请求,一般情况下所有的修改类型的操作都不允许,比如添加,删除,更新操作,但是查询操作一般都可以重复请求,这就是请求的幂等性,对于有幂等性要求的服务每次都需要发起新请求,即便是请求参数一致,但是时间戳肯定也不一致,最终签名也不一致,对于幂等性的校验也非常简单,因为幂等性的请求签名一定是一致的,所以我们只要判断当前传递的签名有没有出现过即可,我们只要以签名作为key,随便放个数据到redis中,判断的时候从rendis以当前key获取数据,如果有则代表重复,没有则代表没有请求过,不重复,然后放入到redis中一份即可
8.6.2 核心代码
@Component
public class IdempotentsFilter extends ZuulFilter {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private CacheService cacheService;//用于从 redis 中获取数据的 feign 对象
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 40;
}
@Override
public boolean shouldFilter() {
//判断之前的过滤器是否放行了,并且要看该服务是否要求是幂等性操作
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取请求参数method
String method = request.getParameter("method");
boolean isIdempotents = true;
try {
//从redis中获取路由信息,判断其幂等性的值
String idempotents = cacheFegin.hget(SystemParams.METHOD_REDIS_PRE + method, "idempotents");
isIdempotents = "1".equals(idempotents);
} catch (Exception e) {
e.printStackTrace();
}
return currentContext.sendZuulResponse()&&isIdempotents;
}
@Override
public Object run() throws ZuulException {
//获取到用户传递的签名
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String sign = request.getParameter("sign");
//去我们的保存的位置看看这个签名存在还是不存在
String result = cacheService.get(SystemParams.IDEMPOTENTS_REDIS_PRE + sign);
if (result != null) {
//代表这个数据已经存在了,则拦截请求
context.setSendZuulResponse(false);//拦截请求
BaseResultBean baseResultBean = new BaseResultBean();
baseResultBean.setMsg("请勿重复提交请求");
baseResultBean.setCode(CommonConstant.IDEMPOTENTS_ERROR);
String json = null;
try {
json = objectMapper.writeValueAsString(baseResultBean);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
context.getResponse().setContentType("application/json;charset=utf-8");
context.setResponseBody(json);
} else {
//如果不存在则代表第一次出现,放行,我们需要保存到 redis 中
cacheFegin.set2redis(SystemParams.IDEMPOTENTS_REDIS_PRE + sign,System.currentTimeMillis()+"",60000);
return null;
}
}
8.7 限流
8.7.1 介绍
对于开放平台中的一些免费的接口或者是针对一个用户方位所有的免费接口会存在限制次数的问题,比如我们当前平台的规则是每个用户每天可以访问所有免费接口多少次,因此,当用户访问免费接口的时候我们需要对他现在剩余的次数进行校验,如果还有免费次数,则允许访问,所以我们只要知道当前服务是不是免费的,以及用户有没有剩余次数即可
8.7.2 核心代码
/**
*
* 此过滤器的主要作用是针对所有免费的服务进行统一次数的访问限制
* 需要注意:我们的免费接口的限制次数一般不会是永久的,一般比如一天限制多少次,或者是一周或者是 1 个月等等,所以每次进入新的统计周日的时候需要恢复用户的限制次数
* 比如一天 10 万次,当天用完了,当晚上 0 点的时候需要给用户恢复次数,所以需要一个定时任务,但是也要注意,这个定时任务应该要保证一定能执行,所以必须是保证高可用的分布式任务
*
* @Author jackiechan
*/
@Component
public class LimitFilter extends ZuulFilter {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private CacheService cacheService;//用于从 redis 中获取数据的 feign 对象
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
/**
* 返回 110 的原因是因为我们这个扣次数的过滤器应该是用户请求的路由校验存在的情况下,才去判断是不是还有次数访问,所以放在路由过滤器的后面
*
* @return
*/
@Override
public int filterOrder() {
return 110;
}
@Override
public boolean shouldFilter() {
//当前过滤器针对的是免费的服务,还是要先判断接口的免费性
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取请求参数method
String method = request.getParameter("method");
boolean isNeedfee = true;
try {
//从redis中获取路由信息,如果是免费的请求,从要执行当前过滤器
String needfee = cacheFegin.hget(SystemParams.METHOD_REDIS_PRE + method, "needfee");
isNeedfee = "0".equals(needfee);
} catch (Exception e) {
e.printStackTrace();
}
return RequestContext.getCurrentContext().sendZuulResponse()&&isFree; //如果前置过滤器有拦截或者是当前是收费接口则不执行
}
@Override
public Object run() throws ZuulException {
//判断用户是否还有剩余次数来访问服务
//得到request对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取请求参数appkey
String appkey = request.getParameter("appkey");
//自减1,得到剩余次数,如果结果大于等于0,就还可以继续访问,反之,不能访问
try {
Long times = cacheFegin.hIncrement(SystemParams.APPKEY_REDIS_PRE + appkey, "limit", -1L);
if (times < 0) {
//已超过可访问次数
currentContext.setSendZuulResponse(false);
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.LIMIT_ERROR);
resultBean.setMsg("已超过今日的可访问次数");
HttpServletResponse response = currentContext.getResponse();
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e1) {
e1.printStackTrace();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
8.8 计费
8.8.1 规则
对于收费的接口,我们需要进行计费扣除,如果用户有钱才会继续访问,没有钱的话就不能访问,本过滤器主要是给用户扣钱的,扣钱后剩余的钱数大于0则即可,不大于0则需要给用户加回去
/**
*
* 此过滤器是针对收费的服务进行计费使用的,关于计费的策略问题,有可能是按照套餐扣除的,有的时候按照次数计费的,所以我应该是针对不同的情况不同处理
* 假设我们当前的过滤器是按照次数计费的
* 考虑到我们的每次请求的费用很低,所以呢我们在保存用户的钱数的时候我们需要的计量单位也要非常精确,比如我们使用毫来保存 ,1块钱=10000毫
*
* @Author jackiechan
*/
@Component
public class FeeFilter extends ZuulFilter {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private CacheService cacheService;//用于从 redis 中获取数据的 feign 对象
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 111;
}
@Override
public boolean shouldFilter() {
//当前过滤器针对的是计费的服务,还是要先判断接口的免费性
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取请求参数method
String method = request.getParameter("method");
boolean isNeedfee = true;
try {
//从redis中获取路由信息,判断其是否收费
String needfee = cacheFegin.hget(SystemParams.METHOD_REDIS_PRE + method, "needfee");
isNeedfee = "1".equals(needfee);
} catch (Exception e) {
e.printStackTrace();
}
return currentContext.sendZuulResponse() && isNeedfee;
}
@Override
public Object run() throws ZuulException {
//得到request对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//获取请求参数sign
String appkey = request.getParameter("appkey");
try {
//获取cutId
String cusId = cacheFegin.hget(SystemParams.APPKEY_REDIS_PRE + appkey, "cusId");
if(cusId!=null){
//获取客户扣费后的money
Long money = cacheFegin.hIncrement(SystemParams.CUSTOMER_REDIS_PRE + cusId, "money", -2);
if(money<0){
//避免用户本来是0或者是1,扣费后变成负数,所以加回去
cacheFegin.hIncrement(SystemParams.CUSTOMER_REDIS_PRE+cusId,"money",2);
}else{
// 有钱则放行
return null;
}
}
} catch (Exception e) {
e.printStackTrace();
}
//提示用户没钱了
currentContext.setSendZuulResponse(false);
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.FEE_ERROR);
resultBean.setMsg("你余额不足,赶紧充值吧!");
HttpServletResponse response = currentContext.getResponse();
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e1) {
e1.printStackTrace();
}
return null;
}
}
注意,此次通过cacheFegin获取cusId时,缓存服务会报类型转换异常。因此要把CacheServiceImpl中操作redis的方法做下修改
@Override
public String hget(String key, String field) throws Exception {
Object o = redisTemplate.opsForHash().get(key, field);
return o==null?null:o.toString();
}
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 InitRouting {
//定义一个map来保存路由信息
public static final Map<String,Map<Object,Object>> API_ROUTING_MAP_CACHE = new ConcurrentHashMap<>();
@Autowired
private CacheFegin cacheFegin;
@PostConstruct //这个注解是在创建对象后执行的
public void init() {
try {
//从缓存中读取到所有路由的key信息 keys APINAME:*
Set<String> allKeys = cacheFegin.selectByPattern("APINAME:*");
System.err.println("redis中所有和路由相关信息的key:" + allKeys);
//遍历所有的key
if(allKeys!=null){
//得到路由的key
for (String key : allKeys) {
//根据路由key查询路由信息
Map<Object, Object> keyMap = cacheFegin.hGetAll(key);
//把路由信息存起来
API_ROUTING_MAP_CACHE.put(key,keyMap);
}
}
System.err.println(API_ROUTING_MAP_CACHE);
} catch (Exception e) {
e.printStackTrace();
}
}
}
8.9.2.3 RoutingFilter改造
// Map<Object, Object> apiMappingInfo = cacheFegin.hGetAll(SystemParams.METHOD_REDIS_PRE + method);
//从本地缓存读取路由信息
Map<Object, Object> apiMappingInfo = InitRouting.API_ROUTING_MAP_CACHE.get(SystemParams.METHOD_REDIS_PRE + method);
if(apiMappingInfo==null){
//如果是空的,则先去缓存读一份
apiMappingInfo = cacheFegin.hGetAll(SystemParams.METHOD_REDIS_PRE + method);
//保存到本地
InitRouting.API_ROUTING_MAP_CACHE.put(SystemParams.METHOD_REDIS_PRE + method,apiMappingInfo);
}
注意,在InitRouting类中执行cacheFegin.selectByPattern(“APINAME:*”);查询缓存时,会走该方法的降级处理。这里hystrix的超时时间的bug,在yml文件中配置hystrix的超时时间也没用。解决方案就是把yml文件中相关的hystrix的超时时间去掉。
在RoutingFilter类中打断点看效果,如果能正常拿到本地缓存的路由信息,则缓存生效.
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,需要在启动类加@EnableBiding注解绑定
public interface SendAPIRoutingChangeStream {
@Output("apiroutingchange")//output 声明当前是生产者,会将消息发送到apiroutingchange交换机
MessageChannel message_channel();
}
zuul stream 需要在启动类加@EnableBiding注解绑定
public interface ReceviedAPIRoutingChangeStream {
@Input("apiroutingchange")//声明当前是消费者,监听是是apiroutingchange交换机,spring 会自动帮我们创建消息队列并绑定到交换机上
SubscribableChannel subscribable_channel();
}
8.9.3.4 消息对象定义
在openapi-commons服务中添加消息类型枚举对象
public enum APIRoutingType {
ADD,UPDATE,DELETE //定义了更新和删除路由的类型
}
在openapi-commons服务中添加消息对象
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的增删改操作中发送消息
@Override
public void addApiMapping(ApiMapping mapping) {
apiMappingMapper.addApiMapping(mapping);
//判断当前的数据是否是有效的
try {
if (mapping.getState() == 1) {
// 有效,同步数据到redis缓存中
cacheFegin.hMset(SystemParams.METHOD_REDIS_PRE + mapping.getGatewayApiName(), mapping);
//发送消息到MQ
sendMessage(SystemParams.METHOD_REDIS_PRE + mapping.getGatewayApiName(), APIRoutingType.ADD);
}
} catch (Exception e) {
e.printStackTrace();
}
}
发送消息
//抽取一个发送消息的方法
public void sendMessage(String key, APIRoutingType type) {
APIRoutingMQBean mqBean = new APIRoutingMQBean();
mqBean.setKey(key);
mqBean.setType(type);
apiRoutingChangeStream.message_channel().send(new GenericMessage<APIRoutingMQBean>(mqBean));
}
8.9.3.6 zuul stream listener
网关消费消息,并同步到本地缓存
@Component
public class APIRoutingStreamListener {
@Autowired
private CacheFegin cacheFegin;
@StreamListener("apiroutingchange")
public void onMessage(APIRoutingMQBean bean){
switch (bean.getType()){
case ADD:
case UPDATE:
//
try {
Map<Object, Object> map = cacheFegin.hGetAll(bean.getKey());
InitRouting.API_ROUTING_MAP_CACHE.put(bean.getKey(),map);
} catch (Exception e) {
e.printStackTrace();
}
break;
case DELETE:
InitRouting.API_ROUTING_MAP_CACHE.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实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。
{“typ”:“JWT”,“alg”:“HS256”}
在头部指明了签名算法是HS256算法。 我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
(1)标准中注册的声明(建议但不强制使用)
iss: jwt签发者 sub: jwt所面向的用户 aud: 接收jwt的一方 exp: jwt的过期时间,这个过期时间必须要大于签发时间 nbf: 定义在什么时间之前,该jwt都是不可用的. iat: jwt的签发时间 jti: jwt的唯一身份标识,主要用来作为一次性token。
(2)公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
(3)私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
这个指的就是自定义的claim。比如前面那个结构举例中的admin和name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。
定义一个payload:
{ "sub":"1234567890","name":"John Doe","admin":true}
然后将其进行base64加密,得到Jwt的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
header (base64后的)
payload (base64后的)
secret
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
8.10.3 发送Toekn 核心代码
创建授权工程auth-center,并引入相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
Token生成发送是在授权系统中的,所以授权系统内部主要是判断你账号和密码,成功后返回一个token,比如我们这里是通过header来返回,并且用户在请求其他服务的时候也是通过header来发送的
@RestController
public class AuthController {
@RequestMapping("/login")
public BaseResultBean login(@RequestBody User user, HttpServletResponse response) {
BaseResultBean resultBean = new BaseResultBean();
if("admin".equals(user.getUsername())&&"admin".equals(user.getPassword())){
//账户和密码正确,给用户发送jwt相关信息
resultBean.setCode("1");
resultBean.setMsg("登录成功!");
Instant now = Instant.now();
String jwt = Jwts.builder()
.setSubject("admin") //当前的用户,任意信息都可以
.setIssuedAt(Date.from(now)) //设置开始的有效期
.setExpiration(Date.from(now.plusSeconds(3600))) //设置过期时间是当前时间顺延1小时
.claim("id", "1") //内容可以随便写
.claim("quanxian", "admin")
.signWith(SignatureAlgorithm.HS256, "haha".getBytes()) //设置签名的算法和密钥值
.compact();//生成内容
System.err.println(jwt);
//我们将token放在响应头返回
response.setHeader("token",jwt);
}else{
resultBean.setCode("0");
resultBean.setMsg("登录失败!");
}
return resultBean;
}
}
8.10.4 校验Token核心代码
@Component
public class JwtFilter extends ZuulFilter {
@Autowired
private CacheFegin cacheFegin;
@Autowired
private ObjectMapper objectMapper;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
return RequestContext.getCurrentContext().sendZuulResponse();
}
@Override
public Object run() throws ZuulException {
//得到requestContext对象
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
//得到用户通过header传递过来的token的值
String jwt = request.getHeader("token");
//检验jwt
try {
Claims body = Jwts.parser().setSigningKey("haha".getBytes()).parseClaimsJws(jwt).getBody();
String subject = body.getSubject();
Object quanxian = body.get("quanxian");
System.out.println(subject);
System.out.println(quanxian);
//如果没有异常代表成功,直接放行
return null;
} catch (Exception e) {
currentContext.setSendZuulResponse(false);
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.AUTH_ERROR);
resultBean.setMsg("还没进行登录认证!");
HttpServletResponse response = currentContext.getResponse();
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e1) {
e1.printStackTrace();
}
}
return null;
}
}
8.10.5 解决多客户端登录JWT问题
存在问题:如果在别的地方重新登录后,得到新生成的token值,但之前的请求还是用原来的token值,一样可以进行登录。也就是说应该重新登录成功后,原来的token值要失效。
1,auth-center工程中要引入依赖
<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>
2,application.yml文件配置
server:
port: 40000
eureka:
client:
service-url:
defaultZone: http://localhost:20000/eureka
spring:
application:
name: auth-center
3,工程中导入CacheFegin接口和降级类,启动类加相关注解
4,openapi-commons工程中的SystemParams接口添加常量
String JWT_TOKEN_REDIS_PRE = "TOKEN:";//JWT在redis中的key
5,auth-center工程的Controller添加相关代码改造
@RestController
public class AuthController {
@Autowired
private CacheFegin cacheFegin;
@RequestMapping("/login")
public BaseResultBean login(@RequestBody User user, HttpServletResponse response) {
BaseResultBean resultBean = new BaseResultBean();
if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
//账户和密码正确,给用户发送jwt相关信息
resultBean.setCode("1");
resultBean.setMsg("登录成功!");
Instant now = Instant.now();
String jwt = Jwts.builder()
.setSubject("admin") //当前的用户,任意信息都可以
.setIssuedAt(Date.from(now)) //设置开始的有效期
.setExpiration(Date.from(now.plusSeconds(3600))) //设置过期时间是当前时间顺延1小时
.claim("id", "1") //内容可以随便写
.claim("quanxian", "admin")
.signWith(SignatureAlgorithm.HS256, "haha".getBytes()) //设置签名的算法和密钥值
.compact();//生成内容
System.err.println(jwt);
//我们将token放在响应头返回
response.setHeader("token", jwt);
try {
//保存生成的jwt到redis缓存中
cacheFegin.set2redis(SystemParams.JWT_TOKEN_REDIS_PRE + user.getUsername(), jwt, 60);
} catch (Exception e) {
e.printStackTrace();
}
} else {
resultBean.setCode("0");
resultBean.setMsg("登录失败!");
}
return resultBean;
}
}
6,网关的JWTfilter的相关代码改造
@Component
public class JWTfilter extends ZuulFilter {
@Autowired
private ObjectMapper objectMapper;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 9;
}
@Override
public boolean shouldFilter() {
return RequestContext.getCurrentContext().sendZuulResponse();
}
@Override
public Object run() throws ZuulException {
//按照我们的要求,用户需要在请求头中传递一个名字叫token的头,在里面携带我们的 JWT 数据
//我们从中获取到数据之后校验数据是不是合法的,判断过期时间,然后根据结果给用户返回对应的结果
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String jwt = request.getHeader("token");
try {
if (!StringUtils.isEmpty(jwt)) {
//找到了 jwt 的数据
Claims body = Jwts.parser().setSigningKey(jwtSignKey).parseClaimsJws(jwt).getBody();//根据我们的密码去生成签名校验我们的 jwt 数据,如果通过则说明数据是对的数据,否则说明传递过来的数据是有问题的不是合法的 jwt 数据
//可能会存在以下情况,因为现在是纯粹本地校验的,如果当前用户已经退出了,然后 token 被别人拿走,然后在其他地方传递过来,怎么办
//如果我们不允许用多机器登陆,如果用户在另外一个机器上登陆了,之前的 jwt 要自动失效
//如果考虑以上情况,我们需要对当前 token的实际有效性需要做过滤
//简单点说,就是把应用当前最新的 jwt 放在 redis 中,然后我们获取到传递过来的,然后获取 reids 中的,然后进行比较,内容一致则说明是真实的
String subject = body.getSubject();
Object id = body.get("id");
String fromRedisJwt = cacheFegin.getFromRedis(SystemParams.JWT_TOKEN_REDIS_PRE + subject);
//判断缓存中的token值和用户传递的token值是否一致
if (jwt != null && !jwt.equals(fromRedisJwt)) {
//不一致的情况,给用户返回认证信息
throw new RuntimeException();
}
System.out.println(subject);
System.out.println(id);
//如果没有异常代表成功,直接放行
return null;
}
} catch (Exception e) {
e.printStackTrace();
currentContext.setSendZuulResponse(false);
BaseResultBean resultBean = new BaseResultBean();
resultBean.setCode(CommonConstant.AUTH_ERROR);
resultBean.setMsg("还没进行登录认证!");
HttpServletResponse response = currentContext.getResponse();
response.setContentType("application/json;charset=utf-8");
try {
currentContext.setResponseBody(objectMapper.writeValueAsString(resultBean));
} catch (JsonProcessingException e1) {
e1.printStackTrace();
}
}
return null;
}
}
8.11 导入订单服务和仓储服务
8.11.1 直接访问订单服务的查询和退货接口
订单服务的启动类中需要设置可以访问特殊字符
@Bean
public ServletWebServerFactory webServerFactory() {
TomcatServletWebServerFactory fa = new TomcatServletWebServerFactory();
fa.addConnectorCustomizers(connector -> {
connector.setProperty("relaxedQueryChars", "(),/:;<=>?@[\\]{}");
connector.setProperty("rejectIllegalHeader", "false");
});
return fa;
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o4y6oNIh-1605001274192)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200808220910735.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n14Xybg5-1605001274196)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200808220945474.png)]
8.11.2 网关整合订单服务
通过网关的方式访问订单服务的查询和退货接口
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CD4hgOeQ-1605001274200)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200808221241452.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PoSkwp9w-1605001274203)(C:\Users\asus\AppData\Roaming\Typora\typora-user-images\image-20200808221304701.png)]
8.11.3 搭建事务协调者tx-Manager
创建tx-manager工程,并引入lcn的依赖
<dependencies>
<dependency>
<groupId>com.codingapi.txlcn</groupId>
<artifactId>txlcn-tm</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
</dependencies>
创建application.properties空文件和application.yml文件
server:
port: 50000
spring:
datasource:
username: root
password: 123
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql:///tx-manager?characterEncoding=utf8&serverTimezone=Asia/Shanghai
redis:
host: 192.168.206.142
port: 6379
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
hibernate:
ddl-auto: update
tx-lcn:
manager:
port: 9527
启动类加注解
@SpringBootApplication
@EnableTransactionManagerServer
public class TxManagerApplication {
public static void main(String[] args){
SpringApplication.run(TxManagerApplication.class,args);
}
}
8.11.4 订单和仓储服务实现分布式事务
订单服务要引入的依赖
<dependency>
<groupId>com.codingapi.txlcn</groupId>
<artifactId>txlcn-tc</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.codingapi.txlcn</groupId>
<artifactId>txlcn-txmsg-netty</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
application.yml文件新增配置
tx-lcn:
client:
manager-address: localhost:9527
启动类加注解 @EnableDistributedTransaction
service实现类的操作方法上添加@LcnTransaction注解
@Override
@Transactional
@LcnTransaction
public int updateStatus(PopOrderData pojo, String exp) {
.....
}
仓储服务的操作同上
九、 日志系统
我们的系统内部应当对用户的操作进行日志处理,以用于后面的不时之需,记录日志本质上就是保存一些特定格式的数据,然后通过某种方式进行查询,比如保存到数据库,或者保存到文件等,我们此处选择的是保存到es中
按照功能划分,日志是一套独立的系统,我们此处通过对网关的访问进行日志保存,其他地方的日志可参考自行编码
我们如何将网关中的日志写入到日志系统中呢, 我们将日志封装到搜索服务,通过MQ将日志从网关发送到搜素,用MQ的原因是日志不属于用户操作流程中的一部分,日志的有无不应该影响用户的请求结果,所以我们使用异步来执行操作
创建openapi-search工程,并引入相关依赖
<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>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>6.5.4</version>
<exclusions>
<exclusion>
<artifactId>elasticsearch</artifactId>
<groupId>org.elasticsearch</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>6.5.4</version>
</dependency>
</dependencies>
application.yml配置文件
server:
port: 38000
spring:
rabbitmq:
host: 192.168.206.142
port: 5672
username: test
password: 123
virtual-host: /test
data:
elasticsearch:
host: 192.168.206.142
port: 9200
index: openapiindex
type: openapitype
eureka:
client:
service-url:
defaultZone: http://localhost:20000/eureka
启动类添加eureka-client注解
编写config配置类
@Configuration
public class EsConfig {
@Value("${spring.data.elasticsearch.host}")
private String host;
@Value("${spring.data.elasticsearch.port}")
private int port;
@Bean
public RestHighLevelClient restHighLevelClient(){
HttpHost httpHost = new HttpHost(host,port);
RestClientBuilder builder = RestClient.builder(httpHost);
RestHighLevelClient highLevelClient = new RestHighLevelClient(builder);
return highLevelClient;
}
}
9.1 ES数据结构
9.1.1 es mapping
这是我们主要保存的数据
9.1.2 创建SearchService接口及其实现类
public interface SearchService {
/**
* 创建索引
*/
void createIndex(String index,String type) throws IOException;
/**
* 判断索引是否存在
*/
boolean isExists(String index) throws IOException;
/**
* 添加文档数据
*/
void add(String json) throws IOException;
}
SearchService实现类
@Service
public class SearchServiceImpl implements SearchService {
@Autowired
private RestHighLevelClient highLevelClient;
@Value("${spring.data.elasticsearch.index}")
private String index;
@Value("${spring.data.elasticsearch.type}")
private String type;
@Override
public void createIndex(String index, String type) throws IOException {
if (!isExists(index)) {
try {
//不存在 ,则创建索引
SearchUtils.create(index, type, highLevelClient);
} catch (IOException e) {
e.printStackTrace();
}
}
}
@Override
public boolean isExists(String index) throws IOException {
GetIndexRequest request = new GetIndexRequest();
request.indices(index);
return highLevelClient.indices().exists(request, RequestOptions.DEFAULT);
}
@Override
public void add(String json) throws IOException {
IndexRequest request = new IndexRequest(index,type);
request.source(json, XContentType.JSON);
highLevelClient.index(request,RequestOptions.DEFAULT);
}
}
SearchUtils工具类
public class SearchUtils {
public static void create(String index, String type, RestHighLevelClient restHighLevelClient) throws IOException {
Settings.Builder settings = Settings.builder().put("number_of_shards", 3)
.put("number_of_replicas", 1);
XContentBuilder contentBuilder = JsonXContent.contentBuilder()
.startObject()
.startObject("properties")
.startObject("apiName")
.field("type", "keyword")
.endObject()
.startObject("app_key")
.field("type", "keyword")
.endObject()
.startObject("content")
.field("type", "text")
.field("analyzer", "ik_max_word")
.endObject()
.startObject("remoteIp")
.field("type", "ip")
.endObject()
.startObject("responseTime")
.field("type", "date")
.field("format","yyyy-MM-dd HH:mm:ss")
.endObject()
.startObject("receiveTime")
.field("type", "date")
.field("format","yyyy-MM-dd HH:mm:ss")
.endObject()
.startObject("serverIp")
.field("type", "ip")
.endObject()
.startObject("totalTime")
.field("type", "long")
.endObject()
.endObject()
.endObject();
CreateIndexRequest request = new CreateIndexRequest(index)
.settings(settings)
.mapping(type,contentBuilder);
restHighLevelClient.indices().create(request, RequestOptions.DEFAULT);
}
}
编写SearchController,创建index
@RestController
@RequestMapping("/search")
public class SearchController {
@Autowired
private SearchService searchService;
@Value("${spring.data.elasticsearch.index}")
private String index;
@Value("${spring.data.elasticsearch.type}")
private String type;
@RequestMapping("/createIndex")
public String createIndex(){
try {
searchService.createIndex(index,type);
return "success";
} catch (IOException e) {
e.printStackTrace();
}
return "faild";
}
}
9.1.3 Search整合MQ接收消息
创建消费绑定接口,定义消息的消费者及监听的交换机
public interface ReceiveMessageStream {
@Input("openapilog")
SubscribableChannel subscribable_channel();
}
消费消息
@Component
public class ReceiveMessageListener {
@Autowired
private SearchService searchService;
@StreamListener("openapilog")
public void receive(String json){
try {
searchService.add(json);
} catch (IOException e) {
e.printStackTrace();
}
}
}
9.1.4 网关发送日志消息
定义LoggerBean实体类
public class LoggerBean {
private String apiName;
private String app_key;
private String content;
private String remoteIp;
private String serverIp;
private long totalTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date responseTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date receiveTime;
public String getApiName() {
return apiName;
}
public void setApiName(String apiName) {
this.apiName = apiName;
}
public String getApp_key() {
return app_key;
}
public void setApp_key(String app_key) {
this.app_key = app_key;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getRemoteIp() {
return remoteIp;
}
public void setRemoteIp(String remoteIp) {
this.remoteIp = remoteIp;
}
public String getServerIp() {
return serverIp;
}
public void setServerIp(String serverIp) {
this.serverIp = serverIp;
}
public long getTotalTime() {
return totalTime;
}
public void setTotalTime(long totalTime) {
this.totalTime = totalTime;
}
public Date getResponseTime() {
return responseTime;
}
public void setResponseTime(Date responseTime) {
this.responseTime = responseTime;
}
public Date getReceiveTime() {
return receiveTime;
}
public void setReceiveTime(Date receiveTime) {
this.receiveTime = receiveTime;
}
}
定义消息通道接口,定义消费生产者,发送消息到交换机
public interface SendMessageStream {
@Output("openapilog")
MessageChannel message_channel();
}
启动类绑定消息通道接口
@EnableBinding({
ReceviedAPIRoutingChangeStream.class, SendMessageStream.class})
编写发送消息的后置过滤器
@Component
public class LoggerFilter extends ZuulFilter {
@Autowired
private SendMessageStream sendMessageStream;
@Autowired
private ObjectMapper objectMapper;
@Override
public String filterType() {
return FilterConstants.POST_TYPE;
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
//发送消息
RequestContext currentContext = RequestContext.getCurrentContext();
HttpServletRequest request = currentContext.getRequest();
String method = request.getParameter("method");
String appkey = request.getParameter("appkey");
//封装日志信息的对象
LoggerBean loggerBean = new LoggerBean();
loggerBean.setApiName(method);
loggerBean.setApp_key(appkey);
//内容为请求参数信息
loggerBean.setContent(request.getQueryString());
loggerBean.setRemoteIp(request.getRemoteAddr());
try {
//设置服务器ip
loggerBean.setServerIp(InetAddress.getLocalHost().getHostAddress());
} catch (UnknownHostException e) {
e.printStackTrace();
}
//设置响应时间为当前时间
Date now = new Date();
loggerBean.setResponseTime(now);
//获取开始时间
Date startTime = (Date) currentContext.get("startTime");
//设置开始时间
loggerBean.setReceiveTime(startTime);
//设置总时间
loggerBean.setTotalTime(now.getTime()-startTime.getTime());
try {
//发送消息,并携带json数据
sendMessageStream.message_channel().send(new GenericMessage<String>(objectMapper.writeValueAsString(loggerBean)));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}
编写获取开始时间的前置过滤器
@Component
public class LoggerPreFilter extends ZuulFilter {
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
//保存了开始时间
RequestContext.getCurrentContext().put("startTime",new Date());
return null;
}
}
测试,查看es中是否添加了日志信息
9.1.5 查询ES操作
SearchService接口新增方法
/**
* 查询数据
* @param params json数据
* @return
* @throws Exception
*/
List<Map> search(String params) throws Exception;
/**
* 查询数量
* @param params
* @return
* @throws Exception
*/
long count(String params) throws Exception;
/**
* 计算某个时间范围内各个接口的请求的平均时间
* @param receiveStartTime
* @param receiveEndTime
* @return
*/
Map<String,Integer> countAvg(String receiveStartTime,String receiveEndTime) throws IOException;
SearchSeviceImpl实现类
@Override
public List<Map> search(String params) throws Exception {
boolean isHighLight = false;//默认不开启高亮显示
//填充根据条件查询到的信息
List<Map> list = new ArrayList<>();
SearchRequest searchRequest = new SearchRequest(index);
//把查询条件转成json
Map jsonMap = objectMapper.readValue(params, Map.class);
//获取查询的关键字
String requestContent = (String) jsonMap.get("content");
//判断各种查询条件,有的话就加查询条件进行查询
SearchSourceBuilder searchSourceBuilder = SearchUtils.getSourceBuilder(jsonMap);
if(!StringUtils.isEmpty(requestContent)){
//获取查询的关键字内容才可能有高亮内容
String highLightPreTag = (String) jsonMap.get("highLightPreTag");
String highLightPostTag = (String) jsonMap.get("highLightPostTag");
//设置高亮内容
HighlightBuilder highlightBuilder = new HighlightBuilder();
//如果要多个字段高亮,这项要为false highlightBuilder.requireFieldMatch(false).field("content").preTags(highLightPreTag).postTags(highLightPostTag);
searchSourceBuilder.highlighter(highlightBuilder);
isHighLight = true;
}
SearchResponse searchResponse = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
for (SearchHit searchHit : searchHits) {
Map<String, Object> sourceAsMap = searchHit.getSourceAsMap();
if(isHighLight){
//如果开启了高亮,则获取所有的高亮字段
Map<String, HighlightField> highlightFields = searchHit.getHighlightFields();
//获取content信息的高亮内容
HighlightField highlightField = highlightFields.get("content");
if(highlightField!=null){
Text[] fragments = highlightField.getFragments();
if(fragments!=null){
//获取高亮字符内容
String highlightString = fragments[0].toString();
//用高亮内容覆盖原来的内容
sourceAsMap.put("content",highlightString);
}
}
}
//把查询信息装到list中
list.add(sourceAsMap);
}
return list;
}
@Override
public long count(String params) throws Exception {
SearchRequest searchRequest = new SearchRequest(index);
//把查询条件转成json
Map jsonMap = objectMapper.readValue(params, Map.class);
//判断查询条件
SearchSourceBuilder searchSourceBuilder = SearchUtils.getSourceBuilder(jsonMap);
//设置查询条件
searchRequest.source(searchSourceBuilder);
//得到查询结果
SearchResponse searchResponse = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
//返回查询个数
return searchResponse.getHits().getTotalHits();
}
@Override
public Map<String, Integer> countAvg(String receiveStartTime,String receiveEndTime) throws IOException {
Map<String, Integer> resultMap = new HashMap<>();//method="order_get" 50 60 110/2 55
SearchRequest searchRequest = new SearchRequest(index);
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//请求的发起时间
searchSourceBuilder.query(QueryBuilders.rangeQuery("receiveTime").from(receiveStartTime,true).to(receiveEndTime));
// AggregationBuilders.terms("apiName") name为自定义,相当于sql中的group by
TermsAggregationBuilder aggregationBuilder = AggregationBuilders.terms("apiName").field("apiName");
//分组再统计
aggregationBuilder.subAggregation(AggregationBuilders.count("count")).field("apiName");
aggregationBuilder.subAggregation(AggregationBuilders.avg("avg_request_time")).field("totalTime");
//设置条件
searchSourceBuilder.aggregation(aggregationBuilder);
searchRequest.source(searchSourceBuilder);
//查询
SearchResponse searchResponse = highLevelClient.search(searchRequest, RequestOptions.DEFAULT);
Aggregations aggregations = searchResponse.getAggregations();
Terms aggregation = aggregations.get("apiName");
List<? extends Terms.Bucket> buckets = aggregation.getBuckets();
for (Terms.Bucket bucket : buckets) {
Object key = bucket.getKey();//服务的id
Avg avg_request_time = bucket.getAggregations().get("avg_request_time");
Double value = avg_request_time.getValue();
resultMap.put(key.toString(),value.intValue());
}
return resultMap;
}
封装查询条件的工具类
public static SearchSourceBuilder getSourceBuilder(Map jsonMap) {
//如果有分页的查询,则需要带start ,rows两个参数
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
int start = (int) jsonMap.get("start");
int rows = (int) jsonMap.get("rows");
sourceBuilder.from(start);
sourceBuilder.size(rows);
//判断各种可能的查询条件
BoolQueryBuilder boolQueryBuilder = null;
//获取查询的内容
String content = (String) jsonMap.get("content");
if (!StringUtils.isEmpty(content)) {
//设置了查询条件,生成boolQueryBuilder复合查询构造器
boolQueryBuilder = boolQueryBuilder == null ? QueryBuilders.boolQuery() : boolQueryBuilder;
boolQueryBuilder.must(QueryBuilders.matchQuery("content",requestContent));
}
String app_key = (String) jsonMap.get("appkey");
if (!StringUtils.isEmpty(app_key)) {
//设置了查询条件
boolQueryBuilder = boolQueryBuilder == null ? QueryBuilders.boolQuery() : boolQueryBuilder;
boolQueryBuilder.must(QueryBuilders.termQuery("app_key",app_key));
}
String apiName = (String) jsonMap.get("apiName");
if (!StringUtils.isEmpty(apiName)) {
//设置了查询条件
boolQueryBuilder = boolQueryBuilder == null ? QueryBuilders.boolQuery() : boolQueryBuilder;
boolQueryBuilder.must(QueryBuilders.termQuery("apiName",apiName));
}
String startTime = (String) jsonMap.get("startTime");
if (!StringUtils.isEmpty(startTime)) {
//设置了查询条件
boolQueryBuilder = boolQueryBuilder == null ? QueryBuilders.boolQuery() : boolQueryBuilder;
boolQueryBuilder.must(QueryBuilders.rangeQuery("receiveTime").gt(startTime));
}
String endTime = (String) jsonMap.get("endTime");
if (!StringUtils.isEmpty(endTime)) {
//设置了查询条件
boolQueryBuilder = boolQueryBuilder == null ? QueryBuilders.boolQuery() : boolQueryBuilder;
boolQueryBuilder.must(QueryBuilders.rangeQuery("responseTime").gt(endTime));
}
if(boolQueryBuilder!=null){
sourceBuilder.query(boolQueryBuilder);
}
return sourceBuilder;
}
SearchController实现
@RequestMapping("/searchLog")
public List<Map> searchLog(String params){
try {
return searchService.search(params);
} catch (Exception e) {
e.printStackTrace();
}
return new ArrayList<>();
}
@RequestMapping("/searchCount")
public Long searchCount(String params){
try {
return searchService.count(params);
} catch (Exception e) {
e.printStackTrace();
}
return 0L;
}
@RequestMapping("/avg")
public Map<String,Integer> getAvg(String receiveStartTime,String receiveEndTime){
try {
return searchService.countAvg(receiveStartTime,receiveEndTime);
} catch (Exception e) {
e.printStackTrace();
}
return new HashMap<>();
}
9.1.6 webmaster日志管理显示日志信息
十、 监控
我们的服务有很多状态需要监控,包括运行状态等等,其实监控就是不断获取数据来进行比较判断,在我们的开放平台中,我们以监控服务的平均运行时间为例子,对监控进行代码编写,使用的技术就是定时任务,需要解决的问题是我们的监控平台是集群,我们要解决分布式任务的问题,我们使用的技术是elastic-job,使用的注册中心是zookeeper
10.1 pom 依赖
创建openapi-monitor工程,导入核心依赖
<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: 192.168.206.142:2181,192.168.206.142:2182,192.168.206.142:2183
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.getName())).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 发送邮件
创建一个EventBean来封装消息
public class EventBean {
private String msg;
private EventType eventType;
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public EventType getEventType() {
return eventType;
}
public void setEventType(EventType eventType) {
this.eventType = eventType;
}
}
定义枚举类型EventType
public enum EventType {
OVERTIME;
}
此处我们使用的是发送邮件的方式来进行告警
@Component
public class MailEventListener {
@EventListener
public void onEven(EventBean eventBean) throws Exception {
switch (eventBean.getEventType()) {
case OVERTIME:
Properties properties = new Properties();
properties.put("mail.host", "smtp.163.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("[email protected]", "NGCTKBVTVUTDMDUF"); //返回认证信息
}
});//创建会话
session.setDebug(true);//控制台会显示一些调试的日志
Transport transport = session.getTransport();//类似于我们的 sql 中的 statement
transport.connect("smtp.163.com", "[email protected]", "NGCTKBVTVUTDMDUF");//通过邮箱和提供的密码来进行登录
Message message = new MimeMessage(session);//创建消息对象,也就是邮件内容对象
message.setFrom(new InternetAddress("[email protected]"));//设置对方显示的来自于谁的邮件
//设置收件人
message.setRecipients(Message.RecipientType.TO, new InternetAddress[]{
new InternetAddress("[email protected]")});//设置收件人
message.setSubject("接口超时预警邮件");//标题
message.setContent(eventBean.getMsg(), "text/html;charset=utf-8");//正文
transport.sendMessage(message, message.getAllRecipients());//发送
transport.close();
break;
}
}
十一 、 微信支付
10.1 介绍
在我们的开放平台中,因为部分api 需要付费,所以用户需要充值,我们此处使用的方式是微信支付,其他支付方式都类似,基本流程就是按照微信要求 ,传递参数过去发起支付,就相当于我们的网关一样,别人要访问我们的网关就要按照我们的要求传递参数
10.2 开发流程
10.2.1 时序图
原生支付模式时序图 |
---|
![]() |
10.2.2 业务流程
- 商户后台系统根据用户选购的商品生成订单。
- 用户确认支付后调用微信支付【统一下单API】生成预支付交易;
- 微信支付系统收到请求后生成预支付交易单,并返回交易会话的二维码链接code_url。
- 商户后台系统根据返回的code_url生成二维码。
- 用户打开微信“扫一扫”扫描二维码,微信客户端将扫码内容发送到微信支付系统。
- 微信支付系统收到客户端请求,验证链接有效性后发起用户支付,要求用户授权。
- 用户在微信客户端输入密码,确认支付后,微信客户端提交授权。
- 微信支付系统根据用户授权完成支付交易。
- 微信支付系统完成支付交易后给微信客户端返回交易结果,并将交易结果通过短信、微信消息提示用户。微信客户端展示支付交易结果页面。
- (微信支付系统通过发送异步消息通知商户后台系统支付结果。商户后台系统需回复接收情况,通知微信后台系统不再发送该单的支付通知。
- 未收到支付通知的情况,商户后台系统调用【查询订单API】。
- 商户确认订单已支付后给用户发货。
10.3 统一下单
通过统一下单地址向微信发起支付请求,并获取支付连接信息,然后生成二维码进行扫码支付,参数和返回值信息详情参考
10.4 签名规则
与我们的网关一样,客户调用我们的网关的时候需要传递签名,我们在发起支付请求的时候微信也会要求我们传递签名,因此我们需要在我们的程序中按照微信的要求生成签名传递过去,同样,客户在请求我们的网关的时候也需要在他们那边代码生成签名
微信支付的签名规则参考微信安全规范
10.5 核心代码
创建payment-center工程,是一个war工程,并导入相关依赖
10.5.1 pom 主要依赖
<dependencies>
<!--解析 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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
application.yml文件
server:
port: 49000
spring:
mvc:
view:
prefix: /WEB-INF/jsp/
suffix: .jsp
布署war项目,启动类需要继承SpringBootServletInitializer
@SpringBootApplication
public class PaymentCenterApplication extends SpringBootServletInitializer {
public static void main(String[] args){
SpringApplication.run(PaymentCenterApplication.class,args);
}
//web项目需要重写的方法
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(PaymentCenterApplication.class);
}
}
10.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
}
10.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");
}
}
10.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;
}
}
10.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();
}
}
10.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 地址用于生成二维码
}
}
10.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;
}
}
}
10.6 其他内容
其他内容就是编写页面,将需要的商品信息价格等拿到然后发起支付,另外再写一个用于接收微信返回结果的 servlet 处理结果,并将最终处理结果返回给微信,要想收支付结果必须将程序运行到线上服务器,返回结果格式参考支付结果通知
修改index页面
<body>
<form action="/payment/createOrder">
购买的商品:<input type="text" name="body"/>
<input type="submit">
</form>
</body>
</html>
编写payment页面
<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
<title>Title</title>
</head>
<body>
订单号:${orderId}
<img src="/img/payimg"/>
</body>
</html>
OrderController编写
@Controller
@RequestMapping("/payment")
public class OrderController {
@RequestMapping("/createOrder")
public String createOrder(String body, Model model, HttpSession httpSession) {
UUID uuid = UUID.randomUUID();
String orderId = uuid.toString().replaceAll("-", "");
try {
//发起请求,并获取支持的二维码字符串
String wxUrl = PayCommonUtil.weixin_pay("1", body, orderId);
//生成二维码
BufferedImage bufferedImage = ZxingUtil.createImage(wxUrl, 300, 300);
model.addAttribute("orderId",orderId);
httpSession.setAttribute("img",bufferedImage);
} catch (Exception e) {
e.printStackTrace();
}
return "payment";
}
}
ImageController编写
@Controller
@RequestMapping("img")
public class ImageController {
@RequestMapping("/payimg")
public void getImage(HttpSession session, HttpServletResponse response){
BufferedImage img = (BufferedImage) session.getAttribute("img");
try {
ImageIO.write(img,"JPEG",response.getOutputStream());
} catch (IOException e) {
e.printStackTrace();
}
}
}
页面展示订单号及二维码
编写ResultController,实现完成支付后的回调处理
@RestController
@RequestMapping("/result")
public class ResultController {
@RequestMapping("/payresult")
public String result(HttpServletRequest request, HttpServletResponse response){
try{
ServletInputStream inputStream = request.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String line = null;
StringBuilder stringBuilder = new StringBuilder();
while((line=bufferedReader.readLine())!=null){
stringBuilder.append(line);
}
//将微信传递给我们的参数转成map
Map<String,String> map = XMLUtil.doXMLParse(stringBuilder.toString());
TreeMap treeMap = new TreeMap();
treeMap.putAll(map);
if(PayCommonUtil.isTenpaySign("UTF-8",treeMap, PayConfigUtil.API_KEY)){
//签名较验成功
String oId = map.get("out_trade_no");
System.err.println("订单号: " + oId+"支付成功!");
//返回数据给微信
return "<xml>" +
" <return_code><![CDATA[SUCCESS]]></return_code>" +
" <return_msg><![CDATA[OK]]></return_msg>" +
"</xml>";
}
}catch (Exception e){
e.printStackTrace();
}
//除了返回上面的xml,其它都算失败
return "success";
}
}
十二、 WebSocket
- 当我们扫码完成后,可能会需要跳转页面,因此页面必须知道支付结果,在我们的生活中还有其他的场景如扫码登陆等都是页面需要知道结果的
- 页面知道结果的方式主要是两种:第一种页面不断轮询服务器获取结果.第二种服务器知道结果后主动通知页面
- 主动通知页面的技术我们选择使用 WebSocket,通过和服务器的长连接会话技术来让服务器主动通知页面
12.1 WebSocket 服务端搭建
所谓的长连接就是和服务器建立一个连接,所以我们需要一个服务端程序并指定连接地址,由于页面跳转只是 Websocket 能实现的功能之一,并不是全部,所以我们将 Websocket单独抽离出来作为一个服务端,当有其他功能需要的时候可以直接访问
12.1.1 pom 主要依赖
我们使用的是 springboot 方式搭建,创建一个websocket-center的war工程,并引入相关依赖
<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>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
启动类继承SpringBootServletInitializer
@SpringBootApplication
@EnableBinding({
ReceivePaymentMessageStream.class})
public class WebsockerApplication extends SpringBootServletInitializer {
public static void main(String[] args){
SpringApplication.run(WebsockerApplication.class,args);
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(WebsockerApplication.class);
}
}
12.1.2 Spring拦截器
本拦截器的主要功能是拦截请求后将我们需要的数据进行获取保存,方便后面使用
@Component
public class PaymentInterceptor extends HttpSessionHandshakeInterceptor {
//握手前,也就是会话连接之前
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
System.err.println("握手之前..");
//约定,地址最后一个"/"的内容作为会话的唯一标识,这个由页面传递过来
String uri = request.getURI().toString();
String name = uri.substring(uri.lastIndexOf("/")+1);
//将name保存起来,后面可以取
attributes.put("name",name);
return super.beforeHandshake(request, response, wsHandler, attributes);
}
//握手后,也就是会话连接之后
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, @Nullable Exception ex) {
System.err.println("握手之后..");
super.afterHandshake(request, response, wsHandler, ex);
}
}
10.2.3 消息处理类
本类主要是对消息进行处理,如收到客户端发送的消息,由于我们的目标是主动向客户端发消息,不需要客户端向服务端发消息,所以本类的主要作用是保存请求的连接
/**
* 文本消息的处理器
*/
@Component
public class PaymentHandler extends TextWebSocketHandler {
private static final Map<String,WebSocketSession> allClients = = new ConcurrentHashMap<>();//用于缓存所有的用户和连接之间的关系
/**
* 当和用户成功建立连接的时候会调用此方法,在此方法内部应该保存连接
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//获取我们在拦截器中保存的name
String name = (String) session.getAttributes().get("name");
allClients.put(name,session);//保存会话
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String name = (String) session.getAttributes().get("name");
allClients.remove(name);//删除会话
super.afterConnectionClosed(session, status);
}
//发送消息给前端
public static void sendMessage(String name, String message) {
WebSocketSession webSocketSession = allClients.get(name);
if (webSocketSession != null && webSocketSession.isOpen()) {
try {
webSocketSession.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
10.2.4 WebSocket 配置类
主要是配置我们的连接地址,以及将拦截器和处理器配置上
@Configuration
@EnableWebSocket
public class WebsocketConfig implements WebSocketConfigurer {
@Autowired
private PaymentHandler paymentHandler;
@Autowired
private PaymentInterceptor interceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
//绑定拦截器,并设置websocket的连接地址,及跨域访问
registry.addHandler(paymentHandler,"/websocket/*").addInterceptors(interceptor).setAllowedOrigins("*");
}
}
10.2.5 测试 html
本 html 是测试 websocket 的,核心代码主要是 websocket 的相关操作回调, 只需要根据具体业务修改具体操作即可
比如我们的微信支付的付款页面,只需要和 websocket 建立连接并处理返回消息即可,别的不需要
在payment-center项目中的payment页面新增如下内容
<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
<title>Title</title>
</head>
<body>
订单号:${orderId}
<img src="${pageContext.request.contextPath}/img/payimg"/>
<br>
<br>
<br>
<span id="content"></span>
</body>
<script type="text/javascript">
var websocket = null;
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//和 websocket 服务器建立连接,此处根据实际情况填写具体的地址
websocket = new WebSocket("ws://47.113.117.108:8081/websocket-center/websocket/${orderId}");
} 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 setMessageInnerHTML(content) {
var span = document.getElementById("content");
span.innerHTML = content;
if("1"==content){
location.href="http:///www.baidu.com";
}
}
</script>
</html>
12.2 整合 MQ
由于我们的 websocket 是独立的服务端,我们的支付系统的页面链接到我们的 websocket,我们通过 websocket 通知页面结果,但是 websocket 并不知道支付结果,我们需要在支付系统中将结果通知 websocket,因为可能还会有其他地方需要知道支付结果,比如需要要给用户加钱等操作,所以我们在支付完成后发送 MQ 消息来通知所有的消费者成功,这样我们的 websocket 就拿到结果了,就可以通知页面了
payment-center工程添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
application.yml文件配置MQ信息
spring:
rabbitmq:
host: 47.113.117.108
port: 5672
username: rabbit
password: 123456
定义发送消息通道接口,并在启动类上绑定该接口
public interface SendPaymentMessageStream {
@Output("paymentExchange")
MessageChannel message_channel();
}
ResultController中,当支付成功后,进行事件的推送,需要创建一个EventBean对象
public class EventBean {
private String oid; //订单号
private int totalPrice; //支付价格
public String getOid() {
return oid;
}
public void setOid(String oid) {
this.oid = oid;
}
public int getTotalPrice() {
return totalPrice;
}
public void setTotalPrice(int totalPrice) {
this.totalPrice = totalPrice;
}
}
@RestController
@RequestMapping("/result")
public class ResultController {
@Autowired
private ApplicationContext context;
@RequestMapping("/payresult")
public String result(HttpServletRequest request, HttpServletResponse response){
try{
ServletInputStream inputStream = request.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String line = null;
StringBuilder stringBuilder = new StringBuilder();
while((line=bufferedReader.readLine())!=null){
stringBuilder.append(line);
}
//将微信传递给我们的参数转成map
Map<String,String> map = XMLUtil.doXMLParse(stringBuilder.toString());
TreeMap treeMap = new TreeMap();
treeMap.putAll(map);
if(PayCommonUtil.isTenpaySign("UTF-8",treeMap, PayConfigUtil.API_KEY)){
//签名较验成功
String oId = map.get("out_trade_no");
System.err.println("订单号: " + oId+"支付成功!");
//事件推送
EventBean eventBean = new EventBean();
eventBean.setOid(oId);
eventBean.setTotalPrice(Integer.parseInt(map.get("total_fee")));
context.publishEvent(eventBean);
//返回数据给微信
return "<xml>" +
" <return_code><![CDATA[SUCCESS]]></return_code>" +
" <return_msg><![CDATA[OK]]></return_msg>" +
"</xml>";
}
}catch (Exception e){
e.printStackTrace();
}
//除了返回上面的xml,其它都算失败
return "success";
}
}
创建事件的监听者,并发送消息给websocket-master
@Component
public class PaymentEventListener {
@Autowired
private SendPaymentMessageStream sendPaymentMessageStream;
@Autowired
private ObjectMapper objectMapper;
@EventListener
public void onEvent(EventBean eventBean) {
try {
//发送已经支付成功结果的消息
MessageChannel messageChannel = sendPaymentMessageStream.message_channel();
messageChannel.send(new GenericMessage<String>(objectMapper.writeValueAsString(eventBean)));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
websocket-master工程创建接收消息通道接口,并在启动类中绑定接口
public interface ReceivePaymentMessageStream {
@Input("paymentExchange")
SubscribableChannel subscribable_channel();
}
application.yml文件配置MQ信息
spring:
rabbitmq:
host: 47.113.117.108
port: 5672
username: rabbit
password: 123456
创建监听MQ消息的PaymentListener类
@Component
public class PaymentListener {
@Autowired
private ObjectMapper objectMapper;
@StreamListener("paymentExchange")
public void onMessage(String json){
try {
Map map = objectMapper.readValue(json, Map.class);
String oid = (String) map.get("oid");//获取订单号,订单号就是会话的唯一标识
//发消息到页面
PaymentHandler.sendMessage(oid,"1");
} catch (IOException e) {
e.printStackTrace();
}
}
}
发消息给页面,PaymentHandler中的编码
//发送消息给前端
public static void sendMessage(String name, String message) {
WebSocketSession webSocketSession = allClients.get(name);
if (webSocketSession != null && webSocketSession.isOpen()) {
try {
webSocketSession.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
}
payment页面的编写
function setMessageInnerHTML(content) {
var span = document.getElementById("content");
span.innerHTML = content;
if("1"==content){
location.href="http:///www.baidu.com";
}
}
需要把两个工程都打包布署到线上环境的Tomcat中
payment-center布署到tomcat-compose中,端口号为8080
websocket-center布署到tomcat2-compose中,端口为8081
参数配置修改:public static String NOTIFY_URL = “http://47.113.117.108:8080/payment-center/result/payment”;//我们的回调地址,用于接收微信告诉我们的支付结果