第6章 数据同步解决方案-canal
角色:数据监控同步组。后端开发人员。
学习目标
- 能够完成数据监控微服务的开发
- 能够完成首页广告缓存更新的功能
- 能够完成商品上架索引库导入数据功能,能够画出流程图和说出实现思路
- 能够完成商品下架索引库删除数据功能,能够画出流程图和说出实现思路
1. canal
拓展:mysql 主备
1.1 canal简介
背景:
早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务 trigger 获取增量变更。从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。
原理:
一、MySQL主备复制原理: 拓展资料(mysql主从备份原理)
binlog关键
- MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看) binlog
MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据
- canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议
- mysql master收到dump请求,开始推送binary log给slave(也就是canal)
- canal解析binary log对象(原始为byte流)
https://github.com/alibaba/canal/wiki/QuickStart
1.2 环境部署
1.2.1 mysql开启binlog模式
(1)查看当前mysql是否开启binlog模式。
SHOW VARIABLES LIKE '%log_bin%'
如果log_bin的值为OFF是未开启,为ON是已开启。
(2)修改/etc/my.cnf 需要开启binlog模式。
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server_id=1
修改完成之后,重启mysqld的服务。
(3) 进入mysql
mysql -h localhost -u root -p
(4)创建账号 用于测试使用
使用root账号创建用户并授予权限
create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
1.2.2 canal服务端安装配置
(1)下载地址canal
https://github.com/alibaba/canal/releases/tag/canal-1.0.24
(2)下载之后 上传到linux系统中,解压缩到指定的目录/usr/local/canal
解压缩之后的目录结构如下:
(3)修改 exmaple下的实例配置
vi conf/example/instance.properties
修改如图所示的几个参数。
(3)指定读取位置
进入mysql中执行下面语句查看binlog所在位置
mysql> show master status;
显示如下:
+------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000001 | 120 | | | |
+------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)
如果file中binlog文件不为 mysql-bin.000001 可以重置mysql
mysql> reset master;
查看canal配置文件
vim /usr/local/canal/conf/example/meta.dat
找到对应的binlog信息更改一致即可
"journalName":"mysql-bin.000001","position":120,"
注意:如果不一致,可能导致以下错误
2019-06-17 19:35:20.918 [New I/O server worker #1-2] ERROR c.a.otter.canal.server.netty.handler.SessionHandler - something goes wrong with channel:[id: 0x7f2e9be3, /192.168.200.56:52225 => /192.168.200.128:11111], exception=java.io.IOException: Connection reset by peer
(4)启动服务:
[root@localhost canal]# ./bin/startup.sh
(5)查看日志:
cat /usr/local/canal/logs/canal/canal.log
这样就表示启动成功了。
1.3 数据监控微服务
当用户执行数据库的操作的时候,binlog 日志会被canal捕获到,并解析出数据。我们就可以将解析出来的数据进行相应的逻辑处理。
我们这里使用的一个开源的项目,它实现了springboot与canal的集成。比原生的canal更加优雅。
https://github.com/chenqian56131/spring-boot-starter-canal
使用前需要将starter-canal安装到本地仓库。
我们可以参照它提供的canal-test,进行代码实现。
1.3.1 微服务搭建
(1)创建工程模块ydles_canal,pom引入依赖
<!--canal相关-->
<dependency>
<groupId>com.xpand</groupId>
<artifactId>starter-canal</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!--rabbitMq相关-->
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
(2)创建包com.ydles.canal ,包下创建启动类
@EnableCanalClient //标注为canal客户端
@SpringBootApplication
public class CanalApplication {
public static void main(String[] args) {
SpringApplication.run(CanalApplication.class,args);
}
}
(3)添加配置文件application.properties
canal.client.instances.example.host=192.168.200.128
canal.client.instances.example.port=11111
canal.client.instances.example.batchSize=1000
spring.rabbitmq.host=192.168.200.128
(4)创建com.ydles.canal.listener包,包下创建类
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@CanalEventListener //这个类是canal的监听类
public class BusinessListener {
/**
*
* @param eventType 改变 insert update delete
* @param rowData
*/
@ListenPoint(schema = "ydles_business",table = "tb_ad")
public void adUpdate(CanalEntry.EventType eventType,CanalEntry.RowData rowData){
System.out.println("EventType:"+eventType);
//改变之前 这一行数据
List<CanalEntry.Column> beforeColumnsList = rowData.getBeforeColumnsList();
for (CanalEntry.Column column : beforeColumnsList) {
System.out.println("改变之前,列名字:"+column.getName()+"列值:"+column.getValue());
}
System.out.println("======================================");
//改变之后 这一行数据
rowData.getAfterColumnsList().forEach(c->System.out.println("改变之后,列名字:"+c.getName()+"列值:"+c.getValue()));
}
}
测试:启动数据监控微服务,修改ydles_business的tb_ad表,观察控制台输出。
2. 首页广告缓存更新
2.1 需求分析
当tb_ad(广告)表的数据发生变化时,更新redis中的广告数据。
2.2 实现思路
需求:双11来了,广告要变了!如何更新
http://192.168.200.128/ad_update?position=web_index_lb
想一下:怎么实现,微服务拆分。微服务之间如何通讯:rabbitmq。
方案:
(1)数据监控微服务,监控tb_ad表,当发生增删改操作时,提取position值(广告位置key), 发送到rabbitmq
(2)运营微服务从rabbitmq中提取消息,通过OkHttpClient调用ad_update来实现对广告缓存数据的更新。
http://192.168.200.128/ad_update?position=web_index_lb
2.3 代码实现
2.3.1 发送消息到mq
(1)在rabbitmq管理后台创建队列 ad_update_queue ,用于接收广告更新通知
(2)引入rabbitmq起步依赖
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
(3)配置文件application.properties 添加内容
spring.rabbitmq.host=192.168.200.128
(4)新增rabbitMQ配置类 com.ydles.canal.config
@Configuration
public class RabbitMQConfig {
//定义队列名称
public static final String AD_UPDATE_QUEUE="ad_update_queue";
//声明队列
@Bean
public Queue queue(){
return new Queue(AD_UPDATE_QUEUE);
}
}
(4)修改BusinessListener类
@CanalEventListener //声明当前的类是canal的监听类
public class BusinessListener {
@Autowired
RabbitTemplate rabbitTemplate;
/**
*
* @param eventType 当前操作数据库的类型
* @param rowData 当前操作数据库的数据
*/
@ListenPoint(schema = "ydles_business",table = "tb_ad")
public void adUpdate(CanalEntry.EventType eventType,CanalEntry.RowData rowData){
System.out.println("广告表数据发生改变");
// rowData.getBeforeColumnsList().forEach((c)-> System.out.println("改变前的数据:"+c.getName()+"::"+c.getValue()));
// System.out.println("========================================");
// rowData.getAfterColumnsList().forEach((c)-> System.out.println("改变后的数据:"+c.getName()+"::"+c.getValue()));
for (CanalEntry.Column column : rowData.getAfterColumnsList()) {
if(column.getName().equals("position")){
System.out.println("发送数据到mq"+column.getValue());
//发送消息
rabbitTemplate.convertAndSend("", RabbitMQConfig.AD_UPDATE_QUEQU, column.getValue());
}
}
}
}
(5)测试:
重启canal服务
打开 http://192.168.200.128:15672/ 观察mq
修改数据库一条数据,查看程序输出、mq
2.3.2 从mq中提取消息执行更新
ydles_service_business工程
(1)pom.xml引入依赖
<!-- mq依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- 远程调用依赖 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.9.0</version>
</dependency>
(2)在spring节点下添加rabbitmq配置
rabbitmq:
host: 192.168.200.128
(3)com.ydles.business.listener下添加监听类
需求:发送http://192.168.200.128/ad_update?position=web_index_lb
@Component
public class AdListener {
@Autowired
RestTemplate restTemplate;
@RabbitListener(queues = "ad_update_queue")
public void reveiveMsg(String position) {
System.out.println("接受到了广告消息,位置:" + position);
//发请求 http://192.168.200.128/ad_update?position=web_index_lb
String url = "http://192.168.200.128/ad_update?position=" + position;
restTemplate.getForObject(url, Map.class);
////1,创建okHttpClient对象
//OkHttpClient okHttpClient = new OkHttpClient();
////2,创建一个Request
//String url = "http://192.168.200.128/ad_update?position=" + position;
//final Request request = new Request.Builder()
// .url(url)
// .build();
////3,新建一个call对象
//Call call = okHttpClient.newCall(request);
////4,请求加入调度,这里是异步Get请求回调
//call.enqueue(new Callback() {
// //失败了怎么样
// @Override
// public void onFailure(Call call, IOException e) {
// System.out.println("发送请求失败");
// }
//
// //成功了应该怎么样
// @Override
// public void onResponse(Call call, Response response) throws IOException {
// System.out.println("发送请求成功"+response.message());
// }
//});
}
}
(7)测试,启动eureka和business微服务,观察控制台输出和数据同步效果。
拓展:java 发送请求 三个原生客户端
okhttp
httpclient
URLconnection
3. 商品上架索引库导入数据
3.1 需求分析
商品上架将商品的sku列表导入或更新索引库。
3.2 实现思路
(1)商品上架(spu.is_marketable 0变为1) —>canal监控—>mq发送spuId。
(2)在rabbitmq管理后台创建商品上架交换机(fanout订阅发布模式)。为什么?
- 导入索引库。
- 详情页面静态化。
(3)spuId—>搜索服务—>feign商品服务获取skulList—>放入Es索引库。
3.3 代码实现
3.3.1 发送消息到mq
(1)在rabbitmq后台创建交换器goods_up_exchange(类型为fanout),创建队列search_add_queue绑定交换器goods_up_exchange,更新rabbitmq配置类
回顾:rabbitMq五种工作模式。
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Configuration
public class RabbitMQConfig {
//定义队列名称
public static final String AD_UPDATE_QUEUE="ad_update_queue";
@Bean
public Queue queue(){
return new Queue(AD_UPDATE_QUEUE,true);
}
//上架交换机名称
public static final String GOODS_UP_EXCHANGE = "goods_up_exchange";
//定义上架队列名称
public static final String SEARCH_ADD_QUEUE = "search_add_queue";
//定义上架队列
@Bean(SEARCH_ADD_QUEUE)
public Queue SEARCH_ADD_QUEUE(){
return new Queue(SEARCH_ADD_QUEUE);
}
//定义上架交换机
@Bean(GOODS_UP_EXCHANGE)
public Exchange GOODS_UP_EXCHANGE(){
return ExchangeBuilder.fanoutExchange(GOODS_UP_EXCHANGE).durable(true)
.build();
}
//上架队列和上架交换机绑定关系构建
@Bean
public Binding GOODS_UP_EXCHANGE_SEARCH_ADD_QUEUE(
@Qualifier(SEARCH_ADD_QUEUE)Queue queue
,@Qualifier(GOODS_UP_EXCHANGE)Exchange exchange ){
return BindingBuilder.bind(queue).to(exchange).with("").noargs();
}
}
(2)数据监控微服务新增com.ydles.canal.listener.SpuListener,添加以下代码:
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@CanalEventListener //这个类是canal的监听类
public class SpuListener {
@Autowired
RabbitTemplate rabbitTemplate;
@ListenPoint(schema = "ydles_goods",table = "tb_spu")
public void goodsUp(CanalEntry.EventType eventType, CanalEntry.RowData rowData){
System.out.println("监听到了spu数据变了:"+eventType);
//isMarketable 0->1
//改变之前的数据 --->map {id:123,name:prada}
Map<String, String> oldMap=new HashMap<>();
rowData.getBeforeColumnsList().forEach(column -> oldMap.put(column.getName(), column.getValue()));
//改变之后的数据 --->map {id:123,name:prada}
Map<String, String> newMap=new HashMap<>();
rowData.getAfterColumnsList().forEach(column -> newMap.put(column.getName(), column.getValue()));
if (oldMap.get("is_marketable").equals("0")&& newMap.get("is_marketable").equals("1")){
//mq发spuId
String spuId = newMap.get("id");
System.out.println("商品上架了,往mq发消息"+spuId);
rabbitTemplate.convertAndSend(RabbitMQConfig.GOODS_UP_EXCHANGE,"",spuId);
}
}
}
测试:修改一条数据,查看mq中消息。
3.3.2 索引库环境准备
我们提供的虚拟机镜像中已经包含了elasticsearch的相关docker镜像
3.3.3 创建索引结构
新建ydles_service_search_api模块,并添加索引库实体类
(1) 添加依赖
<!--公共包-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--spring boot 与es 结合包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
(2) 创建实体类com.ydles.search.pojo
@Document(indexName = "skuinfo", type = "docs")
public class SkuInfo implements Serializable {
//商品id,同时也是商品编号
@Id
@Field(index = true, store = true, type = FieldType.Keyword)
private Long id;
//SKU名称
@Field(index = true, store = true, type = FieldType.Text, analyzer = "ik_smart")
private String name;
//商品价格,单位为:元
@Field(index = true, store = true, type = FieldType.Double)
private Long price;
//库存数量
@Field(index = true, store = true, type = FieldType.Integer)
private Integer num;
//商品图片
@Field(index = false, store = true, type = FieldType.Text)
private String image;
//商品状态,1-正常,2-下架,3-删除
@Field(index = true, store = true, type = FieldType.Keyword)
private String status;
//创建时间
private Date createTime;
//更新时间
private Date updateTime;
//是否默认
@Field(index = true, store = true, type = FieldType.Keyword)
private String isDefault;
//SPUID
@Field(index = true, store = true, type = FieldType.Long)
private Long spuId;
//类目ID
@Field(index = true, store = true, type = FieldType.Long)
private Long categoryId;
//类目名称
@Field(index = true, store = true, type = FieldType.Keyword)
private String categoryName;
//品牌名称
@Field(index = true, store = true, type = FieldType.Keyword)
private String brandName;
//规格
private String spec;
//规格参数
private Map<String, Object> specMap;
//getter & setter略
}
3.3.4 搜索微服务搭建
(1)创建ydles_service_search模块,pom.xml引入依赖
<!--公共包-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--注册中心-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--spring boot 与es 结合包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!--商品实体类-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_goods_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--搜索实体类-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_search_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--rabbitmq依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
(2)ydles_service_search的application.yml
server:
port: 9009
spring:
application:
name: search
rabbitmq:
host: 192.168.200.128
redis:
host: 192.168.200.128
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.200.128:9300
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 600000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 600000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: false
isolation:
strategy: SEMAPHORE
(3)创建com.ydles.search包,包下创建SearchApplication
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients(basePackages = {"com.ydles.goods.feign"})
public class SearchApplication {
public static void main(String[] args) {
SpringApplication.run(SearchApplication.class,args);
}
}
(4) 将rabbitmq配置类放入该模块下
@Configuration
public class RabbitMQConfig {
//上架交换机名称
public static final String GOODS_UP_EXCHANGE = "goods_up_exchange";
//定义队列名称
public static final String AD_UPDATE_QUEQU = "ad_update_quequ";
//定义上架队列名称
public static final String SEARCH_ADD_QUEUE = "search_add_queue";
//声明队列
@Bean(AD_UPDATE_QUEQU)
public Queue queue() {
return new Queue(AD_UPDATE_QUEQU);
}
//声明上架队列
@Bean(SEARCH_ADD_QUEUE)
public Queue SEARCH_ADD_QUEUE() {
return new Queue(SEARCH_ADD_QUEUE);
}
//声明上架交换机
@Bean(GOODS_UP_EXCHANGE)
public Exchange GOODS_UP_EXCHANGE() {
return ExchangeBuilder.fanoutExchange(GOODS_UP_EXCHANGE).durable(true).build();
}
// 队列和交换机绑定
@Bean
public Binding GOODS_UP_EXCHANGE_BINDING(
@Qualifier(SEARCH_ADD_QUEUE) Queue queue,
@Qualifier(GOODS_UP_EXCHANGE) Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with("").noargs();
}
}
3.3.5 商品服务查询商品信息
(1) SkuController新增方法
@GetMapping("/spu/{spuId}")
public List<Sku> findSkuListBySpuId(@PathVariable("spuId") String spuId){
Map<String,Object> searchMap =new HashMap<>();
//搜索全部,传来的spuId为all
if(!"all".equals(spuId)){
//spuId作为条件传入搜索列表
searchMap.put("spuId", spuId);
}
//审核通过的
searchMap.put("status", "1");
List<Sku> skuList = skuService.findList(searchMap);
return skuList;
}
(2) ydles_service_goods_api新增common依赖
<dependencies>
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
(3) 定义skuFegin接口
@FeignClient(name = "goods")
public interface SkuFeign {
//根据spuId查询skuList
@GetMapping("/sku/spu/{spuId}")
public List<Sku> findSkuListBySpuId(@PathVariable("spuId")String spuId);
}
4测试 http://localhost:9001/sku/spu/1205332629172256768
3.3.6 搜索微服务批量导入数据逻辑
(1) 创建 com.ydles.search.dao包,并新增ESManagerMapper接口
public interface ESManagerMapper extends ElasticsearchRepository<SkuInfo,Long> {}
(2)创建 com.ydles.search.service包,包下创建接口EsManagerService
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
public interface EsManagerService {
//1创建索引库,映射
public void createIndexAndMapping();
//2 现有所有sku导入es
public void importData();
//3 根据spuId,将他的skuList导入es
public void importDataBySpuId();
}
(2)创建com.ydles.search.service.impl包,包下创建服务实现类
Template:帮助我们创建连接,关闭连接。类比rabbitTemplate redisTemplate jdbcTemplate
@Service
public class EsManagerServiceImpl implements EsManagerService {
@Autowired
ElasticsearchTemplate elasticsearchTemplate;
@Autowired
SkuFeign skuFeign;
@Autowired
ESManagerMapper esManagerMapper;
//1创建索引库,映射
@Override
public void createIndexAndMapping() {
//创建索引
elasticsearchTemplate.createIndex(SkuInfo.class);
//创建映射
elasticsearchTemplate.putMapping(SkuInfo.class);
}
//2 现有所有sku导入es
@Override
public void importData() {
//1 查到现有所有sku
List<Sku> skuList = skuFeign.findSkuListBySpuId("all");
System.out.println("查询出的skulist多少:"+skuList.size());
//2导入es
List<SkuInfo> skuinfoList = new ArrayList<>();
for (Sku sku : skuList) {
String skuStr = JSON.toJSONString(sku); //{id:12,name:asda}
SkuInfo skuInfo = JSON.parseObject(skuStr, SkuInfo.class);
//需求 Sku.spec string ------>SkuInfo.specMap Map
Map map = JSON.parseObject(sku.getSpec(), Map.class);
skuInfo.setSpecMap(map);
skuinfoList.add(skuInfo);
}
esManagerMapper.saveAll(skuinfoList);
}
@Override
public void importDataBySpuId() {
}
}
(3) 创建com.ydles.search.controller.定义ESManagerController
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@RestController
@RequestMapping("/manager")
public class ESManagerController {
@Autowired
EsManagerService esManagerService;
@GetMapping("/create")
public Result create(){
esManagerService.createIndexAndMapping();
return new Result(true, StatusCode.OK,"创建索引库成功");
}
@GetMapping("/importData")
public Result importData(){
esManagerService.importData();
return new Result(true, StatusCode.OK,"导入数据成功");
}
}
测试:
1head插件查看es中索引状态为空
2启动搜索服务
3postman测试 http://localhost:9009/manager/create
4结果 es中添加skuinfo索引
5postman测试 http://localhost:9009/manager/importData
6结果 es中skuinfo索引添加了数据
3.3.7 接收mq消息执行导入
梳理逻辑:canal监听到某个spu上架,发送到mq之后,搜索服务接受消息,将此spu对应的sku查出来,放到es索引库。
ydles_service_search工程创建com.ydles.search.listener包,包下创建类
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Component
public class GoodUpListener {
@Autowired
EsManagerService esManagerService;
@RabbitListener(queues = RabbitMQConfig.SEARCH_ADD_QUEUE)
public void recieveMsg(String spuId){
System.out.println("监听到了商品上架了!:"+spuId);
esManagerService.importDataBySpuId(spuId);
}
}
3.3.8 测试
1启动canal、search等微服务
2将数据库中spu一条数据由下架改为上架
3search输出数据
4索引库中数据添加
4. 商品下架索引库删除数据
4.1 需求分析
商品下架后将商品从索引库中移除。
4.2 实现思路
与商品上架的实现思路非常类似。
(1)在数据监控微服务中监控tb_spu表的数据,当tb_spu发生更改且is_marketable为0时,表示商品下架,将spu的id发送到rabbitmq。
(2)在rabbitmq管理后台创建商品下架交换器(fanout)。使用分列模式的交换器是考虑商品下架会有很多种逻辑需要处理,索引库删除数据只是其中一项,另外还有删除商品详细页等操作。
(3)搜索微服务从rabbitmq的的队列中提取spu的id,通过调用elasticsearch的高级restAPI 将相关的sku列表从索引库删除。
4.3 代码实现
根据上边讲解的实现思路完成商品下架索引库删除数据的功能
4.3.1 创建交换器与队列
完成商品下架交换器的创建,队列的创建与绑定,将spuId发送消息到mq
商品下架交换器:goods_down_exchange
队列名称: search_delete_queue
绑定 search_delete_queue到goods_down_exchange
//下架交换机名称
public static final String GOODS_DOWN_EXCHANGE = "goods_down_exchange";
//定义下架队列名称
public static final String SEARCH_DEL_QUEUE = "search_del_queue";
//定义下架队列
@Bean(SEARCH_DEL_QUEUE)
public Queue SEARCH_DEL_QUEUE(){
return new Queue(SEARCH_DEL_QUEUE);
}
//定义下架交换机
@Bean(GOODS_DOWN_EXCHANGE)
public Exchange GOODS_DOWN_EXCHANGE(){
return ExchangeBuilder.fanoutExchange(GOODS_DOWN_EXCHANGE).durable(true)
.build();
}
//下架队列和下架交换机绑定关系构建
@Bean
public Binding GOODS_DOWN_EXCHANGE_SEARCH_DEL_QUEUE(
@Qualifier(SEARCH_DEL_QUEUE)Queue queue
,@Qualifier(GOODS_DOWN_EXCHANGE)Exchange exchange ){
return BindingBuilder.bind(queue).to(exchange).with("").noargs();
}
2将RabbitMQConfig 复制到搜索服务
4.3.2 canal监听下架
修改ydles_canal的SpuListener的spuUpdate方法,添加以下代码
//获取最新下架的商品 1->0
if ("1".equals(oldData.get("is_marketable")) && "0".equals(newData.get("is_marketable"))){
//将商品的spuid发送到mq
rabbitTemplate.convertAndSend(RabbitMQConfig.GOODS_DOWN_EXCHANGE,"",newData.get("id"));
}
4.3.3 根据spuId删除索引数据
编写业务逻辑,实现根据spuId删除索引库数据的方法。
(1)ESManagerService新增方法定义
//根据souid删除es索引库中相关的sku数据
void delDataBySpuId(String spuId);
(2)ESManagerServiceImpl实现方法
@Override
public void delDataBySpuId(String spuId) {
List<Sku> skuList = skuFeign.findSkuListBySpuId(spuId);
if (skuList == null || skuList.size()<=0){
throw new RuntimeException("当前没有数据被查询到,无法导入索引库");
}
for (Sku sku : skuList) {
esManagerMapper.deleteById(Long.parseLong(sku.getId()));
}
}
4.3.4 接收mq消息,执行索引库删除
从rabbitmq中提取消息,调动根据spuId删除索引库数据的方法 ydles_service_search新增监听类
@Component
public class GoodsDelListener {
@Autowired
private ESManagerService esManagerService;
@RabbitListener(queues = RabbitMQConfig.SEARCH_DEL_QUEUE)
public void receiveMessage(String spuId){
System.out.println("删除索引库监听类,接收到的spuId: "+spuId);
//调用业务层完成索引库数据删除
esManagerService.delDataBySpuId(spuId);
}
}
测试:
1重启canal、搜索服务
2修改数据库中一条数据,将上架状态改为架
3观察canal应用输出
4观察es输出
总结:
1canal
mysql 主从复制 binlog
canal伪装slave
代码
2广告更新
3商品上架 同步到es库
4商品下架 es库删除
知识点:boot cloud(feign) rabbitmq(5种工作模式) es
第7章 商品搜索-elastaicSearch
角色:搜索后端组 search-service
前置内容:2天elasticsearch入门 倒排索引 全文检索
1课程目标
- 根据搜索关键字查询
- 条件筛选
- 规格过滤
- 价格区间搜索
- 分页查询
- 排序查询
- 高亮查询
2搭建框架
2.1关键字查询-构建搜索条件
需求:day01页面原型-》search.html
1搜索服务 com.ydles.search.service
public interface SearchService {
//按照查询条件进行数据查询
Map search(Map<String,String> searchMap);
}
2com.ydles.search.service.impl
@Service
public class SearchServiceImpl implements SearchService {
@Autowired
ElasticsearchTemplate elasticsearchTemplate;
@Override
public Map search(Map<String, String> searchMap) {
//构建查询
if(searchMap!=null){
// 条件构建对象
NativeSearchQueryBuilder nativeSearchQueryBuilder=new NativeSearchQueryBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//关键字查询
if(StringUtils.isNotEmpty(searchMap.get("keywords"))){
boolQueryBuilder.must(QueryBuilders.matchQuery("name", searchMap.get("keywords")).operator(Operator.AND));
}
nativeSearchQueryBuilder.withQuery(boolQueryBuilder);
//开启查询
/**
* 条件构建对象
* 查询实体类
* 查询结果对象
*/
AggregatedPage<SkuInfo> resultInfo = elasticsearchTemplate.queryForPage(nativeSearchQueryBuilder.build(), SkuInfo.class, new SearchResultMapper() {
@Override
public <T> AggregatedPage<T> mapResults(SearchResponse searchResponse, Class<T> aClass, Pageable pageable) {
return null;
}
});
}
return null;
}
}
2.2封装查询结果
完善com.ydles.search.service.impl
@Service
public class SearchServiceImpl implements SearchService {
@Autowired
ElasticsearchTemplate elasticsearchTemplate;
@Override
public Map search(Map<String, String> searchMap) {
//结果map
Map<String,Object> resultMap=new HashMap<>();
//构建查询
if(searchMap!=null){
// 条件构建对象
NativeSearchQueryBuilder nativeSearchQueryBuilder=new NativeSearchQueryBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//关键字查询
if(StringUtils.isNotEmpty(searchMap.get("keywords"))){
boolQueryBuilder.must(QueryBuilders.matchQuery("name", searchMap.get("keywords")).operator(Operator.AND));
}
nativeSearchQueryBuilder.withQuery(boolQueryBuilder);
//开启查询
/**
* 条件构建对象
* 查询实体类
* 查询结果对象
*/
//封装查询结果
AggregatedPage<SkuInfo> resultInfo = elasticsearchTemplate.queryForPage(nativeSearchQueryBuilder.build(), SkuInfo.class, new SearchResultMapper() {
@Override
public <T> AggregatedPage<T> mapResults(SearchResponse searchResponse, Class<T> aClass, Pageable pageable) {
// 查询结果操作
List<T> list=new ArrayList<>();
// 获取结果
SearchHits hits = searchResponse.getHits();
if (hits!=null){
for (SearchHit hit : hits) {
// hit对象转为skuinfo
SkuInfo skuInfo = JSON.parseObject(hit.getSourceAsString(), SkuInfo.class);
list.add((T) skuInfo);
}
}
//构建返回对象
return new AggregatedPageImpl<>(list,pageable,hits.getTotalHits(),searchResponse.getAggregations());
}
});
// 封装最终返回结果
//总记录数
resultMap.put("total", resultInfo.getTotalElements());
//总页数
resultMap.put("totalPages",resultInfo.getTotalPages());
//数据集合
resultMap.put("rows", resultInfo.getContent());
}
return resultMap;
}
}
2.3表现层定义
1 com.ydles.search.controller
@RestController
@RequestMapping("/search")
public class SearchController {
@Autowired
SearchService searchService;
@GetMapping
public Map search(@RequestParam Map<String,String> searchMap){
//特殊符号处理
this.handleSearchMap(searchMap);
Map searchResult = searchService.search(searchMap);
return searchResult;
}
private void handleSearchMap(Map<String, String> searchMap) {
Set<Map.Entry<String, String>> entries = searchMap.entrySet();
for (Map.Entry<String, String> entry : entries) {
if (entry.getKey().startsWith("spec_")){
searchMap.put(entry.getKey(), entry.getValue().replace("+", "%2B"));
}
}
}
}
2.4测试
postman测试 http://localhost:9009/search?keywords=包包
结果:默认分页 每页10条
3各种查询
3.1条件查询需求
3.2品牌过滤查询
需求:search.ydles.com/search?keywords=PRADA包包&brand=prada
1完善 SearchServiceImpl
//按照品牌查询
if(StringUtils.isNotEmpty(searchMap.get("brand"))){
boolQueryBuilder.filter(QueryBuilders.termQuery("brandName", searchMap.get("brand")));
}
测试 http://localhost:9009/search?keywords=包包&brand=PRADA
http://localhost:9009/search?keywords=包包2019秋季限量版&brand=PRADA
3.3品牌聚合查询
需求:查看静态页面,品牌的显示要根据搜索结果不同展现。
1完善 SearchServiceImpl
//按照品牌聚合
String skuBrand="skuBrand"; nativeSearchQueryBuilder.addAggregation(AggregationBuilders.terms(skuBrand).field("brandName"));
//封装品牌分组结果
StringTerms brandTerms = (StringTerms) resultInfo.getAggregation(skuBrand);
List<String> brandList = brandTerms.getBuckets().stream().map(bucket -> bucket.getKeyAsString()).collect(Collectors.toList());
resultMap.put("brandList", brandList);
测试: http://localhost:9009/search?keywords=包包
3.4按照规格过滤
需求:
(1)前端发来规格查询:
http://localhost:9009/search?keywords=包包&spec_颜色=黑色&spec_二手程度=崭新出厂
(2)后台接到数据后,可以根据前缀spec_来区分是否是规格,如果以 spec_xxx 开始的数据 则为规格数据,需要根据指定规格找信息。
(3)
上图是规格的索引存储格式,真实数据在 “specMap.二手程度.keyword” 中,所以找数据 也是按照如下格式去找。
1完善 SearchServiceImpl
//按照规格过滤查询
for (String key : searchMap.keySet()) {
if(key.startsWith("spec_")){
String value = searchMap.get(key).replace("%2B", "+"); boolQueryBuilder.filter(QueryBuilders.termQuery("specMap."+key.substring(5)+".keyword", value));
}
}
测试: http://localhost:9009/search?keywords=包包&spec_颜色=黑色
3.5按照规格聚合
1完善 SearchServiceImpl
//按照规格聚合
String skuSpec="skuSpec"; nativeSearchQueryBuilder.addAggregation(AggregationBuilders.terms(skuSpec).field("spec.keyword"));
//封装规格分组结果
StringTerms specTerms = (StringTerms) resultInfo.getAggregation(skuSpec);
List<String> specList = specTerms.getBuckets().stream().map(bucket -> bucket.getKeyAsString()).collect(Collectors.toList());
resultMap.put("specList", specList);
测试: http://localhost:9009/search?keywords=包包
text 类型 不能进行聚合和排序
keyword 类型 能进行聚合和排序
3.6价格区间查询
price=0-5000 price=30000
需求:价格区间查询,每次需要将价格传入到后台,前端传入后台的价格大概是 price=0‐500 或者 price=500‐1000 依次类推,最后一个是 price=3000 ,后台可以根据-分割,如果分割 得到的结果最多有2个,第1个表示 x<price ,第2个表示 price<=y。
1完善 SearchServiceImpl
//价格区间查询 price=0-5000 price=30000
if(StringUtils.isNotEmpty(searchMap.get("price"))){
String price = searchMap.get("price");
String[] split = price.split("-");
//代码优化 codeReview
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("price").gte(split[0]);
if(split.length==2){
//price=0-5000
rangeQueryBuilder.lte(split[1]);
}//else {
//price=30000
//}
boolQueryBuilder.filter(rangeQueryBuilder);
}
测试: http://localhost:9009/search?keywords=包包&price=0-500
3.7分页
需求:http://localhost:9009/search?keywords=包包&pageNum=1&pageSize=30
没有带分页参数,你得给我默认查询第一页,每页30条数据。
es:
1完善 SearchServiceImpl
//分页查询
String pageNum = searchMap.get("pageNum");
String pageSize = searchMap.get("pageSize");
if(StringUtils.isEmpty(pageNum)){
pageNum="1";
}
if(StringUtils.isEmpty(pageSize)){
pageSize="30";
}
nativeSearchQueryBuilder.withPageable(PageRequest.of(Integer.parseInt(pageNum)-1, Integer.parseInt(pageSize)));
// 当前页
resultMap.put("pageNum", pageNum);
测试: http://localhost:9009/search?keywords=包包&pageNum=5&pageSize=20
3.8排序
排序这里总共有根据价格排序、根据评价排序、根据新品排序、根据销量排序,排序要 想实现非常简单,只需要告知排序的域以及排序方式即可实现。
价格排序:只需要根据价格高低排序即可,降序价格高->低,升序价格低->高
评价排序:评价分为好评、中评、差评,可以在数据库中设计3个列,用来记录好评、中 评、差评的量,每次排序的时候,好评的比例来排序,当然还要有条数限制,评价条数 需要超过N条。
新品排序:直接根据商品的发布时间或者更新时间排序。
销量排序:销量排序除了销售数量外,还应该要有时间段限制。
需求:http://localhost:9009/search?keywords=包包&sortField=price&sortRule=Desc
1完善 SearchServiceImpl//排序
//1当前域2升序降序
if (StringUtils.isNotEmpty(searchMap.get("sortField")) && StringUtils.isNotEmpty(searchMap.get("sortRule"))) {
if ("ASC".equals(searchMap.get("sortRule"))) {
//升序
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort(searchMap.get("sortField")).order(SortOrder.ASC));
} else {
//降序
nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort(searchMap.get("sortField")).order(SortOrder.DESC));
}
}
测试: http://localhost:9009/search?keywords=包包&pageSize=1000&sortField=price&sortRule=Desc
3.9高亮介绍
需求:京东搜索电脑
什么是高亮:搜索结果文字样式不同于其他。
<font class="skcolor_ljg">包包</font>
实现步骤:
1.指定高亮字段:name字段。关键词前后加标签。
2.将高亮字段提取,替换高亮字段:name。
3.10高亮实现
//1设置高亮域
HighlightBuilder.Field field = new HighlightBuilder.Field("name");
//前缀
field.preTags("<span style='color:red'>");
//后缀
field.postTags("</span>");
nativeSearchQueryBuilder.withHighlightFields(field);
//2获取高亮字段
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (highlightFields != null&&highlightFields.size()>0) { skuInfo.setName(highlightFields.get("name").getFragments()[0].toString());
}
测试: http://localhost:9009/search?keywords=包包
总结 搜索后台开发工程师
1 estemplate.queryForPage(构建搜索条件,实体类,结果映射)
2各种查询
第8章 Thymeleaf
角色: 前端 展现到前台。
学习目标
- Thymeleaf的介绍
- Thymeleaf的入门
- Thymeleaf的语法及标签
- 搜索页面渲染
- 商品详情页静态化功能实现
1.Thymeleaf介绍
1动态页面:
html+java
通过执行asp、php、jsp和.net等程序生成客户端网页代码的网页。通常可以通过网站后台管理系统对网站的内容进行更新管理。发布新闻,发布公司产品,交流互动,博客,网上调查等,这都是动态网站的一些功能。也是我们常见的。 常见的扩展名有:.asp、php、jsp、cgi和aspx 等。 注意:动态页面的“动态”是网站与客户端用户互动的意思,而非网页上有动画的就是动态页面。
A.交互性好。
B.动态网页的信息都需要从数据库中读取,每打开一个一面就需要去获取一次数据库,如果访问人数很多,也就会对服务器增加很大的荷载,从而影响这个网站的运行速度。
2静态页面:
最早的时候,网站内容是通过在主机空间中放置大量的静态网页实现的。为了方便对这些分散在不同目录的静态网页的管理,(一般是通过FTP),象frontpage/dreamweaver这样软件甚至直接提供了向主页空间以FTP方式直接访问文件的功能。以静态网页为主的网站最大的困难在于对网页的管理,在这种框架里,网页框架和网页中的内容混杂在一起,很大程度地加大了内容管理的难度。为了减轻这种管理的成本,发展出了一系列的技术,甚至连css本身,原本也是针对这种乱七八糟的网页维护而设计的,目的就是把网页表达的框架和内容本身抽象分离出来。
A.静态网页的内容稳定,页面加载速度快。
B.静态网页的没有数据库支持,在网站制作和维护方面的工作量较大。
C.静态网页的交互性差,有很大的局限性。
3为什么需要动态页面静态化:
- 搜索引擎的优化
尽管搜索机器人有点讨厌,各个网站不但不会再象从前一样把它封起来,反而热情无比地搞SEO,所谓的面向搜索引擎的优化,其中就包括访问地址的改写,令动态网页看上去是静态网页,以便更多更大量地被搜索引擎收录,从而最大限度地提高自已的内容被目标接收的机会。但是,在完全以动态技术开发的网站,转眼中要求变换成静态网页提供,同时,无论如何,动态网页的内容管理功能也是必须保留的;就如同一辆飞驶的奔驰忽然要求180度转弯,要付出的成本代价是非常大的,是否真的值得,也确实让人怀疑。
- 提高程序性能 10w/s
很多大型网站,进去的时候看它很复杂的页面,但是加载也没有耗费多长时间,除了其它必要原因以外,静态化也是其中必需考虑的技术之一。
先于用户获取资源或数据库数据进而通过静态化处理,生成静态页面,所有人都访问这一个静态页面,而静态化处理的页面本身的访问速度要较动态页面快很多倍,因此程序性能会有大大的提升。
静态化在页面上的体现为:访问速度加快,用户体验性明显提升;在后台体现为:访问脱离数据库,减轻了数据库访问压力。
模板+数据=文本
thymeleaf是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。它是一个开源的Java库,基于Apache License 2.0许可,由Daniel Fernández创建,该作者还是Java加密库Jasypt的作者。
Thymeleaf提供了一个用于整合Spring MVC的可选模块,在应用开发中,你可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity、FreeMarker等。Thymeleaf的主要目标在于提供一种可被浏览器正确显示的、格式良好的模板创建方式,因此也可以用作静态建模。你可以使用它创建经过验证的XML与HTML模板。相对于编写逻辑或代码,开发者只需将标签属性添加到模板中即可。接下来,这些标签属性就会在DOM(文档对象模型)上执行预先制定好的逻辑。
它的特点便是:开箱即用,Thymeleaf允许您处理六种模板,每种模板称为模板模式:
- XML
- 有效的XML
- XHTML
- 有效的XHTML
- HTML5
- 旧版HTML5
所有这些模式都指的是格式良好的XML文件,但Legacy HTML5模式除外,它允许您处理HTML5文件,其中包含独立(非关闭)标记,没有值的标记属性或不在引号之间写入的标记属性。为了在这种特定模式下处理文件,Thymeleaf将首先执行转换,将您的文件转换为格式良好的XML文件,这些文件仍然是完全有效的HTML5(实际上是创建HTML5代码的推荐方法)1。
另请注意,验证仅适用于XML和XHTML模板。
然而,这些并不是Thymeleaf可以处理的唯一模板类型,并且用户始终能够通过指定在此模式下解析模板的方法和编写结果的方式来定义他/她自己的模式。这样,任何可以建模为DOM树(无论是否为XML)的东西都可以被Thymeleaf有效地作为模板处理。
4Thymeleaf介绍
1概念:XML/XHTML/HTML5模板引擎。
2其他模板引擎:Velocity、FreeMarker、jsp
3为什么使用它:springboot内置支持
4特点:开箱即用,Thymeleaf允许您处理六种模板,每种模板称为模板模式:
- XML
- 有效的XML
- XHTML
- 有效的XHTML
- HTML5
- 旧版HTML5
2.Springboot整合thymeleaf
使用springboot 来集成使用Thymeleaf可以大大减少单纯使用thymleaf的代码量,所以我们接下来使用springboot集成使用thymeleaf.
实现的步骤为:
- 创建一个sprinboot项目
- 添加thymeleaf的起步依赖
- 添加spring web的起步依赖
- 编写html 使用thymleaf的语法获取变量对应后台传递的值
- 编写controller 设置变量的值到model中
(1)创建工程
创建一个独立的工程springboot-thymeleaf,该工程为案例工程,不需要放到ydles工程中。
pom.xml依赖
<?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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ydles</groupId>
<artifactId>springboot-thymeleaf</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.4.RELEASE</version>
</parent>
<dependencies>
<!--web起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--thymeleaf配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
</project>
(2)创建包com.ydles.thymeleaf.并创建启动类ThymeleafApplication
@SpringBootApplication
public class ThymeleafApplication {
public static void main(String[] args) {
SpringApplication.run(ThymeleafApplication.class,args);
}
}
(3)创建application.yml
设置thymeleaf的缓存设置,设置为false。默认加缓存的,用于测试。
spring:
thymeleaf:
cache: false
(4)控制层
创建controller用于测试后台 设置数据到model中。
创建com.ydles.controller.TestController,代码如下:
@Controller
@RequestMapping("/test")
public class TestController {
/***
* 访问/test/hello 跳转到demo1页面
* @param model
* @return
*/
@RequestMapping("/hello")
public String hello(Model model){
model.addAttribute("hello","hello welcome");
return "demo";
}
}
(2)创建html
在resources中创建templates目录,在templates目录创建 demo.html,代码如下:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf的入门</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<!--输出hello数据-->
<p th:text="${hello}"></p>
</body>
</html>
解释:
html xmlns:th="http://www.thymeleaf.org"
:这句声明使用thymeleaf标签
(5)测试
启动系统,并在浏览器访问
http://localhost:8080/test/hello
3 Thymeleaf基本语法
(1)th:action
定义后台控制器路径,类似标签的action属性。
例如:
<form th:action="@{/demo/test}">
<input th:type="text" th:name="id">
<button>提交</button>
</form>
action
public String test(Model model,String id){
System.out.println(id);
}
(2)th:each
对象遍历,功能类似jstl中的
创建com.ydles.model.User,代码如下:
public class User {
private Integer id;
private String name;
private String address;
getter and settrt
}
Controller添加数据
List<User> userList=new ArrayList<>();
userList.add(new User(1, "a", "bj"));
userList.add(new User(2, "b", "tj"));
userList.add(new User(3, "c", "nj"));
model.addAttribute("userList", userList);
demo.html
<table>
<tr>
<td>下标</td>
<td>编号</td>
<td>姓名</td>
<td>住址</td>
</tr>
<tr th:each="user,userStat:${userList}">
<td th:text="${userStat.index}"></td>
<td th:text="${user.id}"></td>
<td th:text="${user.name}"></td>
<td th:text="${user.address}"></td>
</tr>
</table>
测试效果
(3) map取值
1action
Map<String,Object> dataMap=new HashMap<>();
dataMap.put("No", "123");
dataMap.put("address", "bj");
model.addAttribute("dataMap", dataMap);
2 demo.html
<div th:each="map,mapStat:${dataMap}">
<div th:text="${map}"></div>
key:<span th:text="${mapStat.current.key}"></span><br>
value:<span th:text="${mapStat.current.value}"></span><br>
====================================
</div>
3测试 启动工程 访问 http://localhost:8080/demo/hello
测试效果
(4)数组遍历
1action
String[] names={"张三","李四","王五"};
model.addAttribute("names",names );
2demo.html
<div th:each="nm,nmStat:${names}">
<span th:text="${nmStat.count}"></span><br>
<span th:text="${nm}"></span><br>
====================================
</div>
3测试 启动工程 访问 http://localhost:8080/demo/test
测试效果
(5)Date输出
1action
model.addAttribute("now", new Date());
2demo.html
<div>
<span th:text="${#dates.format(now,'yyyy-MM-dd hh:mm:ss')}"></span>
</div>
测试效果
(6)th:if条件
1action
model.addAttribute("age", 15);
2demo.html
<div>
<span th:if="${(age>=18)}">终于成年了</span>
</div>
测试效果
(7)th:fragment 模块声明与页面包含
1 新建footer.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>fragment</title>
</head>
<body>
<div id="C" th:fragment="copy">
关于我们<br/>
</div>
</body>
</html>
2 demo.html
<div id="W" th:include="footer::copy"></div>
效果如下:
4 搜索页面渲染
4.1 搜索分析
打开chapter01 框架搭建/资源/静态原型/前台/search.html
搜索页面要显示的内容主要分为3块。
1)搜索的数据结果
2)筛选出的数据搜索条件
3)用户已经勾选的数据条件
4.2 搜索实现
搜索的业务流程如上图,用户每次搜索的时候,先经过搜索业务工程,搜索业务工程调用搜索微服务工程。
4.2.1 搜索工程搭建
(1)search搜索服务 添加依赖
在ydles-service_search工程中的pom.xml中引入如下依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
(2)静态资源导入
将资源中的页面资源/所有内容
拷贝到工程的resources
目录下如下图:
(3) 更改配置文件,在spring下添加内容
spring.thymeleaf.cache: false
4.2.1 基础数据渲染
需求:以前前端json,前端渲染。现在跳转到项目页面,服务器端页面渲染出来。
(1)com.ydles.search.controller添加方法
@GetMapping("/list")
public String list(@RequestParam Map<String,String> searchMap,Model model){
//特殊符号处理
this.handleSearchMap(searchMap);
//获取查询结果
Map resultMap = searchService.search(searchMap);
model.addAttribute("result",resultMap);
model.addAttribute("searchMap",searchMap);
return "search";
}
类注解改为Controller,search方法添加ResponseBody
浏览器访问 http://localhost:9009/search/list?keywords=包包
(2) 搜索结果页面渲染
(2.1)用户选择条件回显
2 <html xmlns:th="http://www.thymeleaf.org">
481 <li class="active">
<span th:text="${searchMap.keywords}"></span>
</li>
484 <li class="with-x" th:if="${#maps.containsKey(searchMap,'brand')}">
品牌:<span th:text="${searchMap.brand}"></span>
<a th:href="@{${#strings.replace(url,'&brand='+searchMap.brand,'')}}">×</a>
</li>
<li class="with-x" th:if="${#maps.containsKey(searchMap,'price')}">
价格:<span th:text="${searchMap.price}"></span>
<a th:href="@{${#strings.replace(url,'&price='+searchMap.price,'')}}">×</a>
</li>
<!--规格-->
<li class="with-x" th:each="sm,mapStat:${searchMap}" th:if="${#strings.startsWith(sm.key,'spec_')}">
<span th:text="${#strings.replace(sm.key,'spec_','')}"></span> : <span th:text="${#strings.replace(sm.value,'%2B','+')}"></span>
<a th:href="@{${#strings.replace(url,'&'+sm.key+'='+sm.value,'')}}">×</a>
</li>
3测试 访问 http://localhost:9009/search/list?keywords=包包&brand=viney&spec_颜色=黑色
(2.2)品牌信息显示
1需求:实现下图功能
2修改search.html页面
512 <div class="type-wrap logo" th:unless="${#maps.containsKey(searchMap,'brand')}">
<div class="fl key brand">品牌</div>
<div class="value logos">
<ul class="logo-list">
<li th:each="brand,brandSate:${result.brandList}">
<a th:text="${brand}" th:href="@{${url}(brand=${brand})}"></a>
</li>
</ul>
</div>
<div class="ext">
<a href="javascript:void(0);" class="sui-btn">多选</a>
<a href="javascript:void(0);">更多</a>
</div>
</div>
3 测试 访问 http://localhost:9009/search/list?keywords=包包
访问 http://localhost:9009/search/list?keywords=包包
(2.3)规格信息数据转换
1需求访问规格接口 http://localhost:9009/search?keywords=包包&spec_颜色=黑色
==》颜色:蓝色,黑色,金色,粉色
版本:6GB+128GB,4GB+64GB
规格数据不好在前端展示。所以要转成map。
颜色:
2 SearchServiceImpl 增加方法
public Map<String, Set<String>> formartSpec(List<String> specList) {
//1定义返回map
Map<String, Set<String>> resultMap = new HashMap<>();
//2遍历specList
if (specList != null && specList.size() > 0) {
for (String specJsonString : specList) {
// 将json转为map
Map<String, String> specMap = JSON.parseObject(specJsonString, Map.class);
//遍历每个商品的规格名字
for (String specKey : specMap.keySet()) {
//看返回map中有此规格没有
Set<String> specSet = resultMap.get(specKey);
if (specSet == null) {
specSet = new HashSet<>();
}
//将此条数据中的规格放入set
specSet.add(specMap.get(specKey));
//将set放入返回map
resultMap.put(specKey, specSet);
}
}
}
return resultMap;
}
3在封装规格信息时,调用
resultMap.put("specList", this.formartSpec(specList));
(2.4)规格与价格显示
1修改search.html页面 规格
526
<div class="type-wrap" th:each="spec,specStat:${result.specList}" th:unless="${#maps.containsKey(searchMap,'spec_'+spec.key)}">
<div class="fl key" th:text="${spec.key}">
</div>
<div class="fl value">
<ul class="type-list">
<li th:each="op,opstat:${spec.value}">
<a th:text="${op}" th:href="@{${url}('spec_'+${spec.key}=${op})}"></a>
</li>
</ul>
</div>
<div class="fl ext"></div>
</div>
2修改search.html页面 价格
539
<div class="type-wrap" th:unless="${#maps.containsKey(searchMap,'price')}">
<div class="fl key">价格</div>
<div class="fl value">
<ul class="type-list">
<li>
<a th:text="0-500元" th:href="@{${url}(price='0-500')}"></a>
</li>
<li>
<a th:text="500-1000元" th:href="@{${url}(price='500-1000')}"></a>
</li>
<li>
<a th:text="1000-1500元" th:href="@{${url}(price='1000-1500')}"></a>
</li>
<li>
<a th:text="1500-2000元" th:href="@{${url}(price='1500-2000')}"></a>
</li>
<li>
<a th:text="2000-3000元" th:href="@{${url}(price='2000-3000')}"></a>
</li>
<li>
<a th:text="3000元以上" th:href="@{${url}(price='3000')}"></a>
</li>
</ul>
</div>
<div class="fl ext">
</div>
</div>
3测试: http://localhost:9009/search/list?keywords=包包&price=0-5000
(2.5)数据列表展示
1需求:显示下列内容
2 修改search.html页面
603-832
<li class="yui3-u-1-5" th:each="sku,skuStat:${result.rows}">
<div class="list-wrap">
<div class="p-img">
<!--<a th:href="'http://192.168.200.128:8081/'+${sku.spuId}+'.html'" target="_blank"><img th:src="${sku.image}" /></a>-->
<a th:href="'http://192.168.200.128:8081/10000000616300.html'" target="_blank"><img th:src="${sku.image}" /></a>
</div>
<div class="price">
<strong>
<em>¥</em>
<i th:text="${sku.price}"></i>
</strong>
</div>
<div class="attr">
<a target="_blank" th:href="'http://192.168.200.128:8081/10000000616300.html'" th:title="${sku.spec}" th:utext="${sku.name}"></a>
</div>
<div class="commit">
<i class="command">已有<span>2000</span>人评价</i>
</div>
<div class="operate">
<a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
<a href="javascript:void(0);" class="sui-btn btn-bordered">收藏</a>
</div>
</div>
</li>
3测试:http://localhost:9009/search/list?keywords=包包&brand=PRADA
4.3 关键字搜索
1需求:用户输入关键字,查询。回显。
2 修改search.html页面
40
<form th:action="@{/search/list}" class="sui-form form-inline">
<div class="input-append">
<input th:type="text" id="autocomplete" name="keywords" th:value="${searchMap.keywords}" class="input-error input-xxlarge" />
<button class="sui-btn btn-xlarge btn-danger" th:type="submit">搜索</button>
</div>
</form>
3测试:http://localhost:9009/search/list?keywords=包包
修改输入框
4.4 条件搜索实现
1需求:
- 用户搜索:拼接url /search/list?keywords=包包
- 点击新规格:拼接 “url /search/list?keywords=包包&spec__颜色=红色
(1)后台记录搜索URL
com.ydles.search.controller SearchController list方法中新增
//拼接url
StringBuilder url = new StringBuilder("/search/list");
// 如果有搜索条件
if (searchMap != null && searchMap.size() > 0) {
url.append("?");
//拼接
for (String paramKey : searchMap.keySet()) {
//排除特殊情况
if(!"sortRule".equals(paramKey)&&!"sortField".equals(paramKey)&&!"pageNum".equals(paramKey)){
url.append(paramKey).append("=").append(searchMap.get(paramKey)).append("&");
}
}
String urlString = url.toString();
//除去最后的&
urlString=urlString.substring(0,urlString.length()-1);
model.addAttribute("url", urlString);
}else {
model.addAttribute("url", url);
}
(2)前端url拼接跳转
1需求:
用户点击相应品牌、规格、价格,跳转请求后端接口。
2修改search.html页面
516 <a th:text="${brand}" th:href="@{${url}(brand=${brand})}"></a>
530 <a th:text="${op}" th:href="@{${url}('spec_'+${spec.key}=${op})}"></a>
543 <a th:text="0-500元" th:href="@{${url}(price='0-500')}"></a>
3测试:http://localhost:9009/search/list?keywords=包包
依次点击品牌、规格、价格信息
4.5 移除搜索条件
1需求:
用户点击X去除刚才的搜索条件。
2修改search.html页面
574-486
<li class="with-x" th:if="${#maps.containsKey(searchMap,'brand')}">
品牌:<span th:text="${searchMap.brand}"></span>
<a th:href="@{${#strings.replace(url,'&brand='+searchMap.brand,'')}}">×</a>
</li>
<li class="with-x" th:if="${#maps.containsKey(searchMap,'price')}">
价格:<span th:text="${searchMap.price}"></span>
<a th:href="@{${#strings.replace(url,'&price='+searchMap.price,'')}}">×</a>
</li>
<!--规格-->
<li class="with-x" th:each="sm:${searchMap}" th:if="${#strings.startsWith(sm.key,'spec_')}">
<span th:text="${#strings.replace(sm.key,'spec_','')}"></span> : <span th:text="${#strings.replace(sm.value,'%2B','+')}"></span>
<a th:href="@{${#strings.replace(url,'&'+sm.key+'='+sm.value,'')}}">×</a>
</li>
3测试:http://localhost:9009/search/list?keywords=包包
添加搜索条件,去除搜索条件。
4.6 排序
1需求:
用户点击排序字段,返回排序好的内容。
2修改search.html页面
593-595
<li>
<a th:href="@{${url}(sortRule='ASC',sortField='price')}">价格↑</a>
</li>
<li>
<a th:href="@{${url}(sortRule='DESC',sortField='price')}">价格↓</a>
</li>
3测试:http://localhost:9009/search/list?keywords=包包
点击价格升序、降序查看结果
4.7 分页
真实的分页应该像百度那样,如下图:
分页后端实现
1需求:
分页。
2将资料Page.java 放到common工程entity下
3com.ydles.search.controller SearchController list方法中新增
// 封装分页数据,并返回
//总记录数
// 当前页
// 每页几条
Page<SkuInfo> page = new Page<>(
Long.parseLong(String.valueOf(resultMap.get("total"))),
Integer.parseInt(String.valueOf(resultMap.get("pageNum"))),
Page.pageSize
);
model.addAttribute("page", page);
分页前端实现
1修改search.html页面
644
<ul>
<li class="prev disabled">
<a th:href="@{${url}(pageNum=${page.upper})}">«上一页</a>
</li>
<li th:each="i:${#numbers.sequence(page.lpage,page.rpage)}" th:class="${i}==${page.currentpage}?'active':''">
<a th:href="@{${url}(pageNum=${i})}" th:text="${i}"></a>
</li>
<li class="next">
<a th:href="@{${url}(pageNum=${page.next})}">下一页»</a>
</li>
</ul>
<div>
<span>共<i th:text="${page.last}"></i>页 </span>
<span>共<i th:text="${page.total}"></i>个商品 </span>
</div>
2测试:http://localhost:9009/search/list?keywords=包包
分页 上一页下一页
5.元动力二奢商品详情页
5.1 需求分析
当系统审核完成商品,需要将商品详情页进行展示,那么采用静态页面生成的方式生成,并部署到高性能的web服务器中进行访问是比较合适的。所以,开发流程如下图所示:
此处MQ我们使用Rabbitmq即可。
流程:
1商品上架->商品服务发送spuid->mq
2mq->静态页服务
3静态页服务->调商品服务获取spu->生成静态页面
5.2 商品静态化微服务创建
5.2.1 需求分析
该微服务只用于生成商品静态页,不做其他事情。
5.2.2 搭建项目
1 创建 静态页服务ydles_service_page
2依赖
<!--公共模块-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<!--thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--mq-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--goods feigh-->
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_goods_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
3配置文件application.yml
server:
port: 9011
spring:
application:
name: page
rabbitmq:
host: 192.168.200.128
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: false
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 600000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 600000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
#生成静态页的位置
pagepath: D:\items
4启动类 com.ydles.page.PageApplication
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients(basePackages = {"com.ydles.goods.feign"})
public class PageApplication {
public static void main(String[] args) {
SpringApplication.run(PageApplication.class,args);
}
}
5.3 生成静态页
5.3.1 需求分析
页面发送请求,传递要生成的静态页的商品的SpuID.后台controller 接收请求,调用thyemleaf的原生API生成商品静态页。
上图是要生成的商品详情页,从图片上可以看出需要查询SPU的3个分类作为面包屑显示,同时还需要查询SKU和SPU信息。
5.3.2 Feign创建
需求:查出3部分数据 资源/静态原型/前台/item.html
- 分类
- spu
- sku
1goods-api中 com.ydles.goods.feign
@FeignClient(name = "goods")
public interface CategoryFeign {
@GetMapping("/category/{id}")
public Result<Category> findById(@PathVariable("id") Integer id);
}
2goods服务 spuController添加
@GetMapping("/findSpuById/{id}")
public Result<Spu> findSpuById(@PathVariable("id") String id){
Spu spu = spuService.findById(id);
return new Result(true,StatusCode.OK,"查询成功",spu);
}
3com.ydles.goods.feign
@FeignClient(name = "goods")
public interface SpuFeign {
@GetMapping("/spu/findSpuById/{id}")
public Result<Spu> findSpuById(@PathVariable("id") String id);
}
5.3.3 静态页生成代码-重点掌握
thymleaf页面静态化 数据+模板=静态html
1将资料的item.html放到项目templates下。作为详情页模板。
2page模块 service层 com.ydles.page.service
public interface PageService {
//生成静态化页面
void generateHtml(String spuId);
}
3 实现类 com.ydles.page.service.impl
package com.ydles.page.service.impl;
import com.alibaba.fastjson.JSON;
import com.ydles.entity.Result;
import com.ydles.goods.feign.CategoryFeign;
import com.ydles.goods.feign.SkuFeign;
import com.ydles.goods.feign.SpuFeign;
import com.ydles.goods.pojo.Category;
import com.ydles.goods.pojo.Sku;
import com.ydles.goods.pojo.Spu;
import com.ydles.page.service.PageService;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*
* 缺啥补啥
*/
@Service
public class PageServiceImpl implements PageService {
@Autowired
TemplateEngine templateEngine;
@Value("${pagepath}")
String pagepath;
//生成静态化页面的方法
@Override
public void generateHtml(String spuId) {
//1 上下文 包含数据
Context context=new Context();
//context.setVariable("name","value");
Map<String, Object> dataMap = getData(spuId);
context.setVariables(dataMap);
//2文件
File dir = new File(pagepath); //文件夹 d:/item
//如果文件夹不存在 创建
if(!dir.exists()){
dir.mkdirs();
}
//d:/item/spuid.html
File file = new File(pagepath + "/" + spuId + ".html");
Writer writer=null;
try {
writer=new FileWriter(file);
templateEngine.process("item",context, writer);
} catch (IOException e) {
e.printStackTrace();
}finally {
//关闭流
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
@Autowired
SpuFeign spuFeign;
@Autowired
SkuFeign skuFeign;
@Autowired
CategoryFeign categoryFeign;
//获取数据方法
public Map<String,Object> getData(String spuId){
Map<String,Object> resultMap = new HashMap<>();
//1spu
Spu spu = spuFeign.findSpuById(spuId).getData();
resultMap.put("spu",spu);
//4imageList
String images = spu.getImages();
if(StringUtils.isNotEmpty(images)){
String[] imageList = images.split(",");
resultMap.put("imageList",imageList);
}
//5 specificationList
String specItems = spu.getSpecItems();
if(StringUtils.isNotEmpty(specItems)){
Map specMap = JSON.parseObject(specItems, Map.class);
resultMap.put("specificationList",specMap);
}
//2sku
List<Sku> skuList = skuFeign.findSkuListBySpuId(spuId);
resultMap.put("skuList",skuList);
//3category
Category category1 = categoryFeign.findById(spu.getCategory1Id()).getData();
Category category2 = categoryFeign.findById(spu.getCategory2Id()).getData();
Category category3 = categoryFeign.findById(spu.getCategory3Id()).getData();
resultMap.put("category1",category1);
resultMap.put("category2",category2);
resultMap.put("category3",category3);
return resultMap;
}
}
静态页服务监听
1com.ydles.page.config 粘贴canal服务中的配置
public static final String PAGE_CREATE_QUEUE="page_create_queue";
@Bean(PAGE_CREATE_QUEUE)
public Queue PAGE_CREATE_QUEUE(){
return new Queue(PAGE_CREATE_QUEUE);
}
@Bean
public Binding PAGE_CREATE_QUEUE_BINDING(@Qualifier(PAGE_CREATE_QUEUE)Queue queue,@Qualifier(GOODS_UP_EXCHANGE)Exchange exchange){
return BindingBuilder.bind(queue).to(exchange).with("").noargs();
}
2 com.ydles.page.listener
@Component
public class PageListener {
@Autowired
private PageService pageService;
@RabbitListener(queues = RabbitMQConfig.PAGE_CREATE_QUEUE)
public void receiveMessage(String spuId){
System.out.println("获取静态化页面的商品id,id的值为: "+spuId);
//条用业务层完成静态化页面生成
pageService.generateHtml(spuId);
}
}
5.3.4 模板填充
(1)面包屑数据
修改item.html,填充三个分类数据作为面包屑,代码如下:
(2)商品图片
修改item.html,将商品图片信息输出,在真实工作中需要做空判断,代码如下:
(3)规格输出
(4)默认SKU显示
静态页生成后,需要显示默认的Sku,我们这里默认显示第1个Sku即可,这里可以结合着Vue一起实现。可以先定义一个集合,再定义一个spec和sku,用来存储当前选中的Sku信息和Sku的规格,代码如下:
页面显示默认的Sku信息
(5)记录选中的Sku
在当前Spu的所有Sku中spec值是唯一的,我们可以根据spec来判断用户选中的是哪个Sku,我们可以在Vue中添加代码来实现,代码如下:
添加规格点击事件
(6)样式切换
点击不同规格后,实现样式选中,我们可以根据每个规格判断该规格是否在当前选中的Sku规格中,如果在,则返回true添加selected样式,否则返回false不添加selected样式。
Vue添加代码:
页面添加样式绑定,代码如下:
5.3.5 启动测试
1page 服务com.ydles.page.service.impl.PageServiceImpl类getItemData方法中新增
// 5获取商品规格信息
resultMap.put("specificationList", JSON.parseObject(spu.getSpecItems(),Map.class));
2测试启动所有服务 修改goods表中一条数据 status 0->1
3生成文件 D:/items/1450862568687009792.html
4打开有数据,没样式
5将静态原型中的css,js,image,data,fonts包拷贝至D盘,刷新页面。
5.3.6 基于nginx完成静态页访问
1将1450862568687009792.html页面放入ngixn下。
/usr/local/openresty/nginx/html/1450862568687009792.html
2重启ngixn
./ngixn -s -reload
3访问 http://192.168.200.128/1450862568687009792.html
4修改search服务中的search.html
608-609
<!--<a th:href="'http://192.168.200.128:8081/'+${sku.spuId}+'.html'" target="_blank"><img th:src="${sku.image}" /></a>-->
<a th:href="'http://192.168.200.128/1450862568687009792.html'" target="_blank"><img th:src="${sku.image}" /></a>
618 <a target="_blank" th:href="'http://192.168.200.128/1450862568687009792.html'" th:title="${sku.spec}" th:utext="${sku.name}"></a>
5 访问搜索页面http://localhost:9009/search/list?keywords=包包
点击图片或名称都可以跳转至详情页。
6域名访问
C:\Windows\System32\drivers\etc\hosts
添加:
192.168.200.128 item.ydles.com
总结:
1thymeleaf
页面静态化
为什么页面静态化
入门工程,标签
2搜索页面静态化渲染
3商品详情页
第9章 用户认证
角色:鉴权组,后端开发工程师。架构师。 难。
1 用户认证分析
1用户认证与授权
什么是用户身份认证?
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证表现形式有:用户名密码登录,指纹打卡等方式。
什么是用户授权?
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。
5张表
tb_user username password
tb_user_role 关联表 user_id role_id
tb_role rolename
tb_role_auth
tb_auth auth(删除首页图片)
2单点登录需求 SSO
让用户在一个系统中登录,其他任意受信任的系统都可以访问,这个功能就叫单点登录。
3第三方账号登录
当需要访问第三方系统的资源时需要首先通过第三方系统的认证(例如:微信认证),由第三方系统对用户认证通过,并授权资源的访问权限。
好处:
1用户:防止信息泄露
2网站:自己就不用再做一套登陆系统。
2 认证解决方案
2.1 单点登录技术方案
分布式系统要实现单点登录,通常将认证系统独立抽取出来,并且将用户身份信息存储在单独的存储介质,比如:MySQL、Redis,考虑性能要求,通常存储在Redis中,如下图:
特点:
1、认证系统为独立的系统。
2、各子系统通过Http或其它协议与认证系统通信,完成用户认证。
3、用户身份信息存储在Redis集群。
Java中有很多用户认证的框架都可以实现单点登录:
1、Apache Shiro.
2、CAS
3、Spring security CAS
2.2 第三方登录技术方案-重点掌握 Oauth2
2.2.1 Oauth2认证流程
怎么用:
为什么:第三方认证技术方案最主要是解决认证协议的通用标准问题,因为要实现跨系统认证,各系统之间要遵循一定的 接口协议。 OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认 证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。 Oauth协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已得到广泛应用。 参考:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin Oauth协议:https://tools.ietf.org/html/rfc6749 下边分析一个Oauth2认证的例子,4399网站使用微信认证的过程:
是什么:
流程图:!!!!!!!!重点记忆
4399——————》微信第三方登录
1.客户端请求第三方授权
用户进入4399的登录页面,点击微信的图标以微信账号登录系统,用户是自己在微信里信息的资源拥有者。
点击“用微信账号登录”出现一个二维码,此时用户扫描二维码,开始给4399授权。
2.资源拥有者同意给客户端授权
资源拥有者扫描二维码表示资源拥有者同意给客户端授权,微信会对资源拥有者的身份进行验证, 验证通过后,QQ会询问用户是否给授权4399访问自己的QQ数据,用户点击“确认登录”表示同意授权,QQ认证服务器会 颁发一个授权码,并重定向到4399的网站。
3.客户端获取到授权码,请求认证服务器申请令牌 此过程用户看不到,客户端应用程序请求认证服务器,请求携带授权码。
4.认证服务器向客户端响应令牌 认证服务器验证了客户端请求的授权码,如果合法则给客户端颁发令牌,令牌是客户端访问资源的通行证。 此交互过程用户看不到,当客户端拿到令牌后,用户在4399看到已经登录成功。
5.客户端请求资源服务器的资源 客户端携带令牌访问资源服务器的资源。 4399网站携带令牌请求访问微信服务器获取用户的基本信息。
6.资源服务器返回受保护资源 资源服务器校验令牌的合法性,如果合法则向用户响应资源信息内容。 注意:资源服务器和认证服务器可以是一个服务也可以分开的服务,如果是分开的服务资源服务器通常要请求认证 服务器来校验令牌的合法性。
Oauth2.0认证流程如下: 引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749
Oauth2包括以下角色:
1、客户端 本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:元动力二奢Android客户端、元动力二奢Web客户端(浏览器端)、微信客户端等。
2、资源拥有者 通常为用户,也可以是应用程序,即该资源的拥有者。
3、授权服务器(也称认证服务器) 用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授 权后方可访问。
4、资源服务器 存储资源的服务器,比如,元动力二奢用户管理服务器存储了元动力二奢的用户信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
2.2.2 Oauth2在项目的应用
Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,项目中使用Oauth2可以实现实现如下功能:
1、本系统访问第三方系统的资源
2、外部系统访问本系统的资源
3、本系统前端(客户端) 访问本系统后端微服务的资源。
4、本系统微服务之间访问资源,例如:微服务A访问微服务B的资源,B访问A的资源。
2.3 Spring security Oauth2认证解决方案-了解
本项目采用 Spring security + Oauth2+JWT完成用户认证及用户授权,Spring security 是一个强大的和高度可定制的身份验证和访问控制框架,Spring security 框架集成了Oauth2协议,下图是项目认证架构图:
登录:
1、用户请求认证服务完成认证。
2、认证服务下发用户身份令牌,拥有身份令牌表示身份合法。
访问:
1、用户携带令牌请求资源服务,请求资源服务必先经过网关。
2、网关校验用户身份令牌的合法,不合法表示用户没有登录,如果合法则放行继续访问。
3、资源服务获取令牌,根据令牌完成授权。
4、资源服务完成授权则响应资源信息。
3 Jwt令牌回顾
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于 在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公 钥/私钥对来签名,防止被篡改。
标准:https://tools.ietf.org/html/rfc7519
优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
1、JWT令牌较长,占存储空间比较大。
3.1 令牌结构
通过学习JWT令牌结构为自定义jwt令牌打好基础。
JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
Header
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
一个例子如下:
下边是Header部分的内容
{
"alg": "HS256",
"typ": "JWT"
}
将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。
Payload
第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比 如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。
一个例子:
{
"sub": "1234567890",
"name": "456",
"admin": true
}
Signature
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明 签名算法进行签名。
一个例子:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
secret:签名所使用的密钥。
3.2 生成私钥公钥-运维
JWT令牌生成采用非对称加密算法
1、生成密钥证书 下边命令生成密钥证书,采用RSA 算法每个证书包含公钥和私钥
keytool -genkeypair -alias ydlershe -keyalg RSA -keypass ydlershe -keystore ydlershe.jks -storepass ydlershe
Keytool 是一个java提供的证书管理工具
-alias:密钥的别名
-keyalg:使用的hash算法
-keypass:密钥的访问密码
-keystore:密钥库文件名,ydles.jks保存了生成的证书
-storepass:密钥库的访问密码
查询证书信息:
keytool -list -keystore ydlershe.jks
2、导出公钥
openssl是一个加解密工具包,这里使用openssl来导出公钥信息。
安装 openssl:http://slproweb.com/products/Win32OpenSSL.html
安装资料目录下的Win64OpenSSL-1_1_1b.exe
配置openssl的path环境变量,
cmd进入ydles.jks文件所在目录执行如下命令:
keytool -list -rfc --keystore ydlershe.jks | openssl x509 -inform pem -pubkey
下面段内容是公钥
-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1e+vuZYMGIGQnTWCfHlkzOXKmEpe4Mj4rK2sl2ykt0aDq2SVrBtUawfCUzSfyuC73OBdja7IevLcivgs0Vy4cd+T2jjaB0aOr9EwnJUB94TFplwoqMdGHHYrF2JTD4QnjdQDHvR8ugGNw7lqRRhttX/L1GoSBmXl6WwQAurfcl5dUqHekVYmCpOjQPS+29LaTNsdSYWUjzg/grDUD/FjjQDCZkQ6sTt1DxAlbFra/Td41ewbEF6xfK0oRFbuZKFeznCVp08dfSO5fL9JbtCeCxnCJsYwLO1142sVe5PpA9e+qML7i/UX6wiFixwRfcvHtRgLa2n6q9Sw38fnLKxPuwIDAQAB-----END PUBLIC KEY-----
将上边的公钥拷贝到文本public.key文件中,合并为一行,可以将它放到需要实现授权认证的工程中。
3.3 基于私钥生成jwt令牌-了解
3.3.1导入认证服务
- 将课件中
ydles_user_auth
的工程导入到项目中去,如下图:
- 启动eureka,再启动认证服务
3.3.2 认证服务中创建测试类
public class CreateJWTTest {
@Test
public void createJWT() {
// 基于私钥生成jwt
// 1创建秘钥工厂
// 1.1秘钥位置
ClassPathResource classPathResource = new ClassPathResource("ydlershe.jks");
//1.2秘钥库密码
String keyPass = "ydlershe";
/**
* 1秘钥位置
* 2秘钥库密码
*/
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(classPathResource, keyPass.toCharArray());
// 2基于工厂拿到私钥
String alias="ydlershe";
String password="ydlershe";
KeyPair keyPair = keyStoreKeyFactory.getKeyPair(alias, password.toCharArray());
//转化为rsa私钥
RSAPrivateKey rsaPrivateKey = (RSAPrivateKey)keyPair.getPrivate();
// 3生成jwt
Map<String,String> map=new HashMap<>();
map.put("conpany", "itlils");
map.put("address", "taiyuan");
Jwt jwt = JwtHelper.encode(JSON.toJSONString(map), new RsaSigner(rsaPrivateKey));
String jwtEncoded = jwt.getEncoded();
System.out.println("jwtEncoded:"+jwtEncoded);
String claims = jwt.getClaims();
System.out.println("claims:"+claims);
}
}
运行 查看输出
访问 http://tool.chinaz.com/Tools/Base64.aspx 解析base64
3.4 基于公钥解析jwt令牌
上面创建令牌后,我们可以对JWT令牌进行解析,这里解析需要用到公钥,我们可以将之前生成的公钥public.key拷贝出来用字符串变量token存储,然后通过公钥解密。
在ydles-user-oauth创建测试类com.ydles.token.ParseJwtTest实现解析校验令牌数据,代码如下:
public class ParseJwtTest {
@Test
public void parseJwt(){
//基于公钥去解析jwt
String jwt ="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmVpamluZyIsImNvbXBhbnkiOiJoZWltYSJ9.cjZNz8G0m4noNYN2VM1SH3ujAtbHElW5Vtbadb0NDI0cjM1DaAXzMA53Qbj4pmVQPl_IfSKqUEXbLxowdRa5NHR43laFsR0kzGbJiTINfSVSroSslYpDdEVwCeAF_a7I-R819YTj4p6sjuYKXbzXpeZQErczFbWWWGR2_U44xH6u1ejRNv8PikFiuzNw-muL7zUJkvqeSJzbEMnQdZMbfvZp4LtSI6B4G_PqpdNXkv19-juxAh99VgJInH_ItF0y5IBOxofA7gRebCZmU8L57gO9ohf2L00D95kis_Ji8lmA1ptLIfXqO_qLVvLBUNH-VtgjGAF0-0pyB-5jlbHP7w";
String publicKey ="-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvFsEiaLvij9C1Mz+oyAmt47whAaRkRu/8kePM+X8760UGU0RMwGti6Z9y3LQ0RvK6I0brXmbGB/RsN38PVnhcP8ZfxGUH26kX0RK+tlrxcrG+HkPYOH4XPAL8Q1lu1n9x3tLcIPxq8ZZtuIyKYEmoLKyMsvTviG5flTpDprT25unWgE4md1kthRWXOnfWHATVY7Y/r4obiOL1mS5bEa/iNKotQNnvIAKtjBM4RlIDWMa6dmz+lHtLtqDD2LF1qwoiSIHI75LQZ/CNYaHCfZSxtOydpNKq8eb1/PGiLNolD4La2zf0/1dlcr5mkesV570NxRmU1tFm8Zd3MZlZmyv9QIDAQAB-----END PUBLIC KEY-----";
//解析令牌
Jwt token = JwtHelper.decodeAndVerify(jwt, new RsaVerifier(publicKey));
//获取负载
String claims = token.getClaims();
System.out.println(claims);
}
}
运行验证
改错公钥或令牌,再测试
4 Oauth2.0入门
4.1 准备工作
- 1ydles_user库中导入表oauth_client_details(已经存在了)
观察表结构及数据
oauth框架必须有的表
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;
导入1条初始化数据,其中加密字符明文为ydlershe:
INSERT INTO `oauth_client_details` VALUES ('ydlershe', null, '$2a$10$NCbyxYr7Xaso0u584oEuMOgYsvxDDWzRz5i/Ha7VySKx9qPqKF4RS', 'app', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost', null, '43200', '43200', null, null);
4.2 Oauth2授权模式介绍
Oauth2有以下授权模式:
1.授权码模式(Authorization Code)
2.隐式授权模式(Implicit)
3.密码模式(Resource Owner Password Credentials)
4.客户端模式(Client Credentials)
其中授权码模式和密码模式应用较多,本小节介绍授权码模式。
4.2.1 授权码模式
4.2.1.1 授权码授权流程(结合流程图)
上边例举的4399网站使用微信认证的过程就是授权码模式,流程如下:
1、客户端请求第三方授权
2、用户同意给客户端授权
3、客户端获取到授权码,请求认证服务器申请 令牌
4、认证服务器向客户端响应令牌
5、客户端请求资源服务器的资源,资源服务校验令牌合法性,完成授权
6、资源服务器返回受保护资源
4.2.1.2 申请授权码
参数列表如下:
client_id:客户端id,和授权配置类中设置的客户端id一致。
response_type:授权码模式固定为code
scop:客户端范围,和授权配置类中设置的scop一致。
redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)
首先跳转到登录页面:
用户名密码都填为:ydles
接下来进入授权页面:
点击Authorize,接下来返回授权码: 认证服务携带授权码跳转redirect_uri,code=k45iLY就是返回的授权码, 每一个授权码只能使用一次
注意观察:此接口为oauth2提供,项目中没有写controller。
4.2.1.3 申请令牌
拿到授权码后,申请令牌。
Post请求:
http://localhost:9200/oauth/token
参数如下:
grant_type:授权类型,填写authorization_code,表示授权码模式
code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
此链接需要使用 http Basic认证。
什么是http Basic认证?
http协议定义的一种认证方式,将客户端id和客户端密码按照“客户端ID:客户端密码”的格式拼接,并用base64编 码,放在header中请求服务端,一个例子:
Authorization:Basic WGNXZWJBcHA6WGNXZWJBcHA=WGNXZWJBcHA6WGNXZWJBcHA= 是用户名:密码的base64编码。 认证失败服务端返回 401 Unauthorized。
以上测试使用postman完成:
http basic认证:
客户端Id和客户端密码会匹配数据库oauth_client_details表中的客户端id及客户端密码。
点击发送: 申请令牌成功
返回信如下:
access_token:访问令牌,携带此令牌访问资源
token_type:有MAC Token与Bearer Token两种类型,两种的校验算法不同,RFC 6750建议Oauth2采用 Bearer Token(http://www.rfcreader.com/#rfc6750)。
refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。
expires_in:过期时间,单位为秒。
scope:范围,与定义的客户端范围一致。
jti:当前token的唯一标识
思考:
cookie redis放那些令牌?
cookie 长度限制的 4K。
4.2.1.4 令牌校验-了解
Spring Security Oauth2提供校验令牌的端点,如下:
Get: http://localhost:9200/oauth/check_token?token= [access_token]
参数:
token:令牌
使用postman测试如下:
如果令牌校验失败,会出现如下结果:
如果令牌过期了,会如下如下结果:
4.2.1.5 刷新令牌
刷新令牌是当令牌快过期时重新生成一个令牌,它于授权码授权和密码授权生成令牌不同,刷新令牌不需要授权码 也不需要账号和密码,只需要一个刷新令牌、客户端id和客户端密码。
测试如下:
Post:http://localhost:9200/oauth/token
参数:
grant_type: 固定为 refresh_token
refresh_token:刷新令牌(注意不是access_token,而是refresh_token)
授权码模式讲解完毕。回顾。
- 1、客户端请求第三方授权
- 2、用户同意给客户端授权
- 3、客户端获取到授权码,请求认证服务器申请令牌
- 4、认证服务器向客户端响应令牌
- 5、客户端请求资源服务器的资源,资源服务校验令牌合法性,完成授权
- 6、资源服务器返回受保护资源
4.2.2 密码模式
密码模式(Resource Owner Password Credentials)与授权码模式的区别是申请令牌不再使用授权码,而是直接 通过用户名和密码即可申请令牌。
4.2.2.1 申请令牌
测试如下:
Post请求:
http://localhost:9200/oauth/token
携带参数:
grant_type:密码模式授权填写password
username:账号
password:密码
并且此链接需要使用 http Basic认证。
测试数据如下:
4.3 资源服务授权
资源服务拥有要访问的受保护资源,客户端携带令牌访问资源服务,如果令牌合法则可成功访问资源服务中的资源,如下图:
上图的业务流程如下:
1、客户端请求认证服务申请令牌
2、认证服务生成令牌认证服务采用非对称加密算法,使用私钥生成令牌。
3、客户端携带令牌访问资源服务客户端在Http header 中添加: Authorization:Bearer令牌。
4、资源服务请求认证服务校验令牌的有效性资源服务接收到令牌,使用公钥校验令牌的合法性。
5、令牌有效,资源服务向客户端响应资源信息
4.3.1 改造用户服务 对接Oauth2
基本上所有微服务都是资源服务,这里我们在课程管理服务上配置授权控制,当配置了授权控制后如要访问课程信 息则必须提供令牌。
1、配置公钥 ,将 ydles_user_auth 项目中public.key复制到ydles_service_user中
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);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/user/add","/user/load/**"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
4.3.2 资源服务授权测试
1不携带令牌访问http://localhost:9005/user
由于该地址受访问限制,需要授权,所以出现如下错误:
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
2携带令牌访问http://localhost:9005/user
在http header中添加 Authorization: Bearer 令牌
当输入错误的令牌也无法正常访问资源。
5 认证开发
5.1 需求分析
功能流程图如下:
执行流程:
1登陆:认证服务认证通过,生成jwt令牌,将jwt令牌及相关信息写入Redis,并且将身份令牌写入cookie
2访问:用户访问资源页面,带着cookie到网关,网关从cookie获取token,并查询Redis校验token,如果token不存在则拒绝访问,否则放行
3退出:请求认证服务,清除redis中的token,并且删除cookie中的token
使用redis存储用户的身份令牌有以下作用:
1、实现用户退出注销功能,服务端清除令牌后,即使客户端请求携带token也是无效的。
2、由于jwt令牌过长,不宜存储在cookie中,所以将jwt令牌存储在redis,由客户端请求服务端获取并在客户端存储。
5.2 Redis配置
将认证服务ydles_user_auth中的application.yml配置文件中的Redis配置改成自己对应的端口和密码。
5.3 认证服务
5.3.1 认证需求分析
认证服务需要实现的功能如下:
1、登录接口
前端post提交账号、密码等,用户身份校验通过,生成令牌,并将令牌存储到redis。 将令牌写入cookie。
2、退出接口
校验当前用户的身份为合法并且为已登录状态。 将令牌从redis删除。 删除cookie中的令牌。
5.3.2 授权参数配置
修改ydles_user_auth中application.yml配置文件,修改对应的授权配置
auth:
ttl: 1200 #token存储到redis的过期时间
clientId: ydlershe #客户端ID
clientSecret: ydlershe #客户端秘钥
cookieDomain: localhost #Cookie保存对应的域名
cookieMaxAge: -1 #Cookie过期时间,-1表示浏览器关闭则销毁
配置redis
redis:
host: 192.168.200.128
5.3.3 申请令牌测试
明确:request=请求头+请求体
所以postman
转化为:请求头中
计算方法为:Basic base64(客户端名称:客户端密码)
为了不破坏Spring Security的代码,我们在Service方法中通过RestTemplate请求Spring Security所暴露的申请令 牌接口来申请令牌,下边是测试代码:
启动类添加
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
写测试类
package com.ydles;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.util.Base64Utils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.net.URI;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* 缺啥补啥
*/
@SpringBootTest(classes = OAuthApplication.class)
@RunWith(SpringRunner.class)
public class ApplyTokenTest {
@Autowired
RestTemplate restTemplate;
@Autowired
LoadBalancerClient loadBalancerClient;
//代码申请令牌
@Test
public void applyToken(){
//1 url http://localhost:9200/oauth/token
ServiceInstance serviceInstance = loadBalancerClient.choose("user-auth");
URI uri = serviceInstance.getUri(); //http://localhost:9200
String url=uri+"/oauth/token";
//2.1 请求头
MultiValueMap<String, String> headers=new LinkedMultiValueMap<>();
headers.add("Authorization", getHttpHeaders("ydlershe","ydlershe"));
//2.2 请求体
MultiValueMap<String, String> body=new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username","itlils");
body.add("password","itlils");
//2 构建请求
HttpEntity<MultiValueMap<String, String>> requestEntity=new HttpEntity<>(body,headers);
//400 401 不报错,返回回来
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
//响应当中有错误 如何处理
@Override
public void handleError(URI url, HttpMethod method, ClientHttpResponse response) throws IOException {
if(response.getRawStatusCode()!=400&&response.getRawStatusCode()!=401){
super.handleError(url,method,response);
}
}
});
//3发请求
ResponseEntity<Map> exchange = restTemplate.exchange(url, HttpMethod.POST, requestEntity, Map.class);
//4请求中 拿数据
Map body1 = exchange.getBody();
System.out.println(body1);
}
//请求头中Authorization值的计算方法
public String getHttpHeaders(String clientId,String clinetSecret){
//Basic base64(客户端名称:客户端密码)
String str=clientId+":"+clinetSecret;
byte[] encode = Base64Utils.encode(str.getBytes());
return "Basic "+new String(encode);
}
}
5.3.4 业务层
1service层添加接口 com.ydles.oauth.service
public interface AuthService {
AuthToken login(String username,String password,String clientId,String clientSecret);
}
2接口实现 com.ydles.oauth.service.impl
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
RestTemplate restTemplate;
@Autowired
LoadBalancerClient loadBalancerClient;
@Autowired
RedisTemplate redisTemplate;
@Value("${auth.ttl}")
private long ttl;
@Override
public AuthToken login(String username, String password, String clientId, String clientSecret) {
// 1申请令牌
//请求地址: http://localhost:9200/oauth/token
ServiceInstance serviceInstance = loadBalancerClient.choose("user-auth");
// http://localhost:9200
URI uri = serviceInstance.getUri();
String url = uri + "/oauth/token";
//请求体 body
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "password");
body.add("username", username);
body.add("password", password);
//请求头
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Authorization", this.getHttpBasic(clientId, clientSecret));
//封装请求参数
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, headers);
//后端401 400,不认为是异常,直接返回给前端
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
if(response.getRawStatusCode()!=400&&response.getRawStatusCode()!=401){
super.handleError(response);
}
}
});
/**
* 1 url
* 2请求方法
* 3请求头和体
* 4返回值类型
*/
ResponseEntity<Map> responseMap = restTemplate.exchange(url, HttpMethod.POST, requestEntity, Map.class);
Map map = responseMap.getBody();
if(map==null&&map.get("access_token")==null&&map.get("refresh_token")==null&&map.get("jti")==null){
new RuntimeException("申请令牌失败!");
}
// 2封装数据结果
AuthToken authToken=new AuthToken();
authToken.setAccessToken((String) map.get("access_token"));
authToken.setRefreshToken((String) map.get("refresh_token"));
authToken.setJti((String) map.get("jti"));
// 3将jti:jwt存储到redis
redisTemplate.boundValueOps(authToken.getJti()).set(authToken.getAccessToken(),ttl , TimeUnit.SECONDS);
return authToken;
}
//请求头中Authorization值的计算方法
private String getHttpBasic(String clientId, String clientSecret) {
String value = clientId + ":" + clientSecret;
byte[] encode = Base64Utils.encode(value.getBytes());
return "Basic "+new String(encode);
}
}
5.3.5 控制层
com.ydles.oauth.controller 添加
package com.ydles.oauth.controller;
import com.ydles.entity.Result;
import com.ydles.entity.StatusCode;
import com.ydles.oauth.service.AuthService;
import com.ydles.oauth.util.AuthToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
@Controller
@RequestMapping("/oauth")
public class AuthController {
@Autowired
AuthService authService;
@Value("${auth.clientId}")
String clientId;
@Value("${auth.clientSecret}")
String clientSecret;
@Value("${auth.cookieMaxAge}")
int cookieMaxAge;
@Value("${auth.cookieDomain}")
String cookieDomain;
@RequestMapping("/login")
@ResponseBody
public Result login(String username, String password, HttpServletResponse response){
AuthToken authToken = authService.login(username, password, clientId, clientSecret);
//三 responses cookie 放jti
Cookie cookie = new Cookie("uid",authToken.getJti());
cookie.setMaxAge(cookieMaxAge);
cookie.setPath("/");
cookie.setDomain(cookieDomain);
cookie.setHttpOnly(false);
response.addCookie(cookie);
return new Result(true, StatusCode.OK,"登陆成功", authToken.getJti());
}
}
拓展:cookie session 区别和联系 应用场景。
如何用代码来操作cookie session?
5.3.6 登录请求放行
修改认证服务WebSecurityConfig类中configure(),添加放行路径
5.3.7 测试认证接口
使用postman测试:
1重启认证服务
2postman测试
POST http://localhost:9200/oauth/login
username itlils
password itlils
观察cookie
观察redis中数据
若key value有乱码,则使用 StringRedisTemplate。
5.3.8 动态获取用户信息
1需求:
当前在认证服务中,用户密码是写死在用户认证类中。所以用户登录时,无论帐号输入什么,只要密码是itheima都可以访问。因此需要动态获取用户帐号与密码.
2用户服务 com.ydles.user.controller UserController中新增方法
@GetMapping("/load/{username}")
public User findUserInfo(@PathVariable("username") String username){
User user = userService.findById(username);
return user;
}
3user-api中 新增 feigh客户端。com.ydles.user.feign
@FeignClient(name = "user")
public interface UserFeign {
@GetMapping("/user/load/{username}")
public User findUserInfo(@PathVariable("username") String username);
}
4认证服务导入依赖
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_user_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
5认证启动类添加注解
@EnableFeignClients(basePackages = {"com.ydles.user.feign"})
6修改com.ydles.oauth.config UserDetailsServiceImpl 改为动态获取用户信息
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
ClientDetailsService clientDetailsService;
@Autowired
UserFeign userFeign;
/****
* 自定义授权认证
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//取出身份,如果身份为空说明没有认证
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret,开始认证client_id和client_secret
if(authentication==null){
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
if(clientDetails!=null){
//秘钥
String clientSecret = clientDetails.getClientSecret();
//静态方式
//return new User(username,new BCryptPasswordEncoder().encode(clientSecret), AuthorityUtils.commaSeparatedStringToAuthorityList(""));
//数据库查找方式
return new User(username,clientSecret, AuthorityUtils.commaSeparatedStringToAuthorityList(""));
}
}
if (StringUtils.isEmpty(username)) {
return null;
}
//根据用户名查询用户信息
// String pwd = new BCryptPasswordEncoder().encode("itlils");
com.ydles.user.pojo.User user = userFeign.findUserInfo(username);
//创建User对象
String permissions = "goods_list,seckill_list";
UserJwt userDetails = new UserJwt(username,user.getPassword(),AuthorityUtils.commaSeparatedStringToAuthorityList(permissions));
return userDetails;
}
}
7重启测试 登陆 http://localhost:9200/oauth/login
报错401 因为用户服务已经受保护。
8用户服务 com.ydles.user.config ResourceServerConfig类的configure方法
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers(
"/user/add","/user/load/**"). //配置地址放行
permitAll()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
9再次访问即可成功
6 认证服务对接网关
6.1 新建网关工程ydles_gateway_web
- ydles_gateway这个大模块,添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
<!--鉴权-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
</dependencies>
创建ydles_gateway_web客户端网关模块。不用添加依赖,因为父依赖已有。
启动类com.ydles.web.gateway
@SpringBootApplication
@EnableEurekaClient
public class WebGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(WebGatewayApplication.class,args);
}
}
- 创建application.yml
spring:
application:
name: gateway-web
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE
routes:
- id: ydles_goods_route
uri: lb://goods
predicates:
- Path=/api/album/**,/api/brand/**,/api/cache/**,/api/categoryBrand/**,/api/category/**,/api/para/**,/api/pref/**,/api/sku/**,/api/spec/**,/api/spu/**,/api/stockBack/**,/api/template/**
filters:
#- PrefixPath=/brand
- StripPrefix=1
#用户微服务
- id: ydles_user_route
uri: lb://user
predicates:
- Path=/api/user/**,/api/address/**,/api/areas/**,/api/cities/**,/api/provinces/**
filters:
- StripPrefix=1
#认证微服务
- id: ydles_oauth_user
uri: lb://user-auth
predicates:
- Path=/api/oauth/**
filters:
- StripPrefix=1
redis:
host: 192.168.200.128
server:
port: 8001
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
management:
endpoint:
gateway:
enabled: true
web:
exposure:
include: true
6.2 网关全局过滤器
1需求:
先通过网关是确定用户是否拥有令牌,微服务是进行令牌的校验。
1.1登陆时:放行
1.2用户访问时:
- 1)判断当前请求是否为登录请求,是的话,则放行
- 2 ) 判断cookie中是否存在信息, 没有的话,拒绝访问
- 3)判断redis中令牌是否存在,没有的话,拒绝访问
2客户端网关工程 com.ydles.web.gateway.filter
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Autowired
AuthService authService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//1判断当前请求是否为登陆,是就放行
String path = request.getURI().getPath();
if ("/api/oauth/login".equals(path)) {
//放行
return chain.filter(exchange);
}
// 2从cookie中获取jti,不存在,拒绝访问
String jti=authService.getJtiFromCookie(request);
if(StringUtils.isEmpty(jti)){
//拒绝访问
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 3从redis获取jwt,不存在,则拒绝访问
String jwt=authService.getJwtFromRedis(jti);
if(StringUtils.isEmpty(jwt)){
//拒绝访问
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 4对当前请求增强,让其携带令牌信息
request.mutate().header("Authorization", "Bearer "+jwt);
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
3com.ydles.web.gateway.service
@Service
public class AuthService {
@Autowired
StringRedisTemplate stringRedisTemplate;
//从request中获取jti
public String getJtiFromCookie(ServerHttpRequest request) {
HttpCookie httpCookie = request.getCookies().getFirst("uid");
if (httpCookie != null) {
String jti = httpCookie.getValue();
return jti;
}
return null;
}
public String getJwtFromRedis(String jti) {
String jwt = stringRedisTemplate.boundValueOps(jti).get();
return jwt;
}
}
测试:
1启动用户网关
2测试Get http://localhost:8001/api/user/
401
3测试登陆
POST http://localhost:8001/api/oauth/login
4再测试Get http://localhost:8001/api/user/ 成功。因为查看cookie中有uid。
7 自定义登录页面
需求:oauth自带登陆页面太丑,我们想要一个体验更好的登陆页面。
访问路径: http://localhost:8001/api/oauth/toLogin
流程: 1客户端网关-》认证服务controller-》login.html。
2点击登陆-》/api/oauth/login-》获取令牌登陆
1 认证服务添加依赖
<!--thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
2 resources下 新增static、templates文件夹。放入资源里的数据。
3 AuthController中添加跳转
@RequestMapping("/toLogin")
public String toLogin(){
return "login";
}
4 放行静态资源。com.ydles.oauth.config包 WebSecurityConfig类
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers( "/oauth/login","/oauth/logout","/oauth/toLogin","/login.html","/css/**","/data/**","/fonts/**","/img/**","/js/**"
);
}
5登陆经过用户网关,所以将static目录copy至gateway_web一份
6开启表单登陆。com.ydles.oauth.config包 WebSecurityConfig类
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.httpBasic() //启用Http基本身份验证
.and()
.formLogin() //启用表单身份验证
.and()
.authorizeRequests() //限制基于Request请求访问
.anyRequest()
.authenticated(); //其他请求都需要经过验证
//开启表单登陆
http.formLogin().loginPage("/oauth/toLogin")//设置访问登陆页面的路径
.loginProcessingUrl("/oauth/login");//设置执行登陆操作的路径
}
7login.html修改
2 <html lang="en" xmlns:th="http://www.thymeleaf.org">
82 <div class="login-box" id="app">
101 <input id="inputName" type="text" v-model="username" placeholder="邮箱/用户名/手机号" class="span2 input-xfat">
104 <input id="inputPassword" type="password" v-model="password" placeholder="请输入密码" class="span2 input-xfat">
114-115
<!--<a class="sui-btn btn-block btn-xlarge btn-danger" href="home.html" target="_blank">登 录</a>-->
<button class="sui-btn btn-block btn-xlarge btn-danger" type="button" @click="login()">登 录</button>
167-188
<script th:inline="javascript">
var app = new Vue({
el:"#app",
data:{
username:"",
password:"",
msg:""
},
methods:{
login:function () {
app.msg="正在登录";
axios.post("/api/oauth/login?username="+app.username+"&password="+app.password).then(function (response) {
if (response.data.flag){
app.msg="登录成功";
} else{
app.msg="登录失败";
}
})
}
}
})
</script>
测试:
1geteway-web工程 com.ydles.web.gateway.filter AuthFilter。放行登陆跳转。
//1判断当前请求是否为登陆,是就放行
String path = request.getURI().getPath();
if ("/api/oauth/login".equals(path) || "/api/oauth/toLogin".equals(path) ) {
//放行
return chain.filter(exchange);
}
2重启认证服务、客户网关
3 浏览器访问 http://localhost:8001/api/oauth/toLogin
输入itlils itlils666 ,显示登陆成功。
5F12查看浏览器cookie
6查看redis
8自定义路径过滤器
1需求:许多url我们需要经行令牌校验,许多url不需要。所以写一个工具类判断。
2写一个工具类,判断url是否需要过滤。com.ydles.web.gateway.filter
package com.ydles.web.gateway.utils;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
*/
public class UrlUtil {
//哪些路径需要令牌
public static String filterPath = "/api/wseckillorder,/api/seckill,/api/wxpay,/api/wxpay/**,/api/worder/**,/api/user/**,/api/address/**,/api/wcart/**,/api/cart/**,/api/categoryReport/**,/api/orderConfig/**,/api/order/**,/api/orderItem/**,/api/orderLog/**,/api/preferential/**,/api/returnCause/**,/api/returnOrder/**,/api/returnOrderItem/**";
//传来一个路径 需不需要令牌
public static boolean hasAuthorize(String url){
String[] split = filterPath.replace("**", "").split(",");
for (String s : split) {
if(url.startsWith(s)){
return true;
}
}
return false;
}
}
3AuthFilter优化
//1.1登陆时:放行
String path = request.getURI().getPath(); // /oauth/login
System.out.println("path:"+path);
if(!UrlUtil.hasAuthorize(path)){
//放行
return chain.filter(exchange);
}
总结:
1登陆
登陆与鉴权 5张表
单点登录sso spring cas Shiro
第三方登录 oauth2
2jwt
构成 base64(头{}).base64(负载{}).sha256(前两部分,secret)
secret------>私钥 jks
公钥 public.txt
3oauth2
授权码
密码
postman 测试
4认证开发
作业:退出
5自定义登录页面
路径过滤器
第10章 购物车
角色:购物车模块后台工程师
学习目标
- 能够通过SpringSecurity进行权限控制
- 掌握购物车流程
- 掌握购物车渲染
- 微服务之间的认证访问
1 SpringSecurity权限控制 鉴权
用户每次访问微服务的时候,先去oauth2.0服务登录,登录后再访问微服务网关,微服务网关将请求转发给其他微服务处理。
由于我们项目使用了微服务,任何用户都有可能使用任意微服务,此时我们需要控制相关权限,例如:普通用户角色不能使用用户的删除操作,只有管理员才可以使用,那么这个时候就需要使用到SpringSecurity的权限控制功能了。
user user_role role role_auth auth
user
id name password
1 itlils itlils666
2 itnanls nangeniubi
user_role
userid roleid
1 1
1 2
1 3
2 3
role
id name
1 管理员
2 运维人员
3 客户
role_auth
roleid authid
1 1
1 2
3 1
auth
id auth
1 查询商品
2 删除商品
User (id:1,name:itlils,password:itlils666,rolelist[管理员, 运维人员,客户],authList[查询商品,删除商品])
1.1 角色权限加载
1ydles-user-oauth工程的com.ydles.oauth.confifig.UserDetailsServiceImpl该类实现了加载用户相关信息
2启动 eureka gateway_web user_oauth service_user工程
3通过网关申请令牌 POST http://localhost:8001/api/oauth/login
4 解析jwt
https://www.qqxiuzi.cn/bianma/base64.htm
1.2 角色权限控制
1被调用的工程中,开启授权。如ydles-user-service工程的com.ydles.user.config.ResourceServerConfig中
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
2controller方法上添加。如ydles-user-service工程的com.ydles.user.controller findAll方法
@PreAuthorize("hasAnyAuthority('admin')")
3重启ydles-user-service,访问 GET http://localhost:8001/api/user/
user服务后台
4 改成能访问,findAll方法上,改为,重启user服务。
@PreAuthorize("hasAnyAuthority('goods_list')")
5重新用postman访问,就能获取数据了
企业开发中的用法:
1 查询用户时,就把相关权限字符串一起查出来,放到jwt
2 各个微服务中,每个controller的每个方法上 想要权限认证的方法才加
@PreAuthorize("hasAnyAuthority('goods_list')")
3 底层咋实现 spring securety Oauth2 框架实现。
1.3 小结
如果希望一个方法能被多个角色访问,配置:@PreAuthorize("hasAnyAuthority('admin','user')")
如果希望一个类都能被多个角色访问,在类上配置:@PreAuthorize("hasAnyAuthority('admin','user')")
2 购物车
购物车分为用户登录购物车和未登录购物车操作
1京东用户登录和不登录都可以操作购物车,如果用户不登录,操作购物车可以将数据存储到Cookie,用户登录后购物车数据可以存储到Redis中,再将之前未登录加入的购物车合并到Redis中即可。
2淘宝天猫,用户要想将商品加入购物车,必须先登录才能操作购物车。
2.1 购物车业务分析
我们实现天猫思路
购物车需求:
1用户在商品详细页点击加入购物车,提交商品SKU编号和购买数量,添加到购物车。
2业务逻辑
添加和查看购物车都是去redis。
3购物车表结构:ydles_order数据中tb_order_item表
2.2 添加购物车
需求:订单服务远程调用商品服务
1将skucontroller的findById方法暴露
goods-api项目com.ydles.goods.feign.SkuFeign
//skuId--->sku
@GetMapping("/sku/{id}")
public Result<Sku> findById(@PathVariable String id);
2service-order 订单服务
导入依赖
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_goods_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
3启动类开启feign
@EnableFeignClients(basePackages = "com.ydles.goods.feign")
添加购物车 业务层实现-重点
添加购物车 :
1选择规格后的一个sku,往购物车添加。购物车redis里存着。
2相同sku可以叠加。不同sku可以都放在购物车中
3 点击加入购物车 skuid num
拓展:回顾 redis 数据结构 string
value:string list set map zset
service-order 订单服务
1com.ydles.order.service.CartService
package com.ydles.order.service;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* 购物车服务层
*/
public interface CartService {
//添加购物车
public void addCart(String skuId,Integer num,String username);
}
2实现com.ydles.order.service.impl.CartServiceImpl
redis中数据格式
package com.ydles.order.service.impl;
import com.alibaba.fastjson.JSON;
import com.ydles.goods.feign.SkuFeign;
import com.ydles.goods.feign.SpuFeign;
import com.ydles.goods.pojo.Sku;
import com.ydles.goods.pojo.Spu;
import com.ydles.order.pojo.OrderItem;
import com.ydles.order.service.CartService;
import com.ydles.util.IdWorker;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* 缺啥补啥
*/
@Service
public class CartServiceImpl implements CartService {
//购物车redis key 头
private static final String CART = "cart_";
@Autowired
StringRedisTemplate stringRedisTemplate;
@Autowired
SpuFeign spuFeign;
@Autowired
SkuFeign skuFeign;
//添加购物车 从购物车减一个,num=-1
@Override
public void addCart(String skuId, Integer num, String username) {
String orderItemStr = (String) stringRedisTemplate.boundHashOps(CART + username).get(skuId);
Sku sku = skuFeign.findById(skuId).getData();
Spu spu = spuFeign.findSpuById(sku.getSpuId()).getData();
//判断 当前用户购物车里有没有添加的sku
OrderItem orderItem;
if (StringUtils.isNotEmpty(orderItemStr)) {
orderItem = JSON.parseObject(orderItemStr, OrderItem.class);
//购物车里有 更新redis数据
orderItem.setNum(orderItem.getNum() + num);
if (orderItem.getNum() < 1) {
//总数小于1 redis删除
stringRedisTemplate.boundHashOps(CART + username).delete(skuId);
}
orderItem.setMoney(orderItem.getPrice() * orderItem.getNum());
orderItem.setPayMoney(orderItem.getPrice() * orderItem.getNum());//满减
orderItem.setWeight(sku.getWeight() * num);
} else {
//新加入购物车的sku
orderItem = sku2OrderItem(spu, sku, num);
}
//放入redis
stringRedisTemplate.boundHashOps(CART + username).put(skuId, JSON.toJSONString(orderItem));
}
@Autowired
IdWorker idWorker;
//拼接 OrderItem
private OrderItem sku2OrderItem(Spu spu, Sku sku, Integer num) {
OrderItem orderItem = new OrderItem();
orderItem.setId(idWorker.nextId() + "");
orderItem.setCategoryId1(spu.getCategory1Id());
orderItem.setCategoryId2(spu.getCategory2Id());
orderItem.setCategoryId3(spu.getCategory3Id());
orderItem.setSpuId(spu.getId());
orderItem.setSkuId(sku.getId());
orderItem.setName(sku.getName());
orderItem.setPrice(sku.getPrice());
orderItem.setNum(num);
orderItem.setMoney(sku.getPrice() * num);
orderItem.setPayMoney(sku.getPrice() * num);
orderItem.setImage(sku.getImage());
orderItem.setWeight(sku.getWeight() * num);
return orderItem;
}
}
订单服务添加cartService,实现添加购物车
1 service-order工程 com.ydles.order.controller
@RestController
@RequestMapping("/cart")
public class CartController {
@Autowired
private CartService cartService;
@Autowired
private TokenDecode tokenDecode;
@GetMapping("/addCart")
public Result addCart(@RequestParam("skuId") String skuId, @RequestParam("num") Integer num){
//动态获取当前人信息,暂时静态
String username = "itlils";
cartService.addCart(skuId,num,username);
return new Result(true, StatusCode.OK,"加入购物车成功");
}
}
redis配置
spring:
redis:
host: 192.168.200.128
网关配置
#订单微服务
- id: ydles_order_route
uri: lb://order
predicates:
- Path=/api/cart/**,/api/cart
filters:
- StripPrefix=1
2启动 service-order、service-good,gateway_web
3通过网关访问服务 http://localhost:8001/api/cart/addCart?skuId=1450868238861729792&num=5
4redis检查
2.4 购物车列表
需求:
2.4.1 思路分析
接着我们实现一次购物车列表操作。因为存的时候是根据用户名往Redis中存储用户的购物车数据的,所以我们这里可以将用户的名字作为key去Redis中查询对应的数据。
3.4.2 代码实现
1service-order 订单服务 com.ydles.order.service.CartService
//查询购物车数据
Map list(String username);
2 实现类com.ydles.order.service.impl.CartServiceImpl 中增加
//查询购物车列表数据
@Override
public Map list(String username) {
Map map = new HashMap();
List<OrderItem> orderItemList = redisTemplate.boundHashOps(CART + username).values();
map.put("orderItemList",orderItemList);
//商品的总数量与总价格
Integer totalNum = 0;
Integer totalMoney = 0;
for (OrderItem orderItem : orderItemList) {
totalNum+=orderItem.getNum();
totalMoney+=orderItem.getMoney();
}
map.put("totalNum",totalNum);
map.put("totalMoney",totalMoney);
return map;
}
3控制层
/***
* 查询用户购物车列表
* @return
*/
@GetMapping(value = "/list")
public Map list(){
//暂时静态,后续修改
String username = "itcast";
return cartService.list(username);
}
4测试
使用Postman访问 GET http://localhost:8001/api/cart/list ,效果如下:
3 购物车渲染
需求:展现如下页面
思路分析
如上图所示,用户每次将商品加入购物车,或者点击购物车列表的时候,先经过订单购物车后端渲染服务,再通过feign调用购物车订单微服务来实现购物车的操作,例如:加入购物车、购物车列表。
3.1 购物车渲染服务搭建
在ydles_web中搭建订单购物车微服务工程ydles_web_order
,该工程主要实现购物车和订单的渲染操作。
在ydles_web中搭建订单购物车微服务工程 ydles_web_order
(1) pom.xml依赖
<?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>ydles-web</artifactId>
<groupId>com.ydles</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ydles_web_order</artifactId>
<!--依赖-->
<dependencies>
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_service_order_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
</project>
(2)application.yml配置
server:
port: 9111
spring:
application:
name: order-web
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
thymeleaf:
cache: false
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 60000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 80000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
thread:
# 熔断器超时时间,默认:1000/毫秒
timeoutInMilliseconds: 80000
(3)创建启动类
创建com.ydles.OrderWebApplication启动类,代码如下:
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients(basePackages = "com.ydles.order.feign")
public class OrderWebApplication {
public static void main(String[] args) {
SpringApplication.run(OrderWebApplication.class,args);
}
}
(4)静态资源拷贝
资源\成品页面\cart.html页面拷贝到工程中,如下图:
3.2 购物车列表渲染
3.2.1 Feign创建
1 ydles_service_order_api中添加CartFeign接口,并在接口中创建添加购物车和查询购物车列表,代码如下:
@FeignClient(name="order")
public interface CartFeign {
/**
* 添加购物车
* @param skuId
* @param num
* @return
*/
@GetMapping("/cart/add")
public Result add(@RequestParam("skuId") String skuId, @RequestParam("num") Integer num);
/***
* 查询用户购物车列表
* @return
*/
@GetMapping(value = "/cart/list")
public Map list();
}
2 导包
<dependency>
<groupId>com.ydles</groupId>
<artifactId>ydles_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
3controller
在ydles_web_order中创建com.ydles.order.controller.CartController,并添加查询购物车集合方法和添加购物车方法,代码如下:
@Controller
@RequestMapping("/wcart")
public class CartController {
//查询
@GetMapping("/list")
public String list(Model model){
Map map = cartFeign.list();
model.addAttribute("items",map);
return "cart";
}
//添加
@GetMapping("/add")
@ResponseBody
public Result<Map> add(String id,Integer num){
cartFeign.addCart(id,num);
Map map = cartFeign.list();
return new Result<>(true, StatusCode.OK,"添加购物车成功",map);
}
}
3.2.3 前端页面
1资料的static和template放入resources下。
改造静态页面
2 <html xmlns:th="http://www.thymeleaf.org">
13-14
<link rel="stylesheet" type="text/css" href="/css/all.css" />
<link rel="stylesheet" type="text/css" href="/css/pages-cart.css" />
86 <div class="cart py-container" id="app">
124-128
<li class="yui3-u-1-8">
<a href="javascript:void(0)" class="increment mins" @click="add(item.skuId,-1)">-</a>
<input autocomplete="off" type="text" v-model="item.num" @blur="add(item.skuId,item.num)" minnum="1" class="itxt" />
<a href="javascript:void(0)" class="increment plus" @click="add(item.skuId,1)">+</a>
</li>
362
<script th:inline="javascript">
var app = new Vue({
el:"#app",
data:{
items:[[${items}]]
},
methods:{
add:function (skuId,num) {
axios.get("/api/wcart/add?skuId="+skuId+"&num="+num).then(function (resp) {
if (resp.data.flag){
app.items=resp.data.data;
}
})
}
}
})
</script>
2若想访问页面渲染微服务,需修改网关配置 gateway-web,增加路由
#订单微服务
- id: ydles_order_route
uri: lb://order
predicates:
- Path=/api/cart/**,/api/categoryReport/**,/api/orderConfig/**,/api/order/**,/api/orderItem/**,/api/orderLog/**,/api/preferential/**,/api/returnCause/**,/api/returnOrder/**,/api/returnOrderItem/**
filters:
- StripPrefix=1
#购物车订单渲染微服务
- id: ydles_order_web_route
uri: lb://order-web
predicates:
- Path=/api/wcart/**,/api/worder/**
filters:
- StripPrefix=1
3启动相关微服务
4先登陆 http://localhost:8001/api/oauth/toLogin itlils itlils666
登陆成功
5 通过网关访问 购物车页面 http://localhost:8001/api/wcart/list
3.2.4 微服务之间的认证访问 购物车渲染服务、订单服务对接网关
1订单服务对接oauth
1配置公钥
将ydles_user_oauth项目的public.key拷贝到ydles_service_order项目的resource目录下
2ydles_service_order项目添加oauth依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
3ydles_service_order项目添加添加配置类
@Configuration
@EnableResourceServer
//开启方法上的PreAuthorize注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
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);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
3.3 微服务间认证-面试重点
因为微服务之间并没有传递头文件,所以我们可以定义一个拦截器,每次微服务调用之前都先检查下头文件,将请求的头文件中的令牌数据再放入到header中,再调用其他微服务即可。
测试访问购物车,产生如下效果:
feign拦截器实现微服务间认证
1创建拦截器
在ydles_common服务中创建一个com.ydles.interceptor.FeignInterceptor拦截器,并将所有头文件数据再次加入到Feign请求的微服务头文件中,代码如下:
package com.ydles.interceptor;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;
/**
* @Created by IT李老师
* 公主号 “IT李哥交朋友”
* 个人微 itlils
* feign拦截器 只要feign远程调用,作用:把上一次请求头jwt带到这一次请求中
*/
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
//1拿到上一层请求头中的jwt
//这一次整体请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if(requestAttributes!=null){
//拿到我们常用的这个请求
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
if(request!=null){
//请所有的求头
Enumeration<String> headerNames = request.getHeaderNames();
//遍历
while (headerNames.hasMoreElements()){
String headName = headerNames.nextElement();
if(headName.equalsIgnoreCase("Authorization")){
String jwt = request.getHeader(headName);//Bearer jwt
//2jwt 带到这一次 feign调用中
requestTemplate.header(headName,jwt);
}
}
}
}
}
}
2 更改ydles_order_web启动类,添加拦截器声明
@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}
测试:通过网关访问 购物车页面 http://localhost:8001/api/wcart/list
重点理解:谁用feign,拦截器就放在谁启动类里。
3.4 动态获取当前登陆人
1添加资源中的TokenDecode工具类到ydles-service-order微服务config包下,用于解密令牌信息
2在ydles-common工程中引入鉴权包
<!--鉴权-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
<scope>provided</scope>
</dependency>
3order 服务增加bean对象
@Bean
public TokenDecode tokenDecode() {
return new TokenDecode();
}
4在CartController中注入TokenDecode,并调用TokenDecode的getUserInfo方法获取用户信息,代码如下:
@Autowired
private TokenDecode tokenDecode;
@GetMapping("/addCart")
public Result addCart(@RequestParam("skuId") String skuId, @RequestParam("num") Integer num){
//动态获取当前人信息,暂时静态
String username = "itcast";
// String username = tokenDecode.getUserInfo().get("username");
cartService.addCart(skuId,num,username);
return new Result(true, StatusCode.OK,"加入购物车成功");
}
@GetMapping("/list")
public Map list(){
//动态获取当前人信息,暂时静态
String username = "itcast";
// String username = tokenDecode.getUserInfo().get("username");
Map map = cartService.list(username);
return map;
}
3.5 页面配置
3.5.1 未登录时登录跳转
在用户没有登录的情况下,直接访问购物车页面,效果如下:
我们可以发现,返回的只是个错误状态码,这个毫无意义,我们应该重定向到登录页面,让用户登录,我们可以修改网关的头文件,让用户每次没登录的时候,都跳转到登录页面。
修改ydles-gateway-web的com.ydles.filter.AuthFilter
,代码如下:
@Component
public class AuthFilter implements GlobalFilter, Ordered {
public static final String Authorization = "Authorization";
//登录地址
public static final String LOGIN_URL = "http://localhost:8001/api/oauth/toLogin";
@Autowired
private AuthService authService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取当前请求对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
if ("/api/oauth/login".equals(path) || !UrlFilter.hasAuthorize(path)){
//放行
return chain.filter(exchange);
}
//判断cookie上是否存在jti也就是短令牌
String jti = authService.getJtiFromCookie(request);
if (StringUtils.isEmpty(jti)){
//拒绝访问,请求跳转
// response.setStatusCode(HttpStatus.UNAUTHORIZED);
// return response.setComplete();
return this.toLoginPage(LOGIN_URL, exchange);
}
//根据短令牌获取长令牌, 并且判断是否有长令牌
String token = authService.getTokenFromRedis(jti);
if (StringUtils.isEmpty(token)){
//拒绝访问,请求跳转
// response.setStatusCode(HttpStatus.UNAUTHORIZED);
// return response.setComplete();
return this.toLoginPage(LOGIN_URL, exchange);
}
//校验通过 , 请求头增强,放行(将长令牌放入请求头)
request.mutate().header(Authorization,"Bearer "+token);
return chain.filter(exchange);
}
/**
* 跳转到登录页面
* @param loginUrl 跳转的地址
* @return
*/
private Mono<Void> toLoginPage(String loginUrl, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set("Location", loginUrl);
return response.setComplete();
}
@Override
public int getOrder() {
return 0;
}
}
测试:访问:http://localhost:8001/api/wcart/list
拓展:
http code
2 200 201 请求没问题
3 303 请求没问题,但是需要客户端往外做一些东西
4 404 客户端的问题
5 服务器端有问题了
3.5.2 登录成功跳转原地址
刚才已经实现了未登录时跳转登录页,但是当登录成功后,并没有跳转到用户本来要访问的页面。
要实现这个功能的话,可以将用户要访问的页面作为参数传递到登录控制器,由登录控制器根据参数完成路径跳转。
2.1修改网关携带当前访问URI
修改ydles-gateway-web的com.ydles.filter.AuthFilter
,在之前的URL后面添加ReturnUrl参数以及ReturnUrl参数的值为request.getURI()
,代码如下:
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取当前请求对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
if ("/api/oauth/login".equals(path) || !UrlFilter.hasAuthorize(path)){
//放行
return chain.filter(exchange);
}
//判断cookie上是否存在jti也就是短令牌
String jti = authService.getJtiFromCookie(request);
if (StringUtils.isEmpty(jti)){
//拒绝访问,请求跳转
// response.setStatusCode(HttpStatus.UNAUTHORIZED);
// return response.setComplete();
return this.toLoginPage(LOGIN_URL + "?FROM=" + request.getURI(), exchange);
}
//根据短令牌获取长令牌, 并且判断是否有长令牌
String token = authService.getTokenFromRedis(jti);
if (StringUtils.isEmpty(token)){
//拒绝访问,请求跳转
// response.setStatusCode(HttpStatus.UNAUTHORIZED);
// return response.setComplete();
return this.toLoginPage(LOGIN_URL + "?FROM=" + request.getURI(), exchange);
}
//........略
2.2 登录控制器获取参数
修改ydles-user-oauth的com.ydles.oauth.controller.AuthController
记录访问来源页,代码如下:
@RequestMapping("/toLogin")
public String toLogin(@RequestParam(value = "FROM", required = false, defaultValue = "") String from, Model model) throws Exception{
model.addAttribute("from",from);
return "login";
}
2.3修改登陆页面 login.html
167 <script th:inline="javascript">
var app = new Vue({
el:"#app",
data:{
username:"",
password:"",
msg:"",
from:[[${from}]]
},
methods:{
login:function () {
app.msg="正在登录";
axios.post("/api/oauth/login?username="+app.username+"&password="+app.password).then(function (response) {
if (response.data.flag){
app.msg="登录成功";
//跳转原地址
location.href=app.from;
} else{
app.msg="登录失败";
}
})
}
}
})
</script>
测试:访问:http://localhost:8001/api/wcart/list
总结:
1鉴权
5张表模型
oauth2
查询用户 权限字符串拼出来
后端微服务 controll层
@PreAuthorize("hasAnyAuthority('seckill_list','user_list')")
2购物车模块
2.1添加
2.2查询
3购物车页面渲染
购物车页面渲染微服务搭建
微服务认证
动态获取当前登录人
tokenDecode
页面配置
未登录--------》登陆页面
优化:未登录--------》登陆页面-----》跳回到购物车