第十二章
购物车解决方案
优就业.JAVA教研室
学习目标
目标1:资源服务器授权配置
目标2:掌握OAuth认证微服务动态加载数据
目标3:掌握购物车流程
目标4:掌握购物车渲染流程
目标5:OAuth2.0认证并获取用户令牌数据
目标6:微服务与微服务之间的认证
1 资源服务器授权配置
1.1 资源服务授权配置
基本上所有微服务都是资源服务
(1)配置公钥 认证服务生成令牌采用非对称加密算法,认证服务采用私钥加密生成令牌,对外向资源服务提供公钥,资源服务使 用公钥 来校验令牌的合法性。 将公钥拷贝到 public.key文件中,将此文件拷贝到每一个需要的资源服务工程的classpath下 ,例如:用户微服务.
(2)添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
(3)配置每个系统的Http请求路径安全控制策略以及读取公钥信息识别令牌,如下:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "public.key";
/***
* 定义JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/***
* 定义JJwtAccessTokenConverter
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
//读取第1行,公钥数据
String s;
if((s=br.readLine())!=null){
return s;
}
} catch (IOException ioe) {
return null;
}
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/user/add"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
1.2 用户微服务资源授权
将上面生成的公钥public.key拷贝到dongyimai-user-service微服务工程的resources目录下,如下图:
(1)引入依赖
在dongyimai-user-service微服务工程pom.xml中引入oauth依赖
<!--oauth依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
(2)资源授权配置
在dongyimai-user-service工程中创建com.offcn.user.config.ResourceServerConfig,代码如下:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "public.key";
/***
* 定义JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/***
* 定义JJwtAccessTokenConverter
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey(){
//加载公钥文件
Resource resource = new ClassPathResource(PUBLIC_KEY);
//读取输入流
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
//读取第1行,公钥数据
String s;
if((s=br.readLine())!=null){
return s;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/user/add"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
问题:注意公钥的格式必须正确,要不然会出现如下错误
1.3 授权测试
用户每次访问微服务的时候,需要先申请令牌,令牌申请后,每次将令牌放到头文件中,才能访问微服务。
头文件中每次需要添加一个Authorization
头信息,头的结果为bearer token
。
(1)不携带令牌测试
访问http://localhost:9007/user 不携带令牌,结果如下:
(2)携带正确令牌访问,注意重新生成令牌
访问http://localhost:9007/user 携带正确令牌
在请求头携带参数:
key: Authorization
value: Bearer+半角空格+accessToken
结果如下:
(3)携带错误令牌
访问http://localhost:9007/user 携带不正确令牌,结果如下:
注意:客户端资源必须拥有:oauth2-resource,否则报错
clients.inMemory()
.withClient("client1")
.secret(passwordEncoder.encode("123"))
.resourceIds("oauth2-resource","dongyimai-user", "dongyimai-goods")
2 网关对接微服务转发Token
用户每次访问微服务的时候,先去oauth2.0服务登录,登录后再访问微服务网关,微服务网关将请求转发给其他微服务处理。
1.用户登录成功后,会将令牌信息存入到cookie中(一般建议存入到头文件中)
2.用户携带Cookie中的令牌访问微服务网关
3.微服务网关先获取头文件中的令牌信息,如果Header中没有Authorization令牌信息,则从参数中找,参数中如果没有,则取Cookie中找Authorization,最后将令牌信息封装到Header中,并调用其他微服务
4.其他微服务会获取头文件中的Authorization令牌信息,然后匹配令牌数据是否能使用公钥解密,如果解密成功说明用户已登录,解密失败,说明用户未登录
1.1 令牌加入到Header中
修改dongyimai-gateway-web的全局过滤器com.offcn.filter.AuthorizeFilter,实现将令牌信息添加到头文件中,代码如下:
try {
//不用解析令牌,直接转发
// Claims claims = JwtUtil.parseJwt(token);
//判断token是否有Bearer开头,如果没有,就添加Bearer开头
if(token!=null){
if(!token.substring(0,6).toLowerCase().startsWith("bearer")){
//添加Bearer开头
token="Bearer "+token;
}
}
//把token转发到对应微服务
request.mutate().header(AUTHORIZE_TOKEN,token);
} catch (Exception e) {
e.printStackTrace();
//解析出现异常返回401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
测试:
访问http://localhost:8001/api/user
,将生成的新令牌放到头文件中效果如下:
注意:经测试如上由于java代码中添加了bearer, 那么在发出请求的时候就可以不加了。并且由于网关中gateway中AuthorizeFilter添加的从cookie中获取令牌。此时参数中请求头中不添加Authorization参数也不会报错。因为最终都会被Cookie获取的令牌给覆盖了。
1.2 SpringSecurity权限控制
由于我们项目使用了微服务,任何用户都有可能使用任意微服务,此时我们需要控制相关权限,例如:普通用户角色不能使用用户的删除操作,只有管理员才可以使用,那么这个时候就需要使用到SpringSecurity的权限控制功能了。
1.2.1 角色加载
在dongyimai-user-oauth服务中,找到配置类WebSecurityConfig,
//编写自定义认证类,账号、密码、授权
private UserDetailsService CreateUserDetailsService(){
//用户账号集合
List<UserDetails> userDetailsList=new ArrayList<>();
//创建第一组账号
UserDetails userDetails1 = User.withUsername("admin").password(passwordEncoder().encode("123")).authorities("ADMIN", "USER").build();
UserDetails userDetails2 = User.withUsername("user1").password(passwordEncoder().encode("456")).authorities("USER").build();
UserDetails userDetails3 = User.withUsername("user2").password(passwordEncoder().encode("789")).authorities("ADMIN", "USER").build();
//把创建好账号添加到账号集合
userDetailsList.add(userDetails1);
userDetailsList.add(userDetails2);
userDetailsList.add(userDetails3);
//把账号存储到内存
return new InMemoryUserDetailsManager(userDetailsList);
}
注意:.authorities(“ADMIN”, “USER”) 就是给用户授权的
1.2.2 角色权限控制
在每个微服务中,需要获取用户的角色,然后根据角色识别是否允许操作指定的方法,Spring Security中定义了四个支持权限控制的表达式注解,分别是@PreAuthorize
、@PostAuthorize
、@PreFilter
和@PostFilter
。其中前两者可以用来在方法调用前或者调用后进行权限检查,后两者可以用来对集合类型的参数或者返回值进行过滤。在需要控制权限的方法上,我们可以添加@PreAuthorize
注解,用于方法执行前进行权限检查,校验用户当前角色是否能访问该方法。
(1)开启@PreAuthorize
在dongyimai-user-service
的ResourceServerConfig
类上添加@EnableGlobalMethodSecurity
注解,用于开启@PreAuthorize的支持,代码如下:
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
(2)方法权限控制
在dongyimaig-user-service
微服务的com.offcn.user.controller.UserController
类的findById方法上添加权限控制注解@PreAuthorize
,代码如下:
@PreAuthorize("hasAnyAuthority('admin')")
(3)测试
我们使用Postman测试,先重新登录创建令牌,然后将令牌数存放到头文件中访问微服务网关来调用user微服务的findById方法,效果如下:
地址:http://localhost:8001/api/user/1
提交方式:GET
发现上面无法访问,因为用户登录的时候,角色不包含admin角色,而delete方法需要admin角色,所以被拦截了。
我们再测试其他方法,其他方法没有配置拦截,所以用户登录后就会放行。
访问http://localhost:8001/api/user
效果如下:
知识点说明:
如果希望一个方法能被多个角色访问,配置:@PreAuthorize("hasAnyAuthority('admin','user')")
如果希望一个类都能被多个角色访问,在类上配置:@PreAuthorize("hasAnyAuthority('admin','user')")
再次执行查询方法
3 OAuth动态加载数据
前面OAuth我们用的数据都是静态的,在现实工作中,数据都是从数据库加载的,所以我们需要调整一下OAuth服务,从数据库加载相关数据。
- 客户端数据[生成令牌相关数据]
- 用户登录账号密码从数据库加载
3.1 客户端数据加载
3.1.1 数据介绍
(1)客户端静态数据
在dongyimai-user-oauth
的com.offcn.oauth.config.AuthorizationServerConfig类中配置了客户端静态数据,主要用于配置客户端数据,代码如下:
//客户端账号配置
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//在内存中创建账号
clients.inMemory()
// admin,授权码认证、密码认证、客户端认证、简单认证、刷新token
.withClient("admin") //账号名称
.secret(passwordEncoder.encode("admin"))//密码,要设置加密
.resourceIds("dongyimai-user", "dongyimai-goods")//资源编号
.scopes("server","app")//作用范围
.authorizedGrantTypes("authorization_code", "password", "refresh_token", "client_credentials","implicit")//登录授权模式
.redirectUris("http://localhost");//登录成功跳转地址
}
(2)客户端表结构介绍
在数据库中创建一张表oauth_client_details,表主要用于记录客户端相关信息,表结构如下:
CREATE TABLE `oauth_client_details` (
`client_id` varchar(48) NOT NULL COMMENT '客户端ID,主要用于标识对应的应用',
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL COMMENT '客户端秘钥,BCryptPasswordEncoder加密算法加密',
`scope` varchar(256) DEFAULT NULL COMMENT '对应的范围',
`authorized_grant_types` varchar(256) DEFAULT NULL COMMENT '认证模式',
`web_server_redirect_uri` varchar(256) DEFAULT NULL COMMENT '认证后重定向地址',
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL COMMENT '令牌有效期',
`refresh_token_validity` int(11) DEFAULT NULL COMMENT '令牌刷新周期',
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
字段说明:
client_id:客户端id
resource_ids:资源id(暂时不用)
client_secret:客户端秘钥
scope:范围
access_token_validity:访问token的有效期(秒)
refresh_token_validity:刷新token的有效期(秒)
authorized_grant_type:授权类型:authorization_code,password,refresh_token,client_credentials
导入2条记录到表中,SQL如下:数据中密文为123
INSERT INTO `oauth_client_details` VALUES ('dongyimai', null, '$2a$10$8DclUiu2LEG99cTGXdpzVOwoqbJY.IZ7qXClQ.uOM8Gq9fDx/eXhO', 'app', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost', null, '432000000', '432000000', null, null);
INSERT INTO `oauth_client_details` VALUES ('ujiuye', null, '$2a$10$8DclUiu2LEG99cTGXdpzVOwoqbJY.IZ7qXClQ.uOM8Gq9fDx/eXhO', 'app', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost', null, '432000000', '432000000', null, null);
上述表结构属于SpringSecurity Oauth2.0所需的一个认证表结构,不能随意更改。相关操作在其他类中有所体现,如:org.springframework.security.oauth2.provider.client.JdbcClientDetailsService
中的片段代码如下:
3.1.2 加载数据改造
(1)、引入依赖库
修改pom.xml
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
(2)、修改连接配置
从数据库加载数据,我们需要先配置数据库连接,在dongyimai-user-oauth的application.yml中配置连接信息,如下代码:
上图代码如下:
spring:
application:
name: oauth2
datasource:
url: jdbc:mysql://localhost:3306/dongyimaidb
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
(3)修改客户端加载源
修改dongyimai-user-oauth的com.offcn.oauth.config.AuthorizationServerConfig类的configure方法,将之前静态的客户端数据变成从数据库加载,修改如下:
修改前:
修改后:
注意如果:表中的密码不清楚可以创建一个测试类生成一段密码拷贝进入表中替换掉原有的密码即可。
在项目的test目录下,编写测试类TestBCryptDemo:
package com.offcn.oauth;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class TestBCryptDemo {
@Test
public void test1() {
String password="dongyimai";
System.out.println(new BCryptPasswordEncoder().encode(password));
}
}
(4)测试
授权码模式测试
访问:http://localhost:9100/oauth/authorize?client_id=dongyimai&response_type=code&scope=app&redirect_uri=http://localhost
效果如下:
用户名对应应用id,密码对应秘钥。账号输入:dongyimai
密码:dongyimai
当然此时从数据中取出数据,注意:如果采用授权码模式,客户端账号密码和用户登录账号密码必须一致。
自定义账号密码模式授权测试
我们之前编写的账号密码登录代码如下,每次都会加载指定的客户端ID和指定的秘钥,所以此时的客户端ID和秘钥固定了,输入的账号密码不再是客户端ID和秘钥了。
如果客户端账号密码发生变化,对应的修改配置文件中的配置,和数据表oauth_client_details中的账号密码一致
auth:
ttl: 3600 #token存储过期时间
clientId: dongyimai #客户端账号
clientSecret: 123 #客户端密码
cookieDomain: localhost
cookieMaxAge: -1
3.2 用户数据加载
因为我们目前整套系统是对内提供登录访问,所以每次用户登录的时候oauth需要调用用户微服务查询用户信息,如上图:
我们需要在用户微服务中提供用户信息查询的方法,并在oauth中使用feign调用即可。
在真实工作中,用户和管理员对应的oauth认证服务器会分开,网关也会分开,我们今天的课堂案例只实现用户相关的认证即可。
(1)、实现自定义认证类
编写自定义认证类,com.offcn.oauth.auth.UserDetailsServiceImpl该类实现了加载用户相关信息进行授权和认证,如下代码:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
String pwd = new BCryptPasswordEncoder().encode("dongyimai");
//创建角色
String permissions="salesman,accountant,user";
//创建用户对象
User userDetails = new User(username,pwd, AuthorityUtils.commaSeparatedStringToAuthorityList(permissions));
return userDetails;
}
}
上述代码给登录用户定义了三个角色,分别为salesman
,accountant
,user
,这一块我们目前使用的是硬编码方式将角色写死了,后面会从数据库加载。
密码也统一写死成了:dongyimai
修改配置类WebSecurityConfig
注释原来写死账号密码的如下代码:
/* //声明自定义认证对象
@Bean(name = "userDetailsService")
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
return this.CreateUserDetailsService();
}
@Override
protected UserDetailsService userDetailsService() {
//启用自定义认证对象进行认证
return this.CreateUserDetailsService();
}
//自定义认证,声明用户登录账号、密码
private UserDetailsService CreateUserDetailsService(){
List<UserDetails> users=new ArrayList<>();
UserDetails adminUser = User.withUsername("admin").password(passwordEncoder().encode("123")).authorities("ADMIN", "USER").build();
UserDetails OneUser = User.withUsername("user1").password(passwordEncoder().encode("123")).authorities("ADMIN", "USER").build();
UserDetails TowUser = User.withUsername("user2").password(passwordEncoder().encode("456")).authorities("USER").build();
users.add(adminUser);
users.add(OneUser);
users.add(TowUser);
return new InMemoryUserDetailsManager(users);
}*/
重写获取自定义认证类方法
@Autowired
UserDetailsService userDetailsService;
@Override
protected UserDetailsService userDetailsService() {
return userDetailsService;
}
(2)修改Feign
修改dongyimai-user-service-api中创建com.offcn.user.feign.UserFeign,添加代码如下:
@FeignClient(name="DYM-USER")
@RequestMapping("/user")
public interface UserFeign {
/***
* 根据username查询用户信息
* @param username
* @return
*/
@GetMapping("/load/{username}")
Result<User> findByUsername(@PathVariable String username);
}
(3)修改UserController
修改dongyimai-user-service的UserController添加findByUsername方法,代码如下:
@GetMapping("/load/{username}")
public Result<User> findByUsername(@PathVariable String username){
//调用UserService实现根据主键查询User
User user = userService.findByUsername(username);
return new Result<User>(true,StatusCode.OK,"查询成功",user);
}
(4)放行查询用户方法
因为oauth需要调用查询用户信息,需要在dongyimai-user-service中放行/user/load/{username}
方法,修改ResourceServerConfig,添加对/user/load/{username}
的放行操作,代码如下:
(5)oauth调用查询用户信息
oauth引入对user-service-api的依赖
<!--依赖用户api-->
<dependency>
<groupId>com.offcn</groupId>
<artifactId>dongyimai-user-service-api</artifactId>
<version>1.0</version>
</dependency>
修改oauth的com.offcn.oauth.UserDetailsServiceImpl
的loadUserByUsername
方法,调用UserFeign查询用户信息,代码如下:
@Autowired
private UserFeign userFeign;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//调用用户微服务,根据用户名获得用户信息
Result<User> userResult = userFeign.findByUsername(username);
if(userResult!=null&&userResult.getData()!=null) {
//获取用户密码
String pwd = userResult.getData().getPassword();
//根据用户名查询用户信息
//String pwd = new BCryptPasswordEncoder().encode("dongyimai");
//创建User对象
//String permissions = "goods_list,seckill_list";
String permissions = "salesman,accountant,user";
User userDetails = new User(username, pwd, AuthorityUtils.commaSeparatedStringToAuthorityList(permissions));
return userDetails;
}else {
return null;
}
}
(6)feign开启
修改com.offcn.OAuthApplication
开启Feign客户端功能
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages = {"com.offcn.user.feign"})
public class OAuthApplication {
public static void main(String[] args) {
SpringApplication.run(OAuthApplication.class,args);
}
@Bean(name = "restTemplate")
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
(7)、设置feign的调用超时时间修改application.yml
# 开启Feign的熔断功能
feign:
hystrix:
enabled: true
#总连接超时时间=(切换服务实例次数+1)*(每个实例重试次数+1)*连接超时时间
USER: #服务名称
ribbon:
#配置指定服务的负载均衡策略
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule
# Ribbon的连接超时时间
ConnectTimeout: 2000
# Ribbon的数据读取超时时间
ReadTimeout: 2000
# 是否对所有操作都进行重试
OkToRetryOnAllOperations: true
# 切换实例的重试次数
MaxAutoRetriesNextServer: 1
# 对当前实例的重试次数
MaxAutoRetries: 1
#设定Hystrix熔断超时时间 ,理论上熔断时间应该大于总连接超时时间
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 10000
(7)测试
我们换个数据库中的账号密码登录,分别输入zhangsan,密码使用我们前面的测试类重新生成密码拷贝进入数据库中替换原有的数据,然后测试效果如下:
注意账号密码从数据表tb_user查看:
4 购物车
购物车分为用户登录购物车和未登录购物车操作,传统的电商系统在用户登录和不登录都可以操作购物车,如果用户不登录,操作购物车可以将数据存储到Cookie或者WebSQL或者SessionStorage中,用户登录后购物车数据可以存储到Redis中,再将之前未登录加入的购物车合并到Redis中即可。
淘宝天猫等则采用了另外一种实现方案,用户要想将商品加入购物车,必须先登录才能操作购物车。
我们今天实现的购物车是天猫解决方案,即用户必须先登录才能使用购物车功能。
4.1 购物车分析
(1)需求分析
用户在商品详细页点击加入购物车,提交商品SKU编号和购买数量,添加到购物车。购物车展示页面如下:
(2)购物车实现思路
我们实现的是用户登录后的购物车,用户将商品加入购物车的时候,直接将要加入购物车的详情存入到Redis即可。每次查看购物车的时候直接从Redis中获取。
(3)表结构分析
用户登录后将商品加入购物车,需要存储商品详情以及购买数量,购物车详情表如下:
dongyimaidb数据库中tb_order_item表:
CREATE TABLE `tb_order_item` (
`id` bigint(20) NOT NULL,
`item_id` bigint(20) NOT NULL COMMENT 'SKU_ID',
`goods_id` bigint(20) DEFAULT NULL COMMENT 'SPU_ID',
`order_id` bigint(20) NOT NULL COMMENT '订单id',
`title` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '商品标题',
`price` decimal(20,2) DEFAULT NULL COMMENT '商品单价',
`num` int(10) DEFAULT NULL COMMENT '商品购买数量',
`total_fee` decimal(20,2) DEFAULT NULL COMMENT '商品总金额',
`pic_path` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '商品图片地址',
`seller_id` varchar(100) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
KEY `item_id` (`item_id`) USING BTREE,
KEY `order_id` (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=DYNAMIC
购物车详情表其实就是订单详情表结构,只是目前临时存储数据到Redis,等用户下单后才将数据从Redis取出存入到数据库中。
4.2 订单购物车微服务
我们先搭建一个订单购物车微服务工程,按照如下步骤实现即可。
(1)导入资源
搭建订单购物车微服务,工程名字dongyimai-order-service并搭建对应的api工程dongyimai-order-service-api,如下图:
同时在dongyimai-order-service中引入dongyimai-order-service-api,
依赖引入:
<dependency>
<groupId>com.offcn</groupId>
<artifactId>dongyimai-order-service-api</artifactId>
<version>1.0</version>
</dependency>
在dongyimai-order-service-api中引入依赖:
<dependencies>
<dependency>
<groupId>com.offcn</groupId>
<artifactId>dongyimai-common-db</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
将代码生成器生成好的dao和相关文件拷贝到工程中,以及生成好的Pojo拷贝到API工程中
dongyimai-order-service:
(2)application.yml配置
在dongyimai-service-order的resources中添加application.yml配置文件,代码如下:
server:
port: 9008
spring:
application:
name: order
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/dongyimaidb?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8
username: root
password: root
type: com.alibaba.druid.pool.DruidDataSource
redis:
host: 192.168.188.138
port: 6379
main:
allow-bean-definition-overriding: true
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka
instance:
lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
feign:
hystrix:
enabled: true
mybatis-plus:
configuration:
map-underscore-to-camel-case: true #开启驼峰式编写规范
type-aliases-package: com.offcn.order.pojo
# 配置sql打印日志
logging:
level:
com.offcn: debug
(3)创建启动类
在dongyimai-service-order的com.offcn.order
中创建启动类,代码如下:
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan(basePackages = {"com.offcn.order.dao"})
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class,args);
}
}
4.3 添加购物车
4.3.1 思路分析
用户添加购物车,只需要将要加入购物车的商品存入到Redis中即可。一个用户可以将多件商品加入购物车,存储到Redis中的数据可以采用Hash类型。
购物车数据的存储结构如下:
4.3.2 代码实现
(1)在dongyimai-order-service-api下创建购物车复合实体类com.offcn.order.group.Cart.java
public class Cart implements Serializable{
private String sellerId;//商家ID
private String sellerName;//商家名称
private List<OrderItem> orderItemList;//购物车明细
//getter and setter ......
}
(2)feign创建
下订单需要调用feign查看商品信息,我们先创建feign分别根据ID查询SKU和SPU信息,在dongyimai-sellergoods-serivce-api工程中的ItemFeign和GoodsFeign根据ID查询方法如下:
com.offcn.sellergoods.feign.ItemFeign
@FeignClient(name="dym-sellergoods")
@RequestMapping("/item")
public interface ItemFeign {
/***
* 根据ID查询Item数据
* @param id
* @return
*/
@GetMapping("/{id}")
Result<Item> findById(@PathVariable Long id);
}
com.offcn.sellergoods.feign.GoodsFeign
@FeignClient(name="dym-sellergoods")
@RequestMapping("/goods")
public interface GoodsFeign {
/***
* 根据ID查询商品数据
* @param id
* @return
*/
@GetMapping("/{id}")
Result<GoodsEntity> findById(@PathVariable Long id);
}
(3)业务层
业务层接口
在dongyimai-order-servicer微服务中创建com.offcn.order.service.CartService接口,代码如下:
public interface CartService {
/**
* 添加商品到购物车
* @param cartList
* @param itemId
* @param num
* @return
*/
public List<Cart> addGoodsToCartList(List<Cart> cartList, Long itemId, Integer num );
/**
* 从redis中查询购物车
* @param username
* @return
*/
public List<Cart> findCartListFromRedis(String username);
/**
* 将购物车保存到redis
* @param username
* @param cartList
*/
public void saveCartListToRedis(String username,List<Cart> cartList);
}
业务层接口实现类
在dongyimai-service-order微服务中创建接口实现类com.offcn.order.service.impl.CartServiceImpl,代码如下:
@Service
public class CartServiceImpl implements CartService {
@Autowired
private ItemFeign itemFeign;
@Autowired
private RedisTemplate redisTemplate;
@Override
public List<Cart> addGoodsToCartList(List<Cart> cartList, Long itemId, Integer num) {
//1.根据商品SKU ID查询SKU商品信息
Result<Item> itemResult = itemFeign.findById(itemId);
Item item = itemResult.getData();
if(item==null){
throw new RuntimeException("商品不存在");
}
if(!item.getStatus().equals("1")){
throw new RuntimeException("商品状态无效");
}
//2.获取商家ID
String sellerId = item.getSellerId();
//3.根据商家ID判断购物车列表中是否存在该商家的购物车
Cart cart = searchCartBySellerId(cartList,sellerId);
//4.如果购物车列表中不存在该商家的购物车
if(cart==null){
//4.1 新建购物车对象 ,
cart=new Cart();
cart.setSellerId(sellerId);
cart.setSellerName(item.getSeller());
OrderItem orderItem = createOrderItem(item,num);
List orderItemList=new ArrayList();
orderItemList.add(orderItem);
cart.setOrderItemList(orderItemList);
//4.2将购物车对象添加到购物车列表
cartList.add(cart);
}else{
//5.如果购物车列表中存在该商家的购物车
// 判断购物车明细列表中是否存在该商品
OrderItem orderItem = searchOrderItemByItemId(cart.getOrderItemList(),itemId);
if(orderItem==null){
//5.1. 如果没有,新增购物车明细
orderItem=createOrderItem(item,num);
cart.getOrderItemList().add(orderItem);
}else{
//5.2. 如果有,在原购物车明细上添加数量,更改金额
orderItem.setNum(orderItem.getNum()+num);
orderItem.setTotalFee(new BigDecimal(orderItem.getNum()*orderItem.getPrice().doubleValue()));
//如果数量操作后小于等于0,则移除
if(orderItem.getNum()<=0){
cart.getOrderItemList().remove(orderItem);//移除购物车明细
}
//如果移除后cart的明细数量为0,则将cart移除
if(cart.getOrderItemList().size()==0){
cartList.remove(cart);
}
}
}
return cartList;
}
/**
* 从redis中查询购物车
*
* @param username
* @return
*/
@Override
public List<Cart> findCartListFromRedis(String username) {
System.out.println("从redis中提取购物车数据....."+username);
List<Cart> cartList = (List<Cart>) redisTemplate.boundHashOps("cartList").get(username);
if(cartList==null){
cartList=new ArrayList();
}
return cartList;
}
/**
* 将购物车保存到redis
*
* @param username
* @param cartList
*/
@Override
public void saveCartListToRedis(String username, List<Cart> cartList) {
System.out.println("向redis存入购物车数据....."+username);
redisTemplate.boundHashOps("cartList").put(username, cartList);
}
/**
* 根据商家ID查询购物车对象
* @param cartList
* @param sellerId
* @return
*/
private Cart searchCartBySellerId(List<Cart> cartList, String sellerId){
for(Cart cart:cartList){
if(cart.getSellerId().equals(sellerId)){
return cart;
}
}
return null;
}
/**
* 根据商品明细ID查询
* @param orderItemList
* @param itemId
* @return
*/
private OrderItem searchOrderItemByItemId(List<OrderItem> orderItemList ,Long itemId ){
for(OrderItem orderItem :orderItemList){
if(orderItem.getItemId().longValue()==itemId.longValue()){
return orderItem;
}
}
return null;
}
/**
* 创建订单明细
* @param item
* @param num
* @return
*/
private OrderItem createOrderItem(Item item,Integer num){
if(num<=0){
throw new RuntimeException("数量非法");
}
OrderItem orderItem=new OrderItem();
orderItem.setGoodsId(item.getGoodsId());
orderItem.setItemId(item.getId());
orderItem.setNum(num);
orderItem.setPicPath(item.getImage());
orderItem.setPrice(item.getPrice());
orderItem.setSellerId(item.getSellerId());
orderItem.setTitle(item.getTitle());
orderItem.setTotalFee(new BigDecimal(orderItem.getPrice().doubleValue()*num));
return orderItem;
}
}
添加dongyimai-order-service对dongyimai-sellergoods-serivce-api的依赖
<dependency>
<groupId>com.offcn</groupId>
<artifactId>dongyimai-sellergoods-serivce-api</artifactId>
<version>1.0</version>
</dependency>
(3)控制层
在dongyimai-service-order微服务中创建com.offcn.order.controller.CartController,代码如下:
@RestController
@CrossOrigin
@RequestMapping(value = "/cart")
public class CartController {
@Autowired
private CartService cartService;
/**
* 购物车列表
*
* @return
*/
@RequestMapping("/findCartList")
public List<Cart> findCartList(String username) {
return cartService.findCartListFromRedis(username);//从redis中提取
}
@RequestMapping("/addGoodsToCartList")
public Result addGoodsToCartList(Long itemId, Integer num) {
String username = "ujiuye";
try {
List<Cart> cartList = findCartList(username);//获取购物车列表
cartList = cartService.addGoodsToCartList(cartList, itemId, num);
cartService.saveCartListToRedis(username, cartList);
return new Result(true, StatusCode.OK, "添加成功");
} catch (Exception e) {
e.printStackTrace();
return new Result(false, StatusCode.ERROR, "添加失败");
}
}
}
(4)feign配置
修改com.offcn.OrderApplication
开启Feign客户端:
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients(basePackages = {"com.offcn.sellergoods.feign"})
@MapperScan(basePackages = {"com.offcn.order.dao"})
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class,args);
}
}
测试添加购物车,效果如下:
请求地址http://localhost:9008/cart/addGoodsToCartList?itemId=1369283&num=100
测试查看购物车:http://localhost:9008/cart/findCartList?username=ujiuye
5 用户身份识别
5.1 购物车需求分析
购物车功能已经做完了,但用户我们都是硬编码写死的。用户要想将商品加入购物车,必须得先登录授权,登录授权后再经过微服务网关,微服务网关需要过滤判断用户请求是否存在令牌,如果存在令牌,才能再次访问微服务,此时网关会通过过滤器将令牌数据再次存入到头文件中,然后访问模板渲染服务,模板渲染服务再调用订单购物车微服务,此时也需要将令牌数据存入到头文件中,将令牌数据传递给购物车订单微服务,到了购物车订单微服务的时候,此时微服务需要校验令牌数据,如果令牌正确,才能使用购物车功能,并解析令牌数据获取用户信息。
5.2 微服务之间认证
如上图:因为微服务之间并没有传递头文件,所以我们可以定义一个拦截器,每次微服务调用之前都先检查下头文件,将请求的头文件中的令牌数据再放入到header中,再调用其他微服务即可。
(1)创建Feign拦截器
在dongyimai-order-service服务中创建一个com.offcn.order.config.FeignInterceptor拦截器,并将所有头文件数据再次加入到Feign请求的微服务头文件中,代码如下:
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
try {
//使用RequestContextHolder工具获取request相关变量
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
//取出request
HttpServletRequest request = attributes.getRequest();
//获取所有头文件信息的key
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
//头文件的key
String name = headerNames.nextElement();
//头文件的value
String values = request.getHeader(name);
//将令牌数据添加到头文件中
requestTemplate.header(name, values);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
(2)创建拦截器Bean
在dongyimai-order-service服务中启动类里创建对象实例
/***
* 创建拦截器Bean对象
* @return
*/
@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}
(3)我们将sellergoods工程配置上身份认证
修改dongyimai-order-service的pom.xml,添加oauth的依赖
<!--oauth依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
将公钥拷贝到dongyimai-order-service工程的resources中
在dongyimai-order-service工程中创建com.offcn.order.config.ResourceServerConfig,配置需要拦截的路径,这里需要拦截所有请求路径,代码如下:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "public.key";
//读取公钥的方法
private String getPubKey(){
//加载公钥文件
Resource resource = new ClassPathResource(PUBLIC_KEY);
//读取输入流
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
//读取第1行,公钥数据
String s;
if((s=br.readLine())!=null){
return s;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/***
* 定义JJwtAccessTokenConverter
* 帮助程序在JWT编码的令牌值和OAuth身份验证信息之间进行转换
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/***
* 定义JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/spec/**"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
注意:当dongyimai-order-service运行起来可能会报链接不到redis localhost:6379,此处可以再配置文件中配置一下即可 。
再次发送添加购物车请求测试,
我们发现ServletRequestAttributes始终为空,原因是RequestContextHolder.getRequestAttributes()该方法是从ThreadLocal变量里面取得相应信息的,当hystrix断路器的隔离策略为THREAD时,是无法取得ThreadLocal中的值。
解决方案:开启熔断,并将hystrix隔离策略换为SEMAPHORE
修改dongyimai-order-service的application.yml配置文件,在application.yml中添加如下代码,代码如下:
#hystrix 配置
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 10000
strategy: SEMAPHORE
再次测试(注意要重新登陆、使用生成的令牌进行访问)
效果如下:
(4)工具类抽取
微服务之间相互认证的情况非常多,我们可以把上面的拦截器抽取出去,放到dongyimai-common的config包中,其他工程需要用,直接创建一个@Bean对象即可。
5.3 网关过滤
为了不给微服务带来一些无效的请求,我们可以在网关中过滤用户请求,先看看头文件中是否有Authorization,如果没有再看看cookie中是否有Authorization,如果都通过了才允许请求到达微服务。
5.4 订单对接网关+oauth
(1)application.yml配置
修改微服务网关dongyimai-gateway-web
的application.yml配置文件,添加order的路由过滤配置,配置如下:
上图代码如下:
#购物车微服务
- id: dongyimai_order_route
uri: lb://ORDER
predicates:
- Path=/api/cart/**,/api/order/**,/api/orderItem/**
filters:
- StripPrefix=1
这里注意使用的是yml格式,所以上面代码中的空格也一并记得拷贝到application.yml文件中。
(2)过滤配置
在微服务网关dongyimai-gateway-web
中添加com.offcn.filter.URLFilter
过滤类,用于过滤需要用户登录的地址,代码如下:
public class URLFilter {
/**
* 要放行的路径
*/
private static final String noAuthorizeurls = "/api/user/add,/api/user/login";
/**
* 判断 当前的请求的地址中是否在已有的不拦截的地址中存在,如果存在 则返回true 表示 不拦截 false表示拦截
*
* @param uri 获取到的当前的请求的地址
* @return
*/
public static boolean hasAuthorize(String uri) {
String[] split = noAuthorizeurls.split(",");
for (String s : split) {
if (s.equals(uri)) {
return true;
}
}
return false;
}
}
(3)全局过滤器修改
修改之前的com.offcn.filter.AuthorizeFilter
的过滤方法,将是否需要用户登录过滤也加入其中,代码如下:
//获取请求的URI
String path = request.getURI().getPath();
//判断请求路径是否是不需要验证的
if(URLFilter.hasAuthorize(path)){
//直接放行
return chain.filter(exchange);
}
(4)测试
使用Postman访问 http://localhost:8001/api/cart/addGoodsToCartList?itemId=1369283&num=100
,效果如下:
未登录:
使用Postman访问 http://localhost:8001/api/cart/addGoodsToCartList?itemId=1369283&num=100
,效果如下:
已登录:
使用Postman访问 http://localhost:8001/api/cart/findCartList?username=ujiuye
,效果如下:
5.5 获取用户数据
5.5.1 数据分析
用户登录后,数据会封装到SecurityContextHolder.getContext().getAuthentication()
里面,我们可以将数据从这里面取出,然后转换成OAuth2AuthenticationDetails
,在这里面可以获取到令牌信息、令牌类型等,代码如下:
这里的tokenValue是加密之后的令牌数据,remoteAddress是用户的IP信息,tokenType是令牌类型。
我们可以获取令牌加密数据后,使用公钥对它进行解密,如果能解密说明语句无误,如果不能解密用户也没法执行到这一步。解密后可以从明文中获取用户信息。
5.5.2 代码实现
(1)在dongyimai-common工程中引入鉴权包
<!--oauth依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<scope>provided</scope>
</dependency>
<!--鉴权-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
<scope>provided</scope>
</dependency>
(2)读取公钥
在dongyimai-common中创建com.offcn.utils.TokenDecode类,用于解密令牌信息,在类中读取公钥信息,代码如下:
public class TokenDecode {
//公钥
private static final String PUBLIC_KEY="public.key";
//定义读取公钥内容变量
private static String publickey="";
//读取公钥内容方法
private String getPubKey(){
Resource resource=new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
//读取第一行公钥数据
if((publickey=bufferedReader.readLine())!=null){
return publickey;
}
} catch (IOException e) {
e.printStackTrace();
}
return publickey;
}
}
(2)校验解析令牌数据
在TokenDecode类中添加校验解析令牌数据的方法,这里用到了JwtHelper实现。
/***
* 读取令牌数据
*/
public Map<String,String> dcodeToken(String token){
//校验Jwt
Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(getPubKey()));
//获取Jwt原始内容
String claims = jwt.getClaims();
return JSON.parseObject(claims,Map.class);
}
(3)获取令牌数据
在TokenDecode类中添加一个getUserInfo方法,用于从容器中获取令牌信息,代码如下:
/***
* 获取用户信息
* @return
*/
public Map<String,String> getUserInfo(){
//获取授权信息
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
//令牌解码
return dcodeToken(details.getTokenValue());
}
完整代码:
import com.alibaba.fastjson.JSON;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.jwt.Jwt;
import org.springframework.security.jwt.JwtHelper;
import org.springframework.security.jwt.crypto.sign.RsaVerifier;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Map;
public class TokenDecode {
//公钥
private static final String PUBLIC_KEY="public.key";
//定义读取公钥内容变量
private static String publickey="";
//读取公钥内容方法
private String getPubKey(){
Resource resource=new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
//读取第一行公钥数据
if((publickey=bufferedReader.readLine())!=null){
return publickey;
}
} catch (IOException e) {
e.printStackTrace();
}
return publickey;
}
//解析令牌读取数据
public Map<String, String> decodeToken(String token){
//调用公钥解析令牌
Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(getPubKey()));
//获取原始内容
String claims = jwt.getClaims();
//解析json字符串为Map
return JSON.parseObject(claims, Map.class);
}
//访问当前登录的用户信息解析令牌获取用户数据
public Map<String, String> getUserInfo(){
//获取springSecurity登录信息
OAuth2AuthenticationDetails details= (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
//解析令牌
return decodeToken(details.getTokenValue());
}
}
(4)dongyimai-order-service微服务的主启动类中声明TokenDecode
@Bean
public TokenDecode getTokenDecode(){
return new TokenDecode();
}
(5)order微服务的控制层获取用户数据
在CartController中注入TokenDecode,并调用TokenDecode的getUserInfo方法获取用户信息,代码如下:
注入TokenDecode:
@Autowired
private TokenDecode tokenDecode;
获取用户名:
@RestController
@CrossOrigin
@RequestMapping(value = "/cart")
public class CartController {
@Autowired
private CartService cartService;
@Autowired
private TokenDecode tokenDecode;
/**
* 购物车列表
*
* @return
*/
@RequestMapping("/findCartList")
public List<Cart> findCartList() {
Map<String, String> userInfo = tokenDecode.getUserInfo();
System.out.println("userInfo:"+userInfo);
String username=userInfo.get("user_name");
return cartService.findCartListFromRedis(username);//从redis中提取
}
@RequestMapping("/addGoodsToCartList")
public Result addGoodsToCartList(Long itemId, Integer num) {
//String username = "ujiuye";
Map<String, String> userInfo = tokenDecode.getUserInfo();
System.out.println("userInfo:"+userInfo);
String username=userInfo.get("user_name");
try {
List<Cart> cartList = findCartList();//获取购物车列表
cartList = cartService.addGoodsToCartList(cartList, itemId, num);
cartService.saveCartListToRedis(username, cartList);
return new Result(true, StatusCode.OK, "添加成功");
} catch (Exception e) {
e.printStackTrace();
return new Result(false, StatusCode.ERROR, "添加失败");
}
}
}
(5)测试
用户登录后测试
经过网关转发请求添加到购物车:
http://localhost:8001/api/cart/addGoodsToCartList?itemId=1324601&num=1
查看redis缓存中保存的用户数据
经过网关,查看购物车数据:
http://localhost:8001/api/cart/findCartList