API网关
考虑一个业务场景:提交订单时,要检查用户是否登陆,检查库存是否足够,再提交订单。而这三个操作属于三个不同的微服务,这样调用请求时,要建立三个连接比较耗时。
API网关的作用就类似于hao123网站,作为一个门户,只需此网站,就可以面向所有的网站。
前端只面向API网关。
API网关的常见作用
- 身份验证和安全
- 审查和监测(当前业务的执行时间,调用了什么服务,用户行为记录)
- 动态路由
- 压力测试
- 负载均衡
- 静态相应处理
基础环境构建
gateway模块
环境配置
采用guns构建业务基础环境,简化开发。
首先到码云上下载guns的源代码:https://gitee.com/naan1993/guns/
使用IDEA在本地打开下载好的源代码工程。进入到rest模块目录下,在项目中找到配置文件,根据自己的实际情况修改相应的数据库配置和项目端口配置。
datasource:
url: jdbc:mysql://127.0.0.1:3306/guns_rest?autoReconnect=true&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
# 自己的用户名和密码
username: root
password:
filters: log4j,wall,mergeStat
- 复制rest模块,并改名为guns-gateway模块,修改此模块的pom文件
- 修改总的pom文件,添加guns-gateway模块的依赖
<modules>
<module>guns-admin</module>
<module>guns-core</module>
<module>guns-rest</module>
<module>guns-generator</module>
<module>guns-gateway</module>
</modules>
- 在工程中整合dubbo
在gateway的pom文件中加入dubbo和zookeeper依赖
修改配置文件
最后在启动类上添加注解:
@EnableDubboConfiguration
到这里dubbo的集成就结束了。首先打开zookeeper,然后再启动这个模块。如果zookeeper的日志中打印出注册信息说明注册成功。
抽离公共API
将guns-cores复制一份改为guns-api,将没用的包全部删除,然后按照上面讲过的步骤对其进行修改。api模块中装的是所有模块都会依赖到的公共接口。在API中写好所有的接口之后,要install,放入Maven库中。
在gateway以及其他模块中,直接在pom文件中进行配置,引入API模块就可以了。这样就避免了每个模块都要写API。
到这里架构搭建就基本完成了。
用户模块开发
- 学会API网关权限验证和其他服务交互
- 学会springboot的自定义配置
- 学会Dubbo负载均衡策略选择和使用
用户表结构
DROP TABLE IF EXISTS mooc_user_t;
CREATE TABLE mooc_user_t(
UUID INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键编号',
user_name VARCHAR(50) COMMENT '用户账号',
user_pwd VARCHAR(50) COMMENT '用户密码',
nick_name VARCHAR(50) COMMENT '用户昵称',
user_sex INT COMMENT '用户性别 0-男,1-女',
birthday VARCHAR(50) COMMENT '出生日期',
email VARCHAR(50) COMMENT '用户邮箱',
user_phone VARCHAR(50) COMMENT '用户手机号',
address VARCHAR(50) COMMENT '用户住址',
head_url VARCHAR(50) COMMENT '头像URL',
biography VARCHAR(200) COMMENT '个人介绍',
life_state INT COMMENT '生活状态 0-单身,1-热恋中,2-已婚,3-为人父母',
begin_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间'
) COMMENT '用户表' ENGINE = INNODB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;
用户模块搭建
首先复制gateway模块,取名为guns-user,修改配置文件,pom文件和项目端口等信息。
建立用户信息类:UserModel(注册所需信息,用户名密码手机号等)和UserInfoModel(查询的用户信息。除了注册所需用户名邮箱之外的信息,如昵称、性别、生日、个性签名等)。记得添加序列化标识。
在API模块中添加UserAPI包,并定义接口中的方法,包括用户注册与登录,用户信息获取与修改等方法。
public interface UserAPI {
//login有返回值:返回用户的UUID —可以把当前活跃用户放到Redis缓存中,并设置一个TTL。
//JWT可能七天以后再失效,但只要缓存过期了,即使用户携带JWT也需要重新登录。
int login(String username,String password);
boolean register(UserModel userModel);
boolean checkUsername(String username);
UserInfoModel getUserInfo(int uuid);
UserInfoModel updateUserInfo(UserInfoModel userInfoModel);
}
在user模块中建立userServiceImpl类,实现这些接口中的方法。
在gateway层写与前端交互的UserController方法。
权限验证—采用JWT
修改gateway中的yaml文件,增加忽略列表的配置。
rest:
auth-open: true #jwt鉴权机制是否开启(true或者false)
sign-open: true #签名机制是否开启(true或false)
jwt:
header: Authorization #http请求头所需要的字段
secret: mySecret #jwt秘钥
expiration: 604800 #7天 单位:秒
auth-path: auth #认证请求的路径
md5-key: randomKey #md5加密混淆key
ignore-url: /user/register,/user/check,/film/getIndex,/film/getConditionList,/film/getFilms,/film/films,/cinema/getCinemas,/cinema/getCondition,/cinema/getFields,/cinema/getFieldInfo #忽略列表,有些服务不需要JWT鉴权
server:
port: 8082 #项目端口
同时,在gateway中的JwtProperties(Jwt配置类)中加入ignoreUrl变量(springBoot会自动根据驼峰原则定位到yml中的配置并读取)。
@Configuration
//自动读取yml配置文件中以JWT_PREFIX为开头的配置
@ConfigurationProperties(prefix = JwtProperties.JWT_PREFIX)
public class JwtProperties {
public static final String JWT_PREFIX = "jwt";
private String header = "Authorization";
private String secret = "defaultSecret";
private Long expiration = 604800L;
private String authPath = "auth";
private String md5Key = "randomKey";
private String ignoreUrl = "";
//相应的get set方法
//...
JWT的生成—AuthController类,使用jwtTokenUtil生成token并返回。在用户登录时,以后的请求,用户都会携带这个token供我们验证。
@RestController
public class AuthController {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Reference(interfaceClass = UserAPI.class)
private UserAPI userAPI;
@RequestMapping(value = "${jwt.auth-path}")
public ResponseVO createAuthenticationToken(AuthRequest authRequest) {
boolean validate = true;
//判断用户是否存在
int userId = userAPI.login(authRequest.getUserName(),authRequest.getPassword());
if(userId==0){
validate=false;
}
if (validate) {
//randomKey和token已经生成完毕;
final String randomKey = jwtTokenUtil.getRandomKey();
final String token = jwtTokenUtil.generateToken(""+userId, randomKey);
//返回值
return ResponseVO.success(new AuthResponse(token, randomKey));
} else {
return ResponseVO.serviceFail("用户名或密码错误");
}
}
}
JWT的使用—AuthFilter类。客户端所有请求首先走这里进行验证,并在这里保存用户信息。
public class AuthFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private JwtProperties jwtProperties;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request.getServletPath().equals("/" + jwtProperties.getAuthPath())) {
chain.doFilter(request, response);
return;
}
//配置忽略列表
String ignoreUrl = jwtProperties.getIgnoreUrl();
String[] ignoreUrls = ignoreUrl.split(",");
for (int i=0;i<ignoreUrls.length;i++){
if(request.getServletPath().startsWith(ignoreUrls[i])){
chain.doFilter(request,response);
return;
}
}
final String requestHeader = request.getHeader(jwtProperties.getHeader());
String authToken = null;
if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
authToken = requestHeader.substring(7);
//通过token获取UserID,并将之存入Threadlocal,以便后续业务调用
String userId = jwtTokenUtil.getUsernameFromToken(authToken);
if(userId==null){
return;
}else {
//保存当前用户
CurrentUser.saveUserId(userId);
}
//验证token是否过期,包含了验证jwt是否正确
try {
boolean flag = jwtTokenUtil.isTokenExpired(authToken);
if (flag) {
RenderUtil.renderJson(response, new ErrorTip(BizExceptionEnum.TOKEN_EXPIRED.getCode(), BizExceptionEnum.TOKEN_EXPIRED.getMessage()));
return;
}
} catch (JwtException e) {
//有异常就是token解析失败
RenderUtil.renderJson(response, new ErrorTip(BizExceptionEnum.TOKEN_ERROR.getCode(), BizExceptionEnum.TOKEN_ERROR.getMessage()));
return;
}
} else {
//header没有带Bearer字段
RenderUtil.renderJson(response, new ErrorTip(BizExceptionEnum.TOKEN_ERROR.getCode(), BizExceptionEnum.TOKEN_ERROR.getMessage()));
return;
}
chain.doFilter(request, response);
}
}
JWT存在的问题:在退出登录/修改密码时如何让JWT失效?
- 将 token 存入 DB(如 Redis)中,失效则删除;但增加了一个每次校验时候都要先从 DB 中查询 token 是否存在的步骤,而且违背了 JWT 的无状态原则(这不就和 session 一样了么?)。
- 在 JWT 中增加一个版本号字段,失效则改变该版本号。服务器端可以设计用户id和一个版本号的对应关系,版本号设置成自动增长的整型数字,或者就是一个时间戳,这样用之前的token去登录时,判断版本号不一致就让重新登录,分配新的版本号,服务器端每次登录以后就更新一下对应数据的版本号。
- 在服务端设置加密的 key 时,为每个用户生成唯一的 key,失效则改变该 key。
在本项目中,我们在设计login方法时,增加了一个返回值。返回值即是用户的uuid。在用户登录成功后,我们可以把这个uuid存在Redis中,并设置过期时间。后续在AuthController进行验证时,如果在Redis中找不到uuid,即使携带JWT,也要重新登录。
JWT在某些方面,比Session要优越。比如,如何允许用户只能在最近五个设备登录?
session: 使用数据库,创建 token 数据库表,有 id, token, user_id 三个字段,user 与 token 表为 1:m 关系。每次登录添加一行记录。根据 token 获取 user_id,再根据 user_id 获取该用户有多少设备登录,超过 5 个,则删除最小 id 一行。
jwt: 使用计数器,在用户表中添加字段 count,默认值为 0,每次登录 count 字段自增1,每次登录创建的 jwt 的 Payload 中携带数据 current_count 为用户的 count 值。每次请求权限接口时,根据 JWT 获取 count 以及 current_count,根据 user_id 查用户表获取 count,判断与 current_count 差值是否小于 5。
对于这个需求,JWT 略简单些,而使用 session 还需要多维护一张 token 表。
业务功能开发与dubbo负载均衡
使用代码生成器生成数据项
实现方法
验证忽略列表
申请JWT
使用JWT访问其他权限功能
报错
java.lang.IllegalStateException:Ambiguous mapping. Cannot map 'UserController' method
"name"属性的作用是为该映射起一个名字,而并不表示该映射的具体路径;"value"属性表示该映射的具体路径。
java.lang.IllegalStateException:Serialized class com.stylefeng.guns.api.user.UserInfoModel must implements java.io.Serializable
必须实现序列化接口:
必须先启动服务提供者,否则会报错—Dubbo中默认启动检查。关闭启动检查:check = false
com.alibaba.dubbo.rpc.RpcException:No provider available from register localhost:2181 for service com.stylefeng.guns.api.user.UserAPI...
负载均衡
Dubbo中的负载均衡策略
策略名称 | 策略描述 |
Random | 随机,按权重设置随机概率 |
RoundRobin | 轮询,按公约后的权重设置轮询比率 |
LeastActive | 最少活跃调用数 使慢的提供者收到更少请求 |
ConsistentHash | 一致性Hash 相同参数的请求总是发到同一提供者。当某台提供者挂了以后,基于虚拟节点,不会引发剧烈变动 |