乐忧商城
16.用户注册
16.1 创建用户中心
用户搜索到自己心仪的商品,接下来就要去购买,但是购买必须先登录。所以接下来我们编写用户中心,实现用户的登录和注册功能。
用户中心的提供的服务:
- 用户的注册
- 用户登录
- 用户个人信息管理
- 用户地址管理
- 用户收藏管理
- 我的订单
- 优惠券管理
这里我们暂时先实现基本的:注册和登录功能。因为用户中心的服务其它微服务也会调用,因此这里我们做聚合。
leyou-user:父工程,包含2个子工程:
- leyou-user-interface:实体及接口
- leyou-user-service:业务和服务
16.2 后台功能准备
数据结构
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(32) NOT NULL COMMENT '密码,加密存储',
`phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
`created` datetime NOT NULL COMMENT '创建时间',
`salt` varchar(32) NOT NULL COMMENT '密码加密的salt值',
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8 COMMENT='用户表';
数据结构比较简单,因为根据用户名查询的频率较高,所以我们给用户名创建了索引
实体类:
package com.leyou.user.pojo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.hibernate.validator.constraints.Length;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.Pattern;
import java.util.Date;
@Table(name = "tb_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Length(min = 4, max = 16, message = "用户名长度应该在4到16位之间")
private String username;// 用户名
@JsonIgnore
@Length(min = 4, max = 16, message = "密码长度应该在4到16位之间")
private String password;// 密码
@Pattern(regexp = "^1[35678]\\d{9}$", message = "手机号格式不正确")
private String phone;// 电话
private Date created;// 创建时间
@JsonIgnore
private String salt;// 密码的盐值
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Date getCreated() {
return created;
}
public void setCreated(Date created) {
this.created = created;
}
public String getSalt() {
return salt;
}
public void setSalt(String salt) {
this.salt = salt;
}
}
注意:为了安全考虑。这里对password和salt添加了注解@JsonIgnore,这样在json序列化时,就不会把password和salt返回。
16.3 数据验证功能
接口说明
实现用户数据的校验,主要包括对:手机号、用户名的唯一性校验。
接口路径
GET /check/{data}/{type}
参数说明
参数 | 说明 | 是否必须 | 数据类型 | 默认值 |
---|---|---|---|---|
data | 要校验的数据 | 是 | String | 无 |
type | 要校验的数据类型:1.用户名 2.手机 | 否 | Integer | 1 |
返回结果:
返回布尔类型结果:
- true:可用
- false:不可用
状态码:
- 200:校验成功
- 400:参数有误
- 500:服务器内部异常
Controller类:
因为有了接口,我们可以不关心页面,所有需要的东西都一清二楚:
- 请求方式:GET
- 请求路径:/check/{param}/{type}
- 请求参数:param,type
- 返回结果:true或false
/**
* 校验数据是否可用
* @param data
* @param type
* @return
*/
@GetMapping("check/{data}/{type}")
public ResponseEntity<Boolean> checkUserData(@PathVariable("data") String data, @PathVariable(value = "type") Integer type) {
Boolean boo = this.userService.checkData(data, type);
if (boo == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
return ResponseEntity.ok(boo);
}
UserService类:
public Boolean checkData(String data, Integer type) {
User record = new User();
switch (type) {
case 1:
record.setUsername(data);
break;
case 2:
record.setPhone(data);
break;
default:
return null;
}
return this.userMapper.selectCount(record) == 0;
}
16.4 阿里大于短信服务
很遗憾,这部分没法完成,因为申请这个东西现在需要已经备案的网站什么之类的,弄了半天没有申请到,但是这个部分无非是调一个发短信的API,缺少这部分对整体也没啥太大影响。
创建短信微服务
因为系统中不止注册一个地方需要短信发送,因此我们将短信发送抽取为微服务:leyou-sms-service,凡是需要的地方都可以使用。
另外,因为短信发送API调用时长的不确定性,为了提高程序的响应速度,短信发送我们都将采用异步发送方式,即:
- 短信服务监听MQ消息,收到消息后发送短信。
- 其它服务要发送短信时,通过MQ通知短信微服务。
1.导入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.sms</groupId>
<artifactId>leyou-sms</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>3.3.1</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
</dependencies>
</project>
2.添加配置
server:
port: 8086
spring:
application:
name: sms-service
rabbitmq:
host: 192.168.124.121
virtual-host: /leyou
username: leyou
password: leyou
leyou:
sms:
accessKeyId: LTAItNxtDfk7MJMq # 你自己的accessKeyId 这部分已经没法使用了
accessKeySecret: HKUIDl21ZM7XoubErXjUFJq84dha8W # 你自己的AccessKeySecret
signName: 乐优商城 # 签名名称
verifyCodeTemplate: SMS_143714980 # 模板名称
3.编写启动类
package com.leyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LeyouSmsApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouSmsApplication.class,args);
}
}
编写短信工具类
先将属性抽取出来注入到属性类中:
@ConfigurationProperties(prefix = "leyou.sms")
public class SmsProperties {
String accessKeyId;
String accessKeySecret;
String signName;
String verifyCodeTemplate;
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getAccessKeySecret() {
return accessKeySecret;
}
public void setAccessKeySecret(String accessKeySecret) {
this.accessKeySecret = accessKeySecret;
}
public String getSignName() {
return signName;
}
public void setSignName(String signName) {
this.signName = signName;
}
public String getVerifyCodeTemplate() {
return verifyCodeTemplate;
}
public void setVerifyCodeTemplate(String verifyCodeTemplate) {
this.verifyCodeTemplate = verifyCodeTemplate;
}
}
然后把阿里提供的demo进行简化和抽取,封装一个工具类(这个是好几年前的工具类了,现在已经不能这么使用了,但是没办法,反正也弄不了了,就直接把以前的粘贴上来占个位置吧):
package com.leyou.sms.utils;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.leyou.sms.config.SmsProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsUtils {
@Autowired
private SmsProperties prop;
//产品名称:云通信短信API产品,开发者无需替换
static final String product = "Dysmsapi";
//产品域名,开发者无需替换
static final String domain = "dysmsapi.aliyuncs.com";
static final Logger logger = LoggerFactory.getLogger(SmsUtils.class);
public SendSmsResponse sendSms(String phone, String code, String signName, String template) throws ClientException {
//可自助调整超时时间
System.setProperty("sun.net.client.defaultConnectTimeout", "10000");
System.setProperty("sun.net.client.defaultReadTimeout", "10000");
//初始化acsClient,暂不支持region化
IClientProfile profile = DefaultProfile.getProfile("cn-hangzhou",
prop.getAccessKeyId(), prop.getAccessKeySecret());
DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", product, domain);
IAcsClient acsClient = new DefaultAcsClient(profile);
//组装请求对象-具体描述见控制台-文档部分内容
SendSmsRequest request = new SendSmsRequest();
request.setMethod(MethodType.POST);
//必填:待发送手机号
request.setPhoneNumbers(phone);
//必填:短信签名-可在短信控制台中找到
request.setSignName(signName);
//必填:短信模板-可在短信控制台中找到
request.setTemplateCode(template);
//可选:模板中的变量替换JSON串,如模板内容为"亲爱的${name},您的验证码为${code}"时,此处的值为
request.setTemplateParam("{\"code\":\"" + code + "\"}");
//选填-上行短信扩展码(无特殊需求用户请忽略此字段)
//request.setSmsUpExtendCode("90997");
//可选:outId为提供给业务方扩展字段,最终在短信回执消息中将此值带回给调用者
request.setOutId("123456");
//hint 此处可能会抛出异常,注意catch
SendSmsResponse sendSmsResponse = acsClient.getAcsResponse(request);
logger.info("发送短信状态:{}", sendSmsResponse.getCode());
logger.info("发送短信消息:{}", sendSmsResponse.getMessage());
return sendSmsResponse;
}
}
编写消息监听器
接下来,编写消息监听器,当接收到消息后,我们发送短信。
@Component
@EnableConfigurationProperties(SmsProperties.class)
public class SmsListener {
@Autowired
private SmsUtils smsUtils;
@Autowired
private SmsProperties prop;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "leyou.sms.queue", durable = "true"),
exchange = @Exchange(value = "leyou.sms.exchange",
ignoreDeclarationExceptions = "true"),
key = {"sms.verify.code"}))
public void listenSms(Map<String, String> msg) throws Exception {
if (msg == null || msg.size() <= 0) {
// 放弃处理
return;
}
String phone = msg.get("phone");
String code = msg.get("code");
if (StringUtils.isBlank(phone) || StringUtils.isBlank(code)) {
// 放弃处理
return;
}
// 发送消息
SendSmsResponse resp = this.smsUtils.sendSms(phone, code,
prop.getSignName(),
prop.getVerifyCodeTemplate());
}
}
我们注意到,消息体是一个Map,里面有两个属性:
- phone:电话号码
- code:短信验证码
16.5 发送短信功能
这里的业务逻辑是这样的:
- 1)我们接收页面发送来的手机号码
- 2)生成一个随机验证码
- 3)将验证码保存在服务端
- 4)发送短信,将验证码发送到用户手机
那么问题来了:验证码保存在哪里呢?
验证码有一定有效期,一般是5分钟,我们可以利用Redis的过期机制来保存。
Spring Data Redis
Spring Data Redis,是Spring Data 家族的一部分。 对Jedis客户端进行了封装,与spring进行了整合。可以非常方便的来实现redis的配置和操作。
RedisTemplate基本操作
Spring Data Redis 提供了一个工具类:RedisTemplate。里面封装了对于Redis的五种数据结构的各种操作,包括:
- redisTemplate.opsForValue() :操作字符串
- redisTemplate.opsForHash() :操作hash
- redisTemplate.opsForList():操作list
- redisTemplate.opsForSet():操作set
- redisTemplate.opsForZSet():操作zset
其它一些通用命令,如expire,可以通过redisTemplate.xx()来直接调用
5种结构:
-
String:等同于java中的,Map<String,String>
-
list:等同于java中的Map<String,List>
-
set:等同于java中的Map<String,Set>
-
sort_set:可排序的set
-
hash:等同于java中的:`Map<String,Map<String,String>>
StringRedisTemplate
RedisTemplate在创建时,可以指定其泛型类型: -
K:代表key 的数据类型
-
V: 代表value的数据类型
注意:这里的类型不是Redis中存储的数据类型,而是Java中的数据类型,RedisTemplate会自动将Java类型转为Redis支持的数据类型:字符串、字节、二进制等等。不过RedisTemplate默认会采用JDK自带的序列化(Serialize)来对对象进行转换。生成的数据十分庞大,因此一般我们都会指定key和value为String类型,这样就由我们自己把对象序列化为json字符串来存储即可。
因为大部分情况下,我们都会使用key和value都为String的RedisTemplate,因此Spring就默认提供了这样一个实现:
具体怎么实现呢?
需要三个步骤:
- 生成随机验证码
- 将验证码保存到Redis中,用来在注册的时候验证
- 发送验证码到leyou-sms-service服务,发送短信
在UserController类中添加方法:
/**
* 发送手机验证码
* @param phone
* @return
*/
@PostMapping("code")
public ResponseEntity<Void> sendVerifyCode(String phone) {
Boolean boo = this.userService.sendVerifyCode(phone);
if (boo == null || !boo) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
return new ResponseEntity<>(HttpStatus.CREATED);
}
在UserService中添加如下代码:
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private AmqpTemplate amqpTemplate;
static final String KEY_PREFIX = "user:code:phone:";
static final Logger logger = LoggerFactory.getLogger(UserService.class);
public Boolean sendVerifyCode(String phone) {
// 生成验证码
String code = NumberUtils.generateCode(6);
try {
// 发送短信
Map<String, String> msg = new HashMap<>();
msg.put("phone", phone);
msg.put("code", code);
this.amqpTemplate.convertAndSend("leyou.sms.exchange", "sms.verify.code", msg);
// 将code存入redis
this.redisTemplate.opsForValue().set(KEY_PREFIX + phone, code, 5, TimeUnit.MINUTES);
return true;
} catch (Exception e) {
logger.error("发送短信失败。phone:{}, code:{}", phone, code);
return false;
}
}
注意:要设置短信验证码在Redis的缓存时间为5分钟
16.6 注册功能
接口说明
基本逻辑:
- 1)校验短信验证码
- 2)生成盐
- 3)对密码加密
- 4)写入数据库
- 5)删除Redis中的验证码
在UserController类中加入:
/**
* 注册
* @param user
* @param code
* @return
*/
@PostMapping("register")
public ResponseEntity<Void> register(User user, @RequestParam("code") String code) {
Boolean boo = this.userService.register(user, code);
if (boo == null || !boo) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
return new ResponseEntity<>(HttpStatus.CREATED);
}
在UserService中加入:
public Boolean register(User user, String code) {
// 校验短信验证码
String cacheCode = this.redisTemplate.opsForValue().get(KEY_PREFIX + user.getPhone());
if (!StringUtils.equals(code, cacheCode)) {
return false;
}
// 生成盐
String salt = CodecUtils.generateSalt();
user.setSalt(salt);
// 对密码加密
user.setPassword(CodecUtils.md5Hex(user.getPassword(), salt));
// 强制设置不能指定的参数为null
user.setId(null);
user.setCreated(new Date());
// 添加到数据库
boolean b = this.userMapper.insertSelective(user) == 1;
if(b){
// 注册成功,删除redis中的记录
this.redisTemplate.delete(KEY_PREFIX + user.getPhone());
}
return b;
}
hibernate-validate
刚才虽然实现了注册,但是服务端并没有进行数据校验,而前端的校验是很容易被有心人绕过的。所以我们必须在后台添加数据校验功能:我们这里会使用Hibernate-Validator框架完成数据校验:
而SpringBoot的web启动器中已经集成了相关依赖:
Hibernate Validator是Hibernate提供的一个开源框架,使用注解方式非常方便的实现服务端的数据校验。hibernate Validator 是 Bean Validation 的参考实现 。Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint(约束) 的实现,除此之外还有一些附加的 constraint。在日常开发中,Hibernate Validator经常用来验证bean的字段,基于注解,方便快捷高效。
常用的注解如下:
如何使用呢?
1.引入依赖
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
2.在User对象的部分属性上添加注解:
@Table(name = "tb_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Length(min = 4, max = 30, message = "用户名只能在4~30位之间")
private String username;// 用户名
@JsonIgnore
@Length(min = 4, max = 30, message = "密码只能在4~30位之间")
private String password;// 密码
@Pattern(regexp = "^1[35678]\\d{9}$", message = "手机号格式不正确")
private String phone;// 电话
private Date created;// 创建时间
@JsonIgnore
private String salt;// 密码的盐值
}
3.在controller中改造register方法,只需要给User添加 @Valid注解即可。
16.7 根据用户名和密码查询用户
查询功能,根据参数中的用户名和密码查询指定用户
接口路径
GET /query
参数说明
参数 | 说明 | 是否必须 | 数据类型 | 默认值 |
---|---|---|---|---|
username | 用户名,格式为4~30位字母、数字、下划线 | 是 | String | 无 |
password | 用户密码,格式为4~30位字母、数字、下划线 | 是 | String | 无 |
状态码
- 200:注册成功
- 400:用户名或密码错误
- 500:服务器内部异常,注册失败
在UserController类中加入:
/**
* 根据用户名和密码查询用户
* @param username
* @param password
* @return
*/
@GetMapping("query")
public ResponseEntity<User> queryUser(
@RequestParam("username") String username,
@RequestParam("password") String password
) {
User user = this.userService.queryUser(username, password);
if (user == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
return ResponseEntity.ok(user);
}
在UserService类中加入:
public User queryUser(String username, String password) {
// 查询
User record = new User();
record.setUsername(username);
User user = this.userMapper.selectOne(record);
// 校验用户名
if (user == null) {
return null;
}
// 校验密码
if (!user.getPassword().equals(CodecUtils.md5Hex(password, user.getSalt()))) {
return null;
}
// 用户名密码都正确
return user;
}
要注意,查询时也要对密码进行加密后判断是否一致。
16.8 总结
1.用户名和手机号的校验
判断type的值
1:校验用户名
2:校验手机号
使用selectCount(record)==0
2.发送短信功能
阿里大于
参照demo工程
redis
安装
SDR使用:StringRedisTemplate
搭建了一个微服务:sms-service,监听rabbitmq的队列,获取到消息之后发送短信
1.生成验证码
2.发送消息给rabbitMQ的队列
3.保存验证码到redis中
3.注册功能:
1.校验验证码
2.生成盐
3.加盐加密
4.新增用户
hibernate-validate:对bean Validate(JSR303 规范)的实现
提供了一系列的注解,通过注解就可以校验数据的合法性
@Valid
4.查询用户(用户名和密码)
1.根据用户查询用户
2.判断用户是否存在
3.对用户输入的密码加盐加密
4.对密码进行比较
17.授权中心
17.1 无状态登录原理
什么是有状态?
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
-
服务端保存大量数据,增加服务端压力
-
服务端保存用户状态,无法进行水平扩展
-
客户端请求依赖服务端,多次请求必须访问同一台服务器
什么是无状态
微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即: -
服务端不保存任何客户端请求者信息
-
客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
- 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务器
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩
- 减小服务端存储压力
如何实现无状态
无状态登录的流程:
- 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
- 认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
- 以后每次请求,客户端都携带认证的token
- 服务端对token进行解密,判断是否有效。
流程图:
整个登录过程中,最关键的点是什么?当然是token的安全性
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。采用何种方式加密才是安全可靠的呢?我们将采用JWT + RSA非对称加密
JWT
JWT,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权;官网:https://jwt.io
数据格式
JWT包含三部分数据:
- Header:头部,通常头部有两部分信息:
- 声明类型,这里是JWT
我们会对头部进行base64编码,得到第一部分数据
- 声明类型,这里是JWT
- Payload:载荷,就是有效数据,一般包含下面信息:
- 用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
- 注册声明:如token的签发时间,过期时间,签发人等
这部分也会采用base64编码,得到第二部分数据
- Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性
生成的数据格式:token==个人证件 jwt=个人身份证
可以看到分为3段,每段就是上面的一部分数据
JWT交互流程
流程图:
步骤翻译:
- 1、用户登录
- 2、服务的认证,通过后根据secret生成token
- 3、将生成的token返回给浏览器
- 4、用户每次请求携带token
- 5、服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息
- 6、处理请求,返回响应结果
因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务端就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。
非对称加密
加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密文),其逆过程就是解码(解密),加密技术的要点是加密算法,加密算法可以分为三类:
- 对称加密,如AES
- 基本原理:将明文分成N个组,然后使用密钥对各个组进行加密,形成各自的密文,最后把所有的分组密文进行合并,形成最终的密文。
- 优势:算法公开、计算量小、加密速度快、加密效率高
- 缺陷:双方都使用同样密钥,安全性得不到保证
- 非对称加密,如RSA
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 私钥加密,持有私钥或公钥才可以解密
- 公钥加密,持有私钥才可解密
- 优点:安全,难以破解
- 缺点:算法比较耗时
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 不可逆加密,如MD5,SHA
- 基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。
RSA算法历史:1977年,三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字缩写:RSA
结合Zuul的鉴权流程
我们逐步演进系统架构设计。需要注意的是:secret是签名的关键,因此一定要保密,我们放到鉴权中心保存,其它任何服务中都不能获取secret。
1.没有RSA加密时
在微服务架构中,我们可以把服务的鉴权操作放到网关中,将未通过鉴权的请求直接拦截,如图:
- 1、用户请求登录
- 2、Zuul将请求转发到授权中心,请求授权
- 3、授权中心校验完成,颁发JWT凭证
- 4、客户端请求其它功能,携带JWT
- 5、Zuul将jwt交给授权中心校验,通过后放行
- 6、用户请求到达微服务
- 7、微服务将jwt交给鉴权中心,鉴权同时解析用户信息
- 8、鉴权中心返回用户数据给微服务
- 9、微服务处理请求,返回响应
这种结构的问题:每次鉴权都需要访问鉴权中心,系统间的网络请求频率过高,效率略差,鉴权中心的压力较大。
2.结合RSA的鉴权
- 我们首先利用RSA生成公钥和私钥。私钥保存在授权中心,公钥保存在Zuul和各个信任的微服务
- 用户请求登录
- 授权中心校验,通过后用私钥对JWT进行签名加密
- 返回jwt给用户
- 用户携带JWT访问
- Zuul直接通过公钥解密JWT,进行验证,验证通过则放行
- 请求到达微服务,微服务直接用公钥解析JWT,获取用户信息,无需访问授权中心
**这里我一直没弄明白,RSA 对 JWT 加密,这不是多此一举吗???要么用RSA要么用JWT加密,如果你用RSA把JWT加密了,你解密的时候也只是得到了原来加密过的JWT,然后你怎么直接获取用户信息呢?????*
17.2 授权中心
授权中心的主要职责:
- 用户鉴权:
- 接收用户的登录请求,通过用户中心的接口进行校验,通过后生成JWT
- 使用私钥生成JWT并返回
- 服务鉴权:微服务间的调用不经过Zuul,会有风险,需要鉴权中心进行认证
- 原理与用户鉴权类似,但逻辑稍微复杂一些(此处我们不做实现)
因为生成jwt,解析jwt这样的行为以后在其它微服务中也会用到,因此我们会抽取成工具。我们把鉴权中心进行聚合,一个工具module,一个提供服务的module
我们先创建父module,名称为:leyou-auth
然后是授权服务的通用模块:leyou-auth-common
最后是授权服务的服务模块:leyou-auth-service(引入依赖和编写配置文件就省略了)
JWT工具类
我们在leyou-auth-common中导入资料中的工具类:
需要在leyou-auth-common中引入JWT依赖:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
</dependencies>
编写登录授权接口
接下来,我们需要在leyou-auth-servcice编写一个接口,对外提供登录授权服务。基本流程如下:
- 客户端携带用户名和密码请求登录
- 授权中心调用用户中心接口,根据用户名和密码查询用户信息
- 如果用户名密码正确,能获取用户,否则为空,则登录失败
- 如果校验成功,则生成JWT并返回
我们需要在授权中心生成真正的公钥和私钥。我们必须有一个生成公钥和私钥的secret,这个可以配置到application.yml中:
leyou:
jwt:
secret: leyou@Login(Auth}*^31)&heiMa% # 登录校验的密钥,这个可以随便写,写得越复杂越好
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
priKeyPath: C:\\tmp\\rsa\\rsa.pri # 私钥地址
expire: 30 # 过期时间,单位分钟(表示该jwt30分钟之后过期)
cookieName: LY_TOKEN
cookieMaxAge: 30
然后编写属性类
package com.leyou.config;
import com.leyou.common.utils.RsaUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.annotation.PostConstruct;
import java.io.File;
import java.security.PrivateKey;
import java.security.PublicKey;
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
private String secret; // 密钥
private String pubKeyPath;// 公钥
private String priKeyPath;// 私钥
private int expire;// token过期时间
private PublicKey publicKey; // 公钥
private PrivateKey privateKey; // 私钥
private String cookieName; //cookie的名字
private Integer cookieMaxAge; // cookie存活时间,单位为分钟
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
public String getCookieName() {
return cookieName;
}
public void setCookieName(String cookieName) {
this.cookieName = cookieName;
}
public Integer getCookieMaxAge() {
return cookieMaxAge;
}
public void setCookieMaxAge(Integer cookieMaxAge) {
this.cookieMaxAge = cookieMaxAge;
}
/**
* @PostContruct:在构造方法执行之后执行该方法
* 用于生成公钥和私钥
*/
@PostConstruct
public void init(){
try {
File pubKey = new File(pubKeyPath);
File priKey = new File(priKeyPath);
if (!pubKey.exists() || !priKey.exists()) {
// 生成公钥和私钥
RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
}
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
} catch (Exception e) {
logger.error("初始化公钥和私钥失败!", e);
throw new RuntimeException();
}
}
// getter setter ...
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public String getPubKeyPath() {
return pubKeyPath;
}
public void setPubKeyPath(String pubKeyPath) {
this.pubKeyPath = pubKeyPath;
}
public String getPriKeyPath() {
return priKeyPath;
}
public void setPriKeyPath(String priKeyPath) {
this.priKeyPath = priKeyPath;
}
public int getExpire() {
return expire;
}
public void setExpire(int expire) {
this.expire = expire;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
public PrivateKey getPrivateKey() {
return privateKey;
}
public void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
}
AuthController类:
编写授权接口,我们接收用户名和密码,校验成功后,写入cookie中。
- 请求方式:post
- 请求路径:/accredit
- 请求参数:username和password
- 返回结果:无
package com.leyou.auth.controller;
import com.leyou.auth.service.AuthService;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import com.leyou.config.JwtProperties;
import com.leyou.user.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
@EnableConfigurationProperties(JwtProperties.class)
public class AuthController {
@Autowired
private AuthService authService;
@Autowired
private JwtProperties jwtProperties;
@PostMapping("/accredit")
public ResponseEntity<Void> accredit(@RequestParam("username")String username,
@RequestParam("password")String password,
HttpServletRequest request,
HttpServletResponse response){
String token = this.authService.accredit(username,password);
if(StringUtils.isEmpty(token)){
ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token,jwtProperties.getCookieMaxAge() * 60);
return ResponseEntity.ok(null);
}
@GetMapping("/verify")
public ResponseEntity<UserInfo> verify(@CookieValue("LY_TOKEN")String token,
HttpServletRequest request,
HttpServletResponse response){
UserInfo user = this.authService.verify(token);
if(user == null){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
//重新生成token
token = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());
//重新设置cookie
CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token, jwtProperties.getExpire() * 60);
return ResponseEntity.ok(user);
} catch (Exception e) {
e.printStackTrace();
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
AuthService类:
package com.leyou.auth.service;
import com.leyou.auth.client.UserClient;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import com.leyou.config.JwtProperties;
import com.leyou.user.pojo.User;
import com.netflix.discovery.converters.Auto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@EnableConfigurationProperties(JwtProperties.class)
public class AuthService {
@Autowired
private JwtProperties jwtProperties;
@Autowired
private UserClient userClient;
/**
* 根据用户名和密码生成token
* @param username
* @param password
* @return
*/
public String accredit(String username, String password) {
// 利用feign组件调用远程接口查询用户是否存在
User user = this.userClient.query(username, password);
if(user == null){
return null;
}
//如果用户存在,则生成token
try {
return JwtUtils.generateToken(new UserInfo(user.getId(), user.getUsername()), jwtProperties.getPrivateKey(), jwtProperties.getExpire());
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 根据token解析出相应的用户
* @param token
* @return
*/
public UserInfo verify(String token) {
if(StringUtils.isEmpty(token)){
return null;
}
try {
UserInfo userInfo = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey());
return userInfo;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
解决cookie写入问题
我在之前测试时,清晰的看到了响应头中,有Set-Cookie属性,为什么在这里却什么都没有?
之前复习cors跨域时,讲到过跨域请求cookie生效的条件:
- 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
- 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
- 浏览器发起ajax需要指定withCredentials 为true
看看我们的服务端cors配置:
没有任何问题。
再看客户端浏览器的ajax配置,我们在js/common.js中对axios进行了统一配置:
一切OK。那说明,问题一定出在响应的set-cookie头中。
我们发现cookie的 domain属性似乎不太对。
说明cookie也是有域 的限制,一个网页,只能操作当前域名下的cookie,但是现在我们看到的地址是0.0.1,而页面是www.leyou.com,域名不匹配,cookie设置肯定失败了!
我们去Debug跟踪CookieUtils,看看到底是怎么回事:我们发现内部有一个方法,用来获取Domain:
它获取domain是通过服务器的host来计算的,然而我们的地址竟然是:127.0.0.1:8087,因此后续的运算,最终得到的domain就变成了:
问题找到了:我们请求时的serverName明明是:www.api.leyou.com,现在却被变成了:127.0.0.1,因此计算domain是错误的,从而导致cookie设置失败!
解决host地址的变化
那么问题来了:为什么我们这里的请求serverName变成了:127.0.0.1:8087呢?
这里的server name其实就是请求的时的主机名:Host,之所以改变,有两个原因:
- 我们使用了nginx反向代理,当监听到api.leyou.com的时候,会自动将请求转发至127.0.0.1:10010,即Zuul。
- 而后请求到达我们的网关Zuul,Zuul就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了 127.0.0.1:8087 ,即我们的授权中心。
我们首先去更改nginx配置,让它不要修改我们的host:proxy_set_header Host $host
这样就解决了nginx这里的问题。但是Zuul还会有一次转发,所以要去修改网关的配置(leyou-gateway工程):
再次测试,发现依然没有cookie!!
Zuul的敏感头过滤
Zuul内部有默认的过滤器,会对请求和响应头信息进行重组,过滤掉敏感的头信息:
会发现,这里会通过一个属性为SensitiveHeaders的属性,来获取敏感头列表,然后添加到IgnoredHeaders中,这些头信息就会被忽略。
而这个SensitiveHeaders的默认值就包含了set-cookie:
解决方案有两种:
全局设置:
- zuul.sensitive-headers=
指定路由设置:
- zuul.routes..sensitive-headers=
- zuul.routes..custom-sensitive-headers=true
思路都是把敏感头设置为null
17.3 首页判断登录状态
虽然cookie已经成功写入,但是我们首页的顶部,登录状态依然没能判断出用户信息
所以需要向后台发起请求,根据cookie获取当前用户的信息。
后台实现校验用户接口
我们在leyou-auth-service中定义用户的校验接口,通过cookie获取token,然后校验通过返回用户信息。
- 请求方式:GET
- 请求路径:/verify
- 请求参数:无,不过我们需要从cookie中获取token信息
- 返回结果:UserInfo,校验成功返回用户信息;校验失败,则返回401
代码如下:
/**
* 验证用户信息
* @param token
* @return
*/
@GetMapping("verify")
public ResponseEntity<UserInfo> verifyUser(@CookieValue("LY_TOKEN")String token){
try {
// 从token中解析token信息
UserInfo userInfo = JwtUtils.getInfoFromToken(token, this.properties.getPublicKey());
// 解析成功返回用户信息
return ResponseEntity.ok(userInfo);
} catch (Exception e) {
e.printStackTrace();
}
// 出现异常则,响应500
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
刷新token
每当用户在页面进行新的操作,都应该刷新token的过期时间,否则30分钟后用户的登录信息就无效了。而刷新其实就是重新生成一份token,然后写入cookie即可。
那么问题来了:我们怎么知道用户有操作呢?
事实上,每当用户来查询其个人信息,就证明他正在浏览网页,此时刷新cookie是比较合适的时机。因此我们可以对刚刚的校验用户登录状态的接口进行改进,加入刷新token的逻辑。
@GetMapping("/verify")
public ResponseEntity<UserInfo> verify(@CookieValue("LY_TOKEN")String token,
HttpServletRequest request,
HttpServletResponse response){
UserInfo user = this.authService.verify(token);
if(user == null){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
//重新生成token
token = JwtUtils.generateToken(user, jwtProperties.getPrivateKey(), jwtProperties.getExpire());
//重新设置cookie
CookieUtils.setCookie(request,response,jwtProperties.getCookieName(),token, jwtProperties.getExpire() * 60);
return ResponseEntity.ok(user);
} catch (Exception e) {
e.printStackTrace();
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
17.4 网关的登录拦截器
接下来,我们在Zuul编写拦截器,对用户的token进行校验,如果发现未登录,则进行拦截。
1.引入依赖
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
2.修改配置文件
leyou:
jwt:
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
cookieName: LY_TOKEN # cookie的名称
3.编写属性类:
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
private String pubKeyPath;// 公钥
private PublicKey publicKey; // 公钥
private String cookieName;
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
@PostConstruct
public void init(){
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
logger.error("初始化公钥失败!", e);
throw new RuntimeException();
}
}
public String getPubKeyPath() {
return pubKeyPath;
}
public void setPubKeyPath(String pubKeyPath) {
this.pubKeyPath = pubKeyPath;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
public String getCookieName() {
return cookieName;
}
public void setCookieName(String cookieName) {
this.cookieName = cookieName;
}
}
4.编写过滤器逻辑
基本逻辑:
- 获取cookie中的token
- 通过JWT对token进行校验
- 通过:则放行;不通过:则重定向到登录页
package com.leyou.gateway.filter;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.utils.JwtUtils;
import com.leyou.gateway.config.FilterProperties;
import com.leyou.gateway.config.JwtProperties;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
@Controller
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class LoginFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProperties;
@Autowired
private FilterProperties filterProperties;
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
// 获取上下文
RequestContext context = RequestContext.getCurrentContext();
// 拿到request对象
HttpServletRequest request = context.getRequest();
String url = request.getRequestURL().toString();
List<String> allowPaths = filterProperties.getAllowPaths();
//白名单直接放行
for (String allowPath : allowPaths) {
if(StringUtils.contains(url, allowPath))
return false;
}
return true;
}
@Override
public Object run() throws ZuulException {
// 获取上下文
RequestContext context = RequestContext.getCurrentContext();
// 拿到request对象
HttpServletRequest request = context.getRequest();
String token = CookieUtils.getCookieValue(request,jwtProperties.getCookieName());
try {
JwtUtils.getInfoFromToken(token,jwtProperties.getPublicKey());
} catch (Exception e) {
// 若出现异常则说明解析失败
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
}
白名单
要注意,并不是所有的路径我们都需要拦截,例如:
- 登录校验接口:/auth/**
- 注册接口:/user/register
- 数据校验接口:/user/check/**
- 发送验证码接口:/user/code
- 搜索接口:/search/**
另外,跟后台管理相关的接口,因为我们没有做登录和权限,因此暂时都放行,但是生产环境中要做登录校验:
- 后台商品服务:/item/**
所以,我们需要在拦截时,配置一个白名单,如果在名单内,则不进行拦截。
在application.yml中添加规则:
leyou:
jwt:
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
cookieName: LY_TOKEN
filter:
allowPaths:
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
- /api/goods/item
然后编写配置类读取这些属性:
@ConfigurationProperties(prefix = "leyou.filter")
public class FilterProperties {
private List<String> allowPaths;
public List<String> getAllowPaths() {
return allowPaths;
}
public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}
最后在过滤器中的shouldFilter方法中添加判断逻辑,代码上面已经粘贴过了。
18.购物车
18.1 搭建购物车微服务
购物车微服务命名为:leyou-cart
1.导入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.cart</groupId>
<artifactId>leyou-cart</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- 要求使用jdk8,我这里用的是jdk11,需要导入这些Jar包,太坑了-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>
</project>
2.编写配置文件
server:
port: 8088
spring:
application:
name: cart-service
redis:
host: 192.168.124.121
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
registry-fetch-interval-seconds: 10
instance:
lease-expiration-duration-in-seconds: 15
lease-renewal-interval-in-seconds: 5
leyou:
jwt:
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
cookieName: LY_TOKEN
3.编写引导类
package com.leyou;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LeyouCartApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouCartApplication.class,args);
}
}
18.2 购物车功能分析
需求描述:
- 用户可以在登录状态下将商品添加到购物车
- 放入数据库
- mongodb
- 放入redis(采用)
- 用户可以在未登录状态下将商品添加到购物车
- 放入localstorage
- cookie
- webSQL
- 用户可以使用购物车一起结算下单
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
这幅图主要描述了两个功能:新增商品到购物车、查询购物车。
新增商品:
- 判断是否登录
- 是:则添加商品到后台Redis中
- 否:则添加商品到本地的Localstorage
无论哪种新增,完成后都需要查询购物车列表:
- 判断是否登录
- 否:直接查询localstorage中数据并展示
- 是:已登录,则需要先看本地是否有数据,
- 有:需要提交到后台添加到redis,合并数据,而后查询
- 否:直接去后台查询redis,而后返回
18.3 未登录的购物车
web本地存储
知道了数据结构,下一个问题,就是如何保存购物车数据。前面我们分析过,可以使用Localstorage来实现。Localstorage是web本地存储的一种,那么,什么是web本地存储呢?
web本地存储主要有两种方式:
- LocalStorage:localStorage 方法存储的数据没有时间限制。第二天、第二周或下一年之后,数据依然可用。
- SessionStorage:sessionStorage 方法针对一个 session 进行数据存储。当用户关闭浏览器窗口后,数据会被删除。
LocalStorage的用法
localStorage.setItem("key","value"); // 存储数据
localStorage.getItem("key"); // 获取数据
localStorage.removeItem("key"); // 删除数据
注意:localStorage和SessionStorage都只能保存字符串。
不过,在我们的common.js中,已经对localStorage进行了简单的封装:
添加购物车
addCart(){
ly.verifyUser().then(res=>{
// 已登录发送信息到后台,保存到redis中
}).catch(()=>{
// 未登录保存在浏览器本地的localStorage中
// 1、查询本地购物车
let carts = ly.store.get("carts") || [];
let cart = carts.find(c=>c.skuId===this.sku.id);
// 2、判断是否存在
if (cart) {
// 3、存在更新数量
cart.num += this.num;
} else {
// 4、不存在,新增
cart = {
skuId: this.sku.id,
title: this.sku.title,
price: this.sku.price,
image: this.sku.images,
num: this.num,
ownSpec: this.ownSpec
}
carts.push(cart);
}
// 把carts写回localstorage
ly.store.set("carts", carts);
// 跳转
window.location.href = "http://www.leyou.com/cart.html";
});
}
查询购物车
因为会多次校验用户登录状态,因此我们封装一个校验的方法:
在common.js中:
在页面item.html中使用该方法:
查询购物车
页面加载时,就去查询购物车:
var cartVm = new Vue({
el: "#cartApp",
data: {
ly,
carts: [],// 购物车数据
},
created() {
this.loadCarts();
},
methods: {
loadCarts() {
// 先判断登录状态
ly.verifyUser().then(() => {
// 已登录
}).catch(() => {
// 未登录
this.carts = ly.store.get("carts") || [];
this.selected = this.carts;
})
}
}
components: {
shortcut: () => import("/js/pages/shortcut.js")
}
})
渲染到页面
删除商品
给删除按钮绑定事件:
点击事件中删除商品:
deleteCart(i){
ly.verifyUser().then(res=>{
// TODO,已登录购物车
}).catch(()=>{
// 未登录购物车
this.carts.splice(i, 1);
ly.store.set("carts", this.carts);
})
}
18.4 已登录购物车
接下来,我们完成已登录购物车。在刚才的未登录购物车编写时,我们已经预留好了编写代码的位置,逻辑也基本一致。
添加登录校验
1.引入jwt相关依赖
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
2.配置公钥
leyou:
jwt:
pubKeyPath: C:/tmp/rsa/rsa.pub # 公钥地址
cookieName: LY_TOKEN # cookie的名称
3.编写属性类
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
private String pubKeyPath;// 公钥
private PublicKey publicKey; // 公钥
private String cookieName;
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
@PostConstruct
public void init(){
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
logger.error("初始化公钥失败!", e);
throw new RuntimeException();
}
}
public String getPubKeyPath() {
return pubKeyPath;
}
public void setPubKeyPath(String pubKeyPath) {
this.pubKeyPath = pubKeyPath;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
public String getCookieName() {
return cookieName;
}
public void setCookieName(String cookieName) {
this.cookieName = cookieName;
}
}
4.编写拦截器
因为很多接口都需要进行登录,我们直接编写SpringMVC拦截器,进行统一登录校验。同时,我们还要把解析得到的用户信息保存起来,以便后续的接口可以使用。
package com.leyou.cart.interceptor;
import com.leyou.cart.config.JwtProperties;
import com.leyou.common.pojo.CookieUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.common.utils.JwtUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
@EnableConfigurationProperties(JwtProperties.class)
public class LoginIntercepter extends HandlerInterceptorAdapter {
//这里使用线程锁来保存登录用户的信息,以防止线程安全问题
private static final ThreadLocal<UserInfo> THREAD_LOCAL = new ThreadLocal<>();
@Autowired
private JwtProperties jwtProperties;
/**
* 只实现前置方法,用于从token中解析登录的用户信息
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String token = CookieUtils.getCookieValue(request, jwtProperties.getCookieName());
if(StringUtils.isBlank(token)){
// 未登录,返回401
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
// 有token,查询用户信息
try {
// 解析成功,证明已经登录
UserInfo user = JwtUtils.getInfoFromToken(token, jwtProperties.getPublicKey());
// 放入线程域
THREAD_LOCAL.set(user);
return true;
} catch (Exception e){
// 抛出异常,证明未登录,返回401
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
THREAD_LOCAL.remove();
}
public static UserInfo getUserInfo(){
return THREAD_LOCAL.get();
}
}
注意:
- 这里我们使用了ThreadLocal来存储查询到的用户信息,线程内共享,因此请求到达Controller后可以共享User
- 并且对外提供了静态的方法:getLoginUser()来获取User信息
(这里我不是很理解,需要对多线程和锁进一步加深理解)
5.配置拦截器
package com.leyou.cart.interceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class LeyouWebMVCIntercepter implements WebMvcConfigurer {
@Autowired
private LoginIntercepter loginIntercepter;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginIntercepter).addPathPatterns("/**");
}
}
后台购物车设计
当用户登录时,我们需要把购物车数据保存到后台,可以选择保存在数据库。但是购物车是一个读写频率很高的数据。因此我们这里选择读写效率比较高的Redis作为购物车存储。
Redis有5种不同数据结构,这里选择哪一种比较合适呢?Map<String, List<String>>
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为key来存储,Value是用户的所有购物车信息。这样看来基本的k-v结构就可以了。
- 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品id进行判断,为了方便后期处理,我们的购物车也应该是k-v结构,key是商品id,value才是这个商品的购物车信息。
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
- 第一层Map,Key是用户id
- 第二层Map,Key是购物车中商品id,值是购物车数据
实体类:
package com.leyou.cart.pojo;
public class Cart {
private Long userId;// 用户id
private Long skuId;// 商品id
private String title;// 标题
private String image;// 图片
private Long price;// 加入购物车时的价格
private Integer num;// 购买数量
private String ownSpec;// 商品规格参数
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Long getPrice() {
return price;
}
public void setPrice(Long price) {
this.price = price;
}
public Integer getNum() {
return num;
}
public void setNum(Integer num) {
this.num = num;
}
public String getOwnSpec() {
return ownSpec;
}
public void setOwnSpec(String ownSpec) {
this.ownSpec = ownSpec;
}
}
页面发起请求
已登录情况下,向后台添加购物车:
这里发起的是Json请求。那么我们后台也要以json接收。
Controller类:
先分析一下:
- 请求方式:新增,肯定是Post
- 请求路径:/cart ,这个其实是Zuul路由的路径,我们可以不管
- 请求参数:Json对象,包含skuId和num属性
- 返回结果:无
@Controller
public class CartController {
@Autowired
private CartService cartService;
/**
* 添加购物车
*
* @return
*/
@PostMapping
public ResponseEntity<Void> addCart(@RequestBody Cart cart) {
this.cartService.addCart(cart);
return ResponseEntity.ok().build();
}
}
CartService类:
这里我们不访问数据库,而是直接操作Redis。基本思路:
- 先查询之前的购物车数据
- 判断要添加的商品是否存在
- 存在:则直接修改数量后写回Redis
- 不存在:新建一条数据,然后写入Redis
@Service
public class CartService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private GoodsClient goodsClient;
static final String KEY_PREFIX = "leyou:cart:uid:";
static final Logger logger = LoggerFactory.getLogger(CartService.class);
public void addCart(Cart cart) {
// 获取登录用户
UserInfo user = LoginInterceptor.getLoginUser();
// Redis的key
String key = KEY_PREFIX + user.getId();
// 获取hash操作对象
BoundHashOperations<String, Object, Object> hashOps = this.redisTemplate.boundHashOps(key);
// 查询是否存在
Long skuId = cart.getSkuId();
Integer num = cart.getNum();
Boolean boo = hashOps.hasKey(skuId.toString());
if (boo) {
// 存在,获取购物车数据
String json = hashOps.get(skuId.toString()).toString();
cart = JsonUtils.parse(json, Cart.class);
// 修改购物车数量
cart.setNum(cart.getNum() + num);
} else {
// 不存在,新增购物车数据
cart.setUserId(user.getId());
// 其它商品信息,需要查询商品服务
Sku sku = this.goodsClient.querySkuById(skuId);
cart.setImage(StringUtils.isBlank(sku.getImages()) ? "" : StringUtils.split(sku.getImages(), ",")[0]);
cart.setPrice(sku.getPrice());
cart.setTitle(sku.getTitle());
cart.setOwnSpec(sku.getOwnSpec());
}
// 将购物车数据写入redis
hashOps.put(cart.getSkuId().toString(), JsonUtils.serialize(cart));
}
}
查询购物车
CartController类:
/**
* 查询购物车列表
*
* @return
*/
@GetMapping
public ResponseEntity<List<Cart>> queryCartList() {
List<Cart> carts = this.cartService.queryCartList();
if (carts == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
return ResponseEntity.ok(carts);
}
CartService类:
public List<Cart> queryCartList() {
// 获取登录用户
UserInfo user = LoginInterceptor.getLoginUser();
// 判断是否存在购物车
String key = KEY_PREFIX + user.getId();
if(!this.redisTemplate.hasKey(key)){
// 不存在,直接返回
return null;
}
BoundHashOperations<String, Object, Object> hashOps = this.redisTemplate.boundHashOps(key);
List<Object> carts = hashOps.values();
// 判断是否有数据
if(CollectionUtils.isEmpty(carts)){
return null;
}
// 查询购物车数据
return carts.stream().map(o -> JsonUtils.parse(o.toString(), Cart.class)).collect(Collectors.toList());
}
修改商品数量
incr(cart){
_this = this;
cart.num += 1;
ly.verify().then(
() => {
// 用户已经登录则更新redis
ly.http.put("/cart",{
skuId: cart.skuId,
num: cart.num
}).then();
}
).catch(res => {
// 未登录
ly.store.set("LY_CARTS",this.carts);
});
},
decr(cart){
if(cart.num > 1){
cart.num -= 1;
}
ly.verify().then(
() => {
// 用户已经登录则更新redis
ly.http.put("/cart",{
skuId: cart.skuId,
num: cart.num
}).then();
}
).catch(res => {
// 未登录
ly.store.set("LY_CARTS",this.carts);
});
},
删除购物车商品
del(cart){
//这里只是改变了 this.carts, redis和localStorage并没有发生改变
let index = this.carts.indexOf(cart);
this.carts.splice(index,1);
//this.selected.splice(index,1);
ly.verify().then(() => {
// 用户已经登录则更新redis
ly.http.delete("/cart",{
skuId: cart.skuId,
}).then();
}).catch(res => {
//如果没有登录则更新localStorage
ly.store.set("LY_CARTS",this.carts);
});
},
deleteSelected(){
temp = this.selected;
this.selected.forEach(cart => {
let index = this.carts.indexOf(cart);
this.carts.splice(index,1);
});
//这里只是改变了 this.carts, redis和localStorage并没有发生改变
ly.verify().then(() => {
//如果已经登录则更新redis
//删除所有的被选中的cart
temp.forEach(cart => {
ly.http.delete("/cart",{
skuId: cart.skuId,
}).then();
})
}).catch(res => {
//如果没有登录则更新localStorage
ly.store.set("LY_CARTS",this.carts);
});
}
最终的CartController:
package com.leyou.cart.controller;
import com.leyou.cart.client.GoodsClient;
import com.leyou.cart.pojo.Cart;
import com.leyou.cart.service.CartService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
public class CartController {
@Autowired
private CartService cartService;
/**
* 根据前端传递过来的cart添加购物车
* @param cart
* @return
*/
@PostMapping
public ResponseEntity<Void> addCart(@RequestBody Cart cart){
this.cartService.addCart(cart);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
/**
* 从redis查询登录用户的所有购物车记录
* @return
*/
@GetMapping("/all")
public ResponseEntity<List<Cart>> queryCarts(){
List<Cart> carts = this.cartService.queryCarts();
if(CollectionUtils.isEmpty(carts)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(carts);
}
/**
* 在redis中更新用户的购物车记录
* @param cart
* @return
*/
@PutMapping
public ResponseEntity<Void> updateNum(@RequestBody Cart cart){
this.cartService.updateNum(cart);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
/**
* 在redis中删除用户的购物车记录
* @param cart
* @return
*/
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Cart cart){
this.cartService.delete(cart);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
最终的CartService:
package com.leyou.cart.service;
import com.leyou.cart.client.GoodsClient;
import com.leyou.cart.interceptor.LoginIntercepter;
import com.leyou.cart.pojo.Cart;
import com.leyou.common.pojo.JsonUtils;
import com.leyou.common.pojo.UserInfo;
import com.leyou.item.pojo.Sku;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class CartService {
private static String KEY_PREFIX = "user:cart:";
@Autowired
private GoodsClient goodsClient;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 将前端发送过来的cart加入到redis中
* redis结构:<userId,Map<skuId,cart>>
* cart里应该有如下信息:userId,skuId,image,title,ownSpec,num,price
* @param cart
*/
public void addCart(Cart cart) {
//拿到登录的用户信息
UserInfo userInfo = LoginIntercepter.getUserInfo();
cart.setUserId(userInfo.getId());
//查询redis
BoundHashOperations<String, Object, Object> hashOperations = redisTemplate.boundHashOps(KEY_PREFIX + cart.getSkuId());
String key = cart.getSkuId().toString();
Integer num = cart.getNum();
if(hashOperations.hasKey(key)){
// 如果之前已经有这件商品则更新数量
cart = JsonUtils.parse(hashOperations.get(key).toString(),Cart.class);
cart.setNum(num + cart.getNum());
}
else{
// 如果之前没有这件商品则新增即可
//查询sku,并补充cart的信息
Sku sku = this.goodsClient.querySkuByskuId(cart.getSkuId());
cart.setTitle(sku.getTitle());
cart.setOwnSpec(sku.getOwnSpec());
cart.setPrice(sku.getPrice());
cart.setImage(StringUtils.isEmpty(sku.getImages())? "":StringUtils.split(sku.getImages(),",")[0]);
}
//存入redis之中
hashOperations.put(key,JsonUtils.serialize(cart));
}
/**
* 从redis查询登录用户的所有购物车记录
* @return
*/
public List<Cart> queryCarts() {
//获取用户信息
UserInfo userInfo = LoginIntercepter.getUserInfo();
//获取redis中的购物车记录
String key = KEY_PREFIX + userInfo.getId();
BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(key);
List<Object> values = hashOperations.values();
//返回List<Cart>
return values.stream().map(cartJson -> {
return JsonUtils.parse(cartJson.toString(),Cart.class);
}).collect(Collectors.toList());
}
/**
* 在redis中更新用户的购物车记录
* @param cart
* @return
*/
public void updateNum(Cart cart) {
//获取用户信息
UserInfo userInfo = LoginIntercepter.getUserInfo();
//获取redis中的购物车记录
String key = KEY_PREFIX + userInfo.getId();
BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(key);
String kk = cart.getSkuId().toString();
Integer num = cart.getNum();
if(hashOperations.hasKey(kk)){
cart = JsonUtils.parse(hashOperations.get(kk).toString(), Cart.class);
cart.setNum(num);
hashOperations.put(kk,JsonUtils.serialize(cart));
}
}
/**
* 在redis中删除用户的购物车记录
* @param cart
* @return
*/
public void delete(Cart cart) {
//获取用户信息
UserInfo userInfo = LoginIntercepter.getUserInfo();
//获取redis中的购物车记录
BoundHashOperations<String, Object, Object> hashOperations = this.redisTemplate.boundHashOps(KEY_PREFIX + userInfo.getId());
//删除记录
String key = cart.getSkuId().toString();
if(hashOperations.hasKey(key)){
hashOperations.delete(key);
}
}
}
18.5 登录后购物车合并
当跳转到购物车页面,查询购物车列表前,需要判断用户登录状态,
- 如果登录:
- 首先检查用户的LocalStorage中是否有购物车信息,
- 如果有,则提交到后台保存,
- 清空LocalStorage
- 如果未登录,直接查询即可
19.下单
最后这部分我并没有完成代码的编写,想编写也并不是很好编写,因为微信支付的API和短信服务的API一样,要申请很麻烦。这里只记录下单方面需要重点注意的几个地方。
19.1 Swagger-UI
随着互联网技术的发展,现在的网站架构基本都由原来的后端渲染,变成了:前端渲染、前后端分离的形态,而且前端技术和后端技术在各自的道路上越走越远。 前端和后端的唯一联系,变成了API接口;API文档变成了前后端开发人员联系的纽带,变得越来越重要。没有API文档工具之前,大家都是手写API文档的,在什么地方书写的都有,而且API文档没有统一规范和格式,每个公司都不一样。这无疑给开发带来了灾难。OpenAPI规范(OpenAPI Specification 简称OAS)是Linux基金会的一个项目,试图通过定义一种用来描述API格式或API定义的语言,来规范RESTful服务开发过程。目前V3.0版本的OpenAPI规范已经发布并开源在github上 。
OpenAPI是一个编写API文档的规范,然而如果手动去编写OpenAPI规范的文档,是非常麻烦的。而Swagger就是一个实现了OpenAPI规范的工具集。
Swagger包含的工具集:
- Swagger编辑器: Swagger Editor允许您在浏览器中编辑YAML中的OpenAPI规范并实时预览文档。
- Swagger UI: Swagger UI是HTML,Javascript和CSS资产的集合,可以从符合OAS标准的API动态生成漂亮的文档。
- Swagger Codegen:允许根据OpenAPI规范自动生成API客户端库(SDK生成),服务器存根和文档。
- Swagger Parser:用于解析来自Java的OpenAPI定义的独立库
- Swagger Core:与Java相关的库,用于创建,使用和使用OpenAPI定义
- Swagger Inspector(免费): API测试工具,可让您验证您的API并从现有API生成OpenAPI定义
- SwaggerHub(免费和商业): API设计和文档,为使用OpenAPI的团队构建。
SpringBoot已经集成了Swagger,使用简单注解即可生成swagger的API文档。
1.引入依赖
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.8.0</version>
</dependency>
2.编写配置
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.8.0</version>
</dependency>
3.接口声明
@RestController
@RequestMapping("order")
@Api("订单服务接口")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private PayHelper payHelper;
/**
* 创建订单
*
* @param order 订单对象
* @return 订单编号
*/
@PostMapping
@ApiOperation(value = "创建订单接口,返回订单编号", notes = "创建订单")
@ApiImplicitParam(name = "order", required = true, value = "订单的json对象,包含订单条目和物流信息")
public ResponseEntity<Long> createOrder(@RequestBody @Valid Order order) {
Long id = this.orderService.createOrder(order);
return new ResponseEntity<>(id, HttpStatus.CREATED);
}
/**
* 分页查询当前用户订单
*
* @param status 订单状态
* @return 分页订单数据
*/
@GetMapping("list")
@ApiOperation(value = "分页查询当前用户订单,并且可以根据订单状态过滤",
notes = "分页查询当前用户订单")
@ApiImplicitParams({
@ApiImplicitParam(name = "page", value = "当前页",
defaultValue = "1", type = "Integer"),
@ApiImplicitParam(name = "rows", value = "每页大小",
defaultValue = "5", type = "Integer"),
@ApiImplicitParam(
name = "status",
value = "订单状态:1未付款,2已付款未发货,3已发货未确认,4已确认未评价,5交易关闭,6交易成功,已评价", type = "Integer"),
})
public ResponseEntity<PageResult<Order>> queryUserOrderList(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "status", required = false) Integer status) {
PageResult<Order> result = this.orderService.queryUserOrderList(page, rows, status);
if (result == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
常用注解说明:
/**
@Api:修饰整个类,描述Controller的作用
@ApiOperation:描述一个类的一个方法,或者说一个接口
@ApiParam:单个参数描述
@ApiModel:用对象来接收参数
@ApiProperty:用对象接收参数时,描述对象的一个字段
@ApiResponse:HTTP响应其中1个描述
@ApiResponses:HTTP响应整体描述
@ApiIgnore:使用该注解忽略这个API
@ApiError :发生错误返回的信息
@ApiImplicitParam:一个请求参数
@ApiImplicitParams:多个请求参数
*/
4.启动测试
启动服务,然后访问:http://localhost:8089/swagger-ui.html
19.2 生成订单id的方式
订单数据非常庞大,将来一定会做分库分表。那么这种情况下, 要保证id的唯一,就不能靠数据库自增,而是自己来实现算法,生成唯一id。而工具类所采用的生成id算法,是由Twitter公司开源的snowflake(雪花)算法。
简单原理
雪花算法会生成一个64位的二进制数据,为一个Long型。(转换成字符串后长度最多19) ,其基本结构:
第一位:为未使用
第二部分:41位为毫秒级时间(41位的长度可以使用69年)
第三部分:5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)
第四部分:最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
snowflake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和workerId作区分),并且效率较高。经测试snowflake每秒能够产生26万个ID。