项目总结
项目开发总结
本次项目开发学习前前后后花费了数半个月,虽然学习的项目章节不太多,但也受益匪浅
不论是从最开始的环境搭建,还是之后的微服务、小程序、网关配置等等方面,也是学到了很多技巧,但这其中我也遇到了很多困难,本次开发总结我将详细讲述我的开发流程,其中遇到的一些困难,以及如何去解决这些困难
环境配置
首先是在最开始的环境配置当中,我们需要先下载虚拟机环境(模拟公司、企业当中的服务器)

可看到虚拟机环境已经帮我们配置好了,这些服务是一些正在运行当中的服务
然后就是虚拟机也需要配置一下内网IP方便后续服务的访问

内网配置好后在配置本地ip

可以看到本地服务地址与虚拟机地址是一样的,然后后面就是一些服务

在这之后我们就可以成功的访问服务了
我们先从本地访问服务域名,通过本地hosts文件配置,将服务地址传输到虚拟机,再通过虚拟机nginx服务将其反向代理到各个服务

Maven私服
Maven私服配置根据服务不同,进行相应配置
git代码管理
对于代码的管理,我觉得是相对重要的,这在开发当中是必不可少的
分支管理
- 不同功能的分支形成代码隔离,发布时从主分支进行代码发布,测试在测试分支,开发在开发分支,减少出错。。
- 分支之间互不影响,开发者可以独立开发,提高团队开发效率
分支管理策略
master
主分支,用于部署生产环境的分支,是所有分支的主干,与当前线上代码保持统一
dev
开发分支,开发各类分支的合并分支,即当前最新功能分支
feature
功能分支,一般用于新技术的开发,开发完成后合并到master/dev分支,随后便可删除分支
release
用于测试的分支,如遇bug,将其推到 fixbug分支进行修复
fixbug
bug修复分支,完成修复后,将其合并到master/dev
tag标签
master发布开发环境后,用tag来进行版本标记
# 列出所有本地分支
$ git branch
# 列出所有远程分支
$ git branch -r
# 列出所有本地分支和远程分支
$ git branch -a
# 新建一个分支,但依然停留在当前分支
$ git branch [branch-name]
# 新建一个分支,并切换到该分支
$ git checkout -b [branch]
# 新建一个分支,指向指定commit
$ git branch [branch] [commit]
# 新建一个分支,与指定的远程分支建立追踪关系
$ git branch --track [branch] [remote-branch]
# 切换到指定分支,并更新工作区
$ git checkout [branch-name]
# 切换到上一个分支
$ git checkout -
# 建立追踪关系,在现有分支与指定的远程分支之间
$ git branch --set-upstream [branch] [remote-branch]
# 合并指定分支到当前分支
$ git merge [branch] # 快速合并
$ git merge --no-ff [branch] # 非快速合并(建议使用)
# 选择一个commit,合并进当前分支
$ git cherry-pick [commit]
# 删除分支
$ git branch -d [branch-name]
# 删除远程分支
$ git push origin --delete [branch-name]
$ git branch -dr [remote/branch]
然后后面就是一些分支开发测试(这里就不做多的篇幅解释)

项目列表
这些服务后续会用到

Jenkins
在学习本次项目之前,我也才只接触Jenkins过一次,那还是在上次2023年的一次靶机练习,记得当时也是打的迷迷糊糊,不过经过本次学习后,我也更深层次的体验到了Jenkins的用法
从最开始的任务构建到参数、版本号、微服务地址、服务名称、端口号、git地址等等一系列的配置,在到脚本构建,一键运行,就完成了项目的一个搭建,通过测试便可查看是否搭建成功


测试成功后,将Jenkins中web钩子设定,绑定git,测试推送
Jenkins总结
从Jenkins的使用流程来看,我认为Jenkins就是用于将git进行连接配置,然后直接启动服务的中间件,他可以直接将服务上传到docker容器中(前提是在环境中已经配置好了docker,Maven,git),他方便我们对服务的一个管理,是一个强大的中间件。
登录业务
关于登录业务,我觉得文档上给的图就已经完美诠释了整个登录流程

首先前端先从WebManager 后端服务管理中获取 验证码 (并携带key)
后端服务管理生成验证码
讲验证码和key值传入Redis
Redis返回值
后端返回验证码图传给前端
前端输入账号密码验证码
后端从Redis中进行匹配验证码
Redis返回结果
后端进行校验
- 假如验证码匹配失败,就返回验证码错误
- 假如匹配成功,从Redis中删除验证码值,返回后端
进行用户名密码校验
- 错误:返回前端用户名或密码错误
- 成功:生成token,返回登录成功
验证码功能的实现
这个功能的实现文档中是基于算法做的验证码,我在实际的过程中使用的是4位随机数所做的验证码,其实也大差不差
后端实现过程
从git拉取代码 sl-express-ms-web-manager
在idea中进行基础jdk、Manven配置 然后就可以开始实现了
hutool验证码文档说明

@Override
public void createCaptcha(String key, HttpServletResponse response) throws IOException {
//1. 生成验证码,指定宽、高、字符个数、干扰线数
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(115, 38, 0, 10);
//1.1 自己创建一个随机数范围,验证码为四位随机数
String random = "123456789abcdefghijklmnopqrstuvwsyz";
RandomGenerator randomGenerator = new RandomGenerator(random,4);
lineCaptcha.setGenerator(randomGenerator);
//1.2 获取生成的验证码值
lineCaptcha.createCode();
String Code = lineCaptcha.getCode();
//2. 将验证码的值写入到redis,有效期为1分钟
String redisKey = CAPTCHA_REDIS_KEY + key;
this.stringRedisTemplate.opsForValue().set(redisKey, Code, Duration.ofMinutes(1));
//输出到页面
response.setHeader(HttpHeaders.PRAGMA, "No-cache");
response.setHeader(HttpHeaders.CACHE_CONTROL, "No-cache");
response.setDateHeader(HttpHeaders.EXPIRES, 0L);
lineCaptcha.write(response.getOutputStream());
}
在Controller中调用Service中的方法:

测试
可以看到验证码

这样这么一个接口就完成了
然后接下来我们还需要将前端接口进行连接
前端部署
同样的前端,我们先需要将代码拉取

修改.env.production.stu文件,设置前后端交互接口改为本机地址和端口:

提交到git中,使用Jenkins部署
选择develop分支,开始构建

访问地址进行测试:http://admin.sl-express.com/

可以看到请求为200

登录功能实现
刚刚实现的是验证码功能,登录功能还没有实现,我们现在紧接着实现登录
在AuthServiceImpl中实现代码


在Controller中调用:

测试
可以看到测试有数据

权限管家
权限管家是一个通用的权限管理中台服务,在权限管家中可以管理用户,管理后台系统的菜单,以及角色的管理。
改造登录接口为权限管家的接口

测试
可以看到测试成功

到这里我们的登录接口也完成了
用户端登录和统一网关
问题
首先是最开始的微信小程序,
在登录接口和小程序登录代码实现完成后
小程序还是会出现400系列错误问题
然后就是token实现后不出token的问题
解决
对于登录后还是会出现400系错误的问题
这个是由于此项目是我们学校老师为我们配置后出现的小程序账号、密钥不同的原因,导致数据不能正常进行传递引发的客户端错误
通过项目测试打印出账号,进行更换实现成功配置
(这边报错的图忘记截出来了只有最后的实现了)
实现流程
接下来就该实现用户端的登录和网关配置
因为用户端是基于微信小程序开发的
微信小程序登录的流程:

- 点击按钮动态获取phoneCode
- wx.login()获取登录code
- request向后端发送code、phoneCode
- 后端接收并校验登录凭据appid+appsecret+code
- 微信接口进行处理
- 返回session_key+openid等
- 通过openid 向数据库进行用户查询
- 后端接收返回用户信息,如果是不存在说明新用户,返回null
- 后端向微信接口获取用户手机号接口
- 微信进行内部处理
- 返回手机号
- 后端新增用户或更新用户手机号
- 生成token
- 将token传入微信小程序
- 微信小程序wx.request()请求头携带token
- 后端校验token,获取到openid
- 后端进行处理
- 将响应数据返回微信小程序
登录实现
env.js中修改本地接口

可以看到本地请求为502
这是由于本地网关并没有配置导致的后面会进行配置

请求参数(2个参数,code:登录临时凭证,phoneCode:获取手机号的凭证):

拉取后端代码 sl-express-ms-web-customer
登录接口
登录接口的实现是在com.sl.ms.web.customer.controller.UserController#login中

可以看到代码是由UserLoginVO响应数据对象和UserLoginRequestVO请求参数对象组成
我们现看看UserLoginVO响应数据对象
package com.sl.ms.web.customer.vo.user;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO {
@ApiModelProperty("微信唯一标识符")
private String openid;
@ApiModelProperty("短令牌,有效期较短")
private String accessToken;
@ApiModelProperty("长令牌,有效期较长")
private String refreshToken;
@ApiModelProperty("是否绑定手机号 0否 1是")
private Integer binding;
}
UserLoginRequestVO请求参数对象组成
package com.sl.ms.web.customer.vo.user;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* C端用户登录
*/
@Data
public class UserLoginRequestVO {
@ApiModelProperty("登录临时凭证")
private String code;
@ApiModelProperty("手机号临时凭证")
private String phoneCode;
}
用户微服务
用户端通过微信登陆流程图

在Jenkins部署sl-express-ms-user-service


数据库已在服务器部署
小程序登录
@Value("${sl.wechat.appid}")
private String appid;
@Value("${sl.wechat.secret}")
private String secret;
public static final String LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session";
private static final int TIMEOUT = 20000;
@Override
public JSONObject getOpenid(String code) throws IOException {
//文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
//1. 封装参数
Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("appid", this.appid) //小程序 appId
.put("secret", this.secret) //小程序 appSecret
.put("js_code", code) // 登录时获取的 code,可通过wx.login获取
.put("grant_type", "authorization_code") //授权类型
.build();
//2. 发送get请求
HttpResponse response = HttpRequest.get(LOGIN_URL) //设置get请求url
.form(requestParam) //设置表单参数
.timeout(TIMEOUT) //设置超时时间,20s
.execute();//执行请求
if (response.isOk()) {
// 3. 解析响应的结果,如果出现错误抛出异常
JSONObject jsonObject = JSONUtil.parseObj(response.body());
if (jsonObject.containsKey("errcode")) {
throw new SLWebException(jsonObject.toString());
}
return jsonObject;
}
String errMsg = StrUtil.format("调用微信登录接口出错! code = {}", code);
throw new SLWebException(errMsg);
}

通过小程序登录测试

获取手机号
想要获取到手机号的要先获取到微信access_token,在获取到手机号,否则获取不到
com.sl.ms.web.customer.service.impl


@Test
void getPhone() throws IOException {
//在测试用例中输入自己获取到的 phonecode
String phone = wechatService.getPhone("1aaaaa1111");
System.out.println(phone);
}
实现登录
com.sl.ms.web.customer.service.impl.MemberServiceImpl#login

生成token实现,这里采用的是RSA加密方式(密钥信息在nacos中的sl-express-ms-web-customer.properties配置)
com.sl.ms.web.customer.service.impl.TokenServiceImp

启动服务,在小程序进行测试

可以看到登录成功
双token三验证
单token存在的问题
在日常使用中,我们会频繁的进行登录,而在登录的过程中,会先生成jwt的token,前端接收到token将其保存下来,在进行后端服务请求的时候,在请求中携带token,服务端对token进行校验和鉴权,这种模式叫做 单token模式
在单token模式中,会出现token有效时间的问题,有效时间太短容易过期,太长容易被攻击截取。
在另一方面,token是无状态的,就是说我们不能自如的控制token的状态,无法让他主动失效。
方案设计
解决问题
token有效期长不安全
登录成功后,生成2个token,分别是:access_token、refresh_token,前者有效期短(如:5分钟),后者的有效期长(如:24小时)
正常请求后端服务时,携带access_token,如果发现access_token失效,就通过refresh_token到后台服务中换取新的access_token和refresh_token,这个可以理解为token的续签
以此往复,直至refresh_token过期,需要用户重新登录
token的无状态性
为了使token有状态,也就是后端可以控制其提前失效,需要将refresh_token设计成只能使用一次
需要将refresh_token存储到redis中,并且要设置过期时间
这样的话,服务端如果检测到用户token有安全隐患(如:异地登录),只需要将refresh_token失效即可
代码实现
生成刷新token
生成刷新refresh_token的主要逻辑有两点:
- 生成jwt格式的token,有效期时间一般小时为单位
- 将token存入到redis,使token有状态,并且确保只能使用一次
com.sl.ms.web.customer.service.impl.TokenServiceImpl
public static final String REDIS_REFRESH_TOKEN_PREFIX = "SL_CUSTOMER_REFRESH_TOKEN_";

刷新token
刷新token的动作是在refresh_token过期之后进行的,主要实现关键点有:
- 校验refresh_token是否被伪造以及是否在有效期内
- 从redis中查询,是否不存在,如果不存在说明已经失效或已经使用过,如果存在,就需要将其删除
- 重新生成一对token,响应结果
com.sl.ms.web.customer.service.impl.TokenServiceImpl

在Controller中调用上述方法
com.sl.ms.web.customer.controller.UserController

Service中调用
com.sl.ms.web.customer.service.impl.MemberServiceImpl

完善用户端登录
用户端登录成功后,需要在补一个token,之前已经写过一个了

测试

网关
网关作用
网关能够更好的管理项目分配,每一个客户端都将通过网关传递到服务端
网关就是每个客户端的中间商,他还能对token、以及用户的权限进行有效的校验
比如说:司机不能登录到快递员端,快递员不能登录到司机端

拉取代码
从sl-express-gateway中拉取网关代码:
拉取代码后,创建develop分支进行开发
bootstrap-local.yml
server:
port: 9527
tomcat:
uri-encoding: UTF-8
threads:
max: 1000
min-spare: 30
spring:
cloud:
nacos:
username: nacos
password: nacos
server-addr: 192.168.150.101:8848
discovery:
namespace: ecae68ba-7b43-4473-a980-4ddeb6157bdc
ip: 192.168.150.1
config:
namespace: ecae68ba-7b43-4473-a980-4ddeb6157bdc
gateway:
routes:
- id: sl-express-ms-web-manager #路由标识,需要唯一
uri: lb://sl-express-ms-web-manager #最终请求转发的微服务,指定的是微服务名,并开启负载均衡
predicates: #断言
- Path=/manager/**
filters: #配置过滤器
- ManagerToken #自定义局部过滤器
- StripPrefix=1 #去掉第一个路径,例如:/manager/user/login -转发到下游微服务-> /user/login
- AddRequestHeader=X-Request-From, sl-express-gateway #转发到下游微服务携带的请求头,增强安全性
globalcors:
cors-configurations:
'[/**]':
allowed-origin-patterns: "*"
allowed-headers: "*"
allow-credentials: true
allowed-methods:
- GET
- POST
- DELETE
- PUT
- OPTION
discovery:
locator:
enabled: true #表明gateway开启服务注册和发现的功能,并且spring cloud gateway自动根据服务发现为每一个服务创建了一个router,这个router将以服务名开头的请求路径转发到对应的服务
itcast:
authority:
host: ${authority.host} #authority服务地址,根据实际情况更改
port: ${authority.port} #authority服务端口
timeout: ${authority.timeout} #http请求的超时时间
public-key-file: auth/pub.key
applicationId: ${authority.applicationId}
#角色id
role:
manager: ${role.manager}
courier: ${role.courier}
driver: ${role.driver}
sl:
noAuthPaths:
- /courier/login/account
- /courier/swagger-ui.html
- /courier/webjars/
- /courier/swagger-resources
- /courier/v2/api-docs
- /courier/doc.html
- /customer/user/login
- /customer/user/refresh
- /customer/swagger-ui.html
- /customer/webjars/
- /customer/swagger-resources
- /customer/v2/api-docs
- /customer/doc.html
- /driver/login/account
- /driver/swagger-ui.html
- /driver/webjars/
- /driver/swagger-resources
- /driver/v2/api-docs
- /driver/doc.html
- /manager/login
- /manager/webjars/
- /manager/swagger-resources
- /manager/v2/api-docs
- /manager/doc.html
- /manager/captcha
jwt:
public-key: ${sl.jwt.user-secret-key}
自定义过滤器配置

过滤器实现
在过滤器中主要实现以下几个功能:
- 白名单放行
- 请求头中的token是否有效
- 权限是否匹配(校验角色)
- 向下游微服务传递解析token的数据以及token值
代码实现:
/**
* 管理端token校验的过滤器
*/
@Component
public class ManagerTokenGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
@Resource
private MyConfig myConfig;
@Resource
private TokenCheckService tokenCheckService;
@Value("${role.manager}")
private List<Long> managerRoleIds; //获取配置文件中的管理员角色id
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
//1. 校验请求路径,如果是白名单,直接放行
String path = exchange.getRequest().getPath().toString();
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
//直接放行
return chain.filter(exchange);
}
//2. 获取请求头中的token,进行校验,如果为空或校验失效,响应401
String token = exchange.getRequest().getHeaders().getFirst(Constants.GATEWAY.AUTHORIZATION);
if (StrUtil.isEmpty(token)) {
//设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//校验token
AuthUserInfoDTO authUserInfoDTO = null;
try {
authUserInfoDTO = this.tokenCheckService.parserToken(token);
} catch (Exception e) {
//token不可用,不做处理
}
if (ObjectUtil.isEmpty(authUserInfoDTO)) {
//token不可用,设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//3. 校验权限,如果是非管理员不能登录
AuthTemplate authTemplate = AuthTemplateFactory.get(token);
//3.1 获取用户拥有的角色id列表
List<Long> roleIds = authTemplate.opsForRole().findRoleByUserId(authUserInfoDTO.getUserId()).getData();
//3.2 取交集,判断用户拥有的角色是否与预定的角色列表是否有交集
Collection<Long> intersection = CollUtil.intersection(roleIds, this.managerRoleIds);
if (CollUtil.isEmpty(intersection)) {
//无交集,说明没有权限,设置响应状态码为400
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
//4. 校验通过,向下游传递用户信息和token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
//4.1 校验通过放行
return chain.filter(exchange);
};
}
}
测试
将网关启动,测试http://127.0.0.1:9527/manager/doc.html#/home看能不能正常看到页面

配置前端
将测试URL改为VUE_APP_BASE_URL = 'http://192.168.150.1:9527/manager'

将代码上传git
在Jenkins构造项目
访问http://admin.sl-express.com/

验证码端口可以看到是9527,部署成功

代码优化
优化一
将校验逻辑抽取到独立的类中,这样在每个端的GatewayFilterFactory中就可以共用了。
com.sl.gateway.filter.TokenGatewayFilter

package com.sl.gateway.filter;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.itheima.auth.factory.AuthTemplateFactory;
import com.itheima.auth.sdk.AuthTemplate;
import com.itheima.auth.sdk.dto.AuthUserInfoDTO;
import com.itheima.auth.sdk.service.TokenCheckService;
import com.sl.gateway.config.MyConfig;
import com.sl.transport.common.constant.Constants;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
@Component
public class TokenGatewayFilter implements GatewayFilter {
@Resource
private MyConfig myConfig;
@Resource
private TokenCheckService tokenCheckService;
@Value("${role.manager}")
private List<Long> managerRoleIds; //获取配置文件中的管理员角色id
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1. 校验请求路径,如果是白名单,直接放行
String path = exchange.getRequest().getPath().toString();
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
//直接放行
return chain.filter(exchange);
}
//2. 获取请求头中的token,进行校验,如果为空或校验失效,响应401
String token = exchange.getRequest().getHeaders().getFirst(Constants.GATEWAY.AUTHORIZATION);
if (StrUtil.isEmpty(token)) {
//设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//校验token
AuthUserInfoDTO authUserInfoDTO = null;
try {
authUserInfoDTO = this.tokenCheckService.parserToken(token);
} catch (Exception e) {
//token不可用,不做处理
}
if (ObjectUtil.isEmpty(authUserInfoDTO)) {
//token不可用,设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//3. 校验权限,如果是非管理员不能登录
AuthTemplate authTemplate = AuthTemplateFactory.get(token);
//3.1 获取用户拥有的角色id列表
List<Long> roleIds = authTemplate.opsForRole().findRoleByUserId(authUserInfoDTO.getUserId()).getData();
//3.2 取交集,判断用户拥有的角色是否与预定的角色列表是否有交集
Collection<Long> intersection = CollUtil.intersection(roleIds, this.managerRoleIds);
if (CollUtil.isEmpty(intersection)) {
//无交集,说明没有权限,设置响应状态码为400
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
//4. 校验通过,向下游传递用户信息和token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
//4.1 校验通过放行
return chain.filter(exchange);
}
}
com.sl.gateway.filter.ManagerTokenGatewayFilterFactory

优化二
通过前面的优化一已经将过滤器抽取到一个独立的类中,这样就可以在多个过滤器工厂中通用了,但也是存在一些问题的,例如:
- 不同的终端校验的角色id是不同的
- 用户端是不需要校验角色的
- 用户端校验token的逻辑与其他三端是不一样的
- 用户端请求头中的token参数名与其他三端也不一样
基于这些问题,就能够意识到,单纯的抽取代码是不够的,需要进一步的优化。想一想,该怎么优化呢?
优化的思路就是,在TokenGatewayFilter中保留四端通用的逻辑,不同的逻辑抽取到接口中,由四端具体的实现。
自定义AuthFilter接口:
com.sl.gateway.filter.AuthFilter

总结
在这次实训中,后面其实还有挺多章节的内容,我没有完成,我只是将其完整的看了一遍
虽然没有完成整个实训,但我已经粗略的领略到了项目开发流程,从开始的环境搭建到后面微服务的实现,我学习到了中间件的使用,容器的配置,git的管理等等,这些都是在平常学习中不会遇到的问题,所以这次实训也是为我带来了一些别样的收获




