一.搜索业务概要
用户有可能会根据分类搜索、品牌搜索,还有可能根据规格搜索,以及价格搜索和排序 操作。根据分类和品牌搜索的时候,可以直接根据指定域搜索,而规格搜索的域数据是 不确定的,价格是一个区间搜索,所以我们可以分为三段实现,先实现分类、品牌搜 素,再实现规格搜索,然后实现价格区间搜索。
搜索依据.png
1.根据关键字查询
需求分析
根据关键字模糊查询
实现思路
(1)Service层添加一个搜索方法,搜索参数用Map集合接收。
(2)通过ElasticSearchTemplate.queryForPage(SearchQuery query, Class<T> clazz, SearchResultMapper mapper)方法实现查询,返回一个结果操作类
(3)将需要返回给前端页面的数据信息封装到一个自定义Map
(4)Controller层添加一个接口方法,用于搜索
具体实现
该流程主要代码实现在queryForPage方法的编写
public Map search(Map<String, String> searchMap) {
if (searchMap != null) {
Map<String,Object> resultMap = new HashMap<>();
//构建布尔查询
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//布尔查询中需要用到模糊查询 match(字段类型,具体值即传入参数)
if(StringUtils.isNotEmpty(searchMap.get("keyword"))){
boolQuery.must(QueryBuilders.matchQuery("name", searchMap.get("keyword")).operator(Operator.AND));
}
//原生搜索实现类(第一个参数)
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
nativeSearchQueryBuilder.withQuery(boolQuery);
/**
* 第一个参数:条件构建对象
* 第二个参数:查询操作实体类
* 第三个参数:查询结果操作对象
*/
AggregatedPage<SkuInfo> resultInfo = elasticsearchTemplate.queryForPage(nativeSearchQueryBuilder.build(), SkuInfo.class, new SearchResultMapper() {
@Override
public <T> AggregatedPage<T> mapResults(SearchResponse searchResponse, Class<T> aClass, Pageable pageable) {
//拿到命中的所有数据
SearchHits hits = searchResponse.getHits();
long total = hits.getTotalHits();
Aggregations aggregations = searchResponse.getAggregations();
List<T> list = new ArrayList<>();
if(hits != null){
for (SearchHit hit : hits) {
//将单条数据封装成一个SkuInfo对象
SkuInfo skuInfo = JSON.parseObject(hit.getSourceAsString(), SkuInfo.class);
list.add((T) skuInfo);
}
}
// new出这个接口的实现类, 关注该返回对象需要哪些参数
return new AggregatedPageImpl<T>(list,pageable,total,aggregations);
}
});
//需要的数据包括:查询结果对象,总记录数,总页数
resultMap.put("list",resultInfo.getContent());
resultMap.put("totals",resultInfo.getTotalElements());
resultMap.put("totalPages",resultInfo.getTotalPages());
return resultMap;
}
return null;
}
controller层的代码如下:
/**
* 根据关键字全文检索
* @param searchMap
* @return
*/
@GetMapping
public Result search(@RequestParam Map<String,String> searchMap){
this.handlerSearchMap(searchMap);
Map map = searchService.search(searchMap);
return new Result(true, StatusCode.OK,"查询成功",map);
}
//对搜索入参带有特殊符号进行处理
private void handlerSearchMap(Map<String, String> searchMap) {
if(null != 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"));
}
}
}
}
二.页面静态化流程
1商品详情页实现页面静态化
需求分析
采用静态页面生成的方式生成商品详情页面,并部署到高性能的web服务器中进行访问
实现思路
(1)新建一个页面的微服务,使用canal监听数据库中tb_spu表,当监测到商品上架后,将该商品的spuId发送到中间件rabbitmq。
(2)新建一个页面静态化队列,将商品上架交换机与该队列绑定。
(3)静态页微服务设置监听器, 监听静态页生成队列, 根据商品id获取商品详细数据并使用thymeleaf的模板技术生成静态页面。
具体实现
1.通过Feign微服务调用拿到模板页面所需要的数据信息,准备一个getItemData方法
@Autowired
private SkuFeign skuFeign;
@Autowired
private SpuFeign spuFeign;
@Autowired
private CategoryFeign categoryFeign;
private Map<String, Object> getItemData(String spuId) {
Map<String,Object> resultMap = new HashMap<>();
//spu数据
Spu spu = spuFeign.findSpuById(spuId).getData();
resultMap.put("spu",spu);
//图片信息
if(spu != null){
if(StringUtils.isNotEmpty(spu.getImages())){
resultMap.put("imageList",spu.getImages().split(","));
}
}
//sku数据
List<Sku> skuList = skuFeign.findListBySpuId(spuId);
resultMap.put("skuList",skuList);
//商品分类信息
Category category1 = categoryFeign.findById(spu.getCategory1Id()).getData();
resultMap.put("category1",category1);
Category category2 = categoryFeign.findById(spu.getCategory2Id()).getData();
resultMap.put("category2",category2);
Category category3 = categoryFeign.findById(spu.getCategory3Id()).getData();
resultMap.put("category3",category3);
//规格信息
resultMap.put("specificationList", JSON.parseObject(spu.getSpecItems(),Map.class));
return resultMap;
}
2.html页面生成的方法
@Value("${pagepath}")
private String pagepath;
@Autowired
private TemplateEngine templateEngine;
@Override
public void generateHtml(String spuId) {
//1.拿数据
Context context = new Context();
Map<String, Object> itemData = this.getItemData(spuId);
context.setVariables(itemData);
//2.获取详情页面存储位置
File dir = new File(pagepath);
//3.文件存在
if(!dir.exists()){
dir.mkdirs();
}
//4.定义输出流,完成文件生成
File file = new File(dir + "/" + spuId + ".html");
Writer out= null;
try{
out = new PrintWriter(file);
//生成静态化页面
// 模板名称 context 输出流
templateEngine.process("item",context,out);
}catch(Exception e){
e.printStackTrace();
}finally {
try {
//5.关闭流
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.消息消费端实现方法调用
@Component
public class PageListener {
@Autowired
private PageService pageService;
@RabbitListener(queues = RabbitmqConfig.PAGE_CREATE_QUEUE)
public void receiceMessage(String spuId){
pageService.generateHtml(spuId);
}
}
三.认证开发+SpringSecurity+Oauth2+网关
1.认证分析
需求分析
实现单点登录和第三方账号登录。
1.单点登录:
用户访问的项目中,有多个微服务需要识别用户身份信息,每次都要登录的话就太麻烦了。让用户在一个系统中登录成功后,在其他任意受信任的系统都可以访问,这个功能叫做单点登录。
2.第三方账号登录
主要是基于用户在第三方平台已有的账号完成当前应用的登录功能,常用的有微信和QQ等。
解决方案
1.单点登录:通常将认证系统独立出来,将用户身份信息存储在单独的存储介质,考虑性能要求,存储在redis中,整个过程如图:
单点登录流程图.png用户请求首先经过网关,将请求转发到具体的微服务上比如订单服务,它会首先找到用户认证系统进行用户认证,需要获取用户信息并存储到redis中。
用户认证框架有Apache Shiro ,CAS ,SpringSecurity 等。
2.第三方登录技术方案
最主要需要解决的是认证协议通用标准化问题,因为要实现跨系统认证,各系统之间要遵循一定的接口协议。而OAUTH协议为用户资源授权提供了一个安全开放又简易的标准,具体流程如下图:
微信认证流程图.png
用户访问畅购登录页面,选择第三方登录比如微信登陆,畅购网站会向微信端请求认证。
微信端接着向用户返回一个授权页面,用户授权通过后,微信端会返回一个授权码给畅购网站,然后畅购网站向微信端申请一个令牌,微信端返回一个令牌给畅购网站,接着拿着令牌去获取用户信息,在用户信息端会检验令牌合法性,合法则会响应给畅购网站用户信息。
本项目采用的方案
使用 Spring security + Oauth2+ JWT完成用户认证及用户授权。
认证流程.png
1.用户在登录页面发送登录请求,经过网关自动放行和登录有关的资源。
2.在认证系统中会暴露一个接口接收用户的登录信息,通过restTemplate发起请求“/oauth/token”,请求springsecurity认证接口,验证登陆成功后,会返回一个令牌信息,将段令牌jti存入cookie中,同时jti和jwt令牌以KV形式存入redis。
3.用户再次访问时,网关首先会检测请求头cookie中是否有jti令牌,再根据jti从redis中查找jwt令牌,将jwt令牌携带到request头信息中,当访问到用户资源服务时,springsecurity框架通过公钥会自动校验jwt,判断用户有无访问资格。
具体实现流程
1.用户从登录页面发送登录请求,在认证系统user-oauth服务中会暴露一个接口接收用户的登录信息。
@PostMapping("/login")
@ResponseBody
public Result login(String username, String password, HttpServletResponse response){
if(StringUtils.isEmpty(username)){throw new RuntimeException("用户名为空");}
if(StringUtils.isEmpty(password)){throw new RuntimeException("密码为空");}
AuthToken authToken = authService.login(username, password, clientId, clientSecret);
//将jti存储到cookie
this.saveJti2Cookie(authToken.getJti(),response);
return new Result(true, StatusCode.OK,"登陆成功",authToken.getJti());
}
2.在service层中通过restTemplate发起请求“/oauth/token”,请求springsecurity认证接口UserDetailsServiceImpl,在loadUserByUsername方法中,用过Feign远程调用user服务中查询User信息进行校对,登陆通过(还可以指定当前用户的角色权限,在资源服务对应的接口方法上使用@PreAuthorize("hasAnyAuthority("xxxx")"),对权限进行控制),则会颁发一个令牌信息并将jti和jwt作为键值对存入redis中,jti存入cookie中。令牌包括6部分数据:
access_token:访问令牌,携带此令牌访问资源
token_type:有MAC Token与Bearer Token两种类型,两种的校验算法不同,Oauth2采 用 Bearer Token。
refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。
expires_in:过期时间,单位为秒。
scope:范围,与定义的客户端范围一致。
jti:当前token的唯一标识。
public AuthToken login(String username, String password, String clientId, String clientSecret) {
//申请令牌
ServiceInstance serviceInstance = loadBalancerClient.choose("USER-AUTH");
URI uri = serviceInstance.getUri();
String url = uri +"/oauth/token";
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("changgou","changgou"));
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
if(response.getRawStatusCode()!=400 && response.getRawStatusCode()!=401){
super.handleError(response);
}
}
});
HttpEntity<MultiValueMap<String,String>> requestEntity = new HttpEntity<>(body,headers);
ResponseEntity<Map> responseEntity = restTemplate.exchange(url, HttpMethod.POST, requestEntity, Map.class);
//得到令牌信息
Map map = responseEntity.getBody();
if(map ==null || map.get("access_token") == null || map.get("refresh_token") == null || map.get("jti") == null){
throw new RuntimeException("申请令牌失败");
}
//封装结果数据
AuthToken authToken = new AuthToken();
authToken.setAccessToken((String) map.get("access_token"));
authToken.setJti((String) map.get("jti"));
authToken.setRefreshToken((String) map.get("refresh_token"));
//jti jwt存放在redis
stringRedisTemplate.boundValueOps(authToken.getJti()).set(authToken.getAccessToken(),ttl, TimeUnit.SECONDS);
return authToken;
}
3.用户下一次访问服务页面时,网关层会检验请求参数cookie中是否含有jti,如果有再去redis中根绝jti拿到Jwt,将令牌信息携带到当前请求的请求头中,去访问用户资源服务。在资源服务中springsecurity框架通过公钥会自动校验jwt,判断用户有无访问资格。
四.购物车功能
1.添加购物车
需求分析
用户在商品详细页点击加入购物车,提交商品Sku编号和购买数量,添加到购物车。购物车展示页面如下:
购物车页面展示.png
表结构分析
用户登录后将商品添加到购物车,我们需要在订单数据库中添加一张购物车详情表,存储商品详细信息,分类信息,以及数量等等。
实现思路
1.添加购物车后,将用户名username,商品skuId,以及购物车详情表中对应的信息封装的OrderItem存入redis中,通过hash结构(key field value)储存数据,大Key和username相关,小Key和skuId有关,value存储购物车详情,一个hash代表一个用户购物车。
2.用定时任务持久化到mysql,维护一个跟日期相关的set,记录当前日期哪些用户操作了购物车,定时任务可以先去访问set,拿到对应的username。
具体实现
@GetMapping("/add")
public Result addCart(@RequestParam("skuId") String skuId, @RequestParam("num")Integer num){
String username = tokenDecode.getUserInfo().get("username");
cartService.addCart(skuId,num,username);
return new Result(true, StatusCode.OK,"添加购物车成功");
}
service层:
@Override
public void addCart(String skuId, Integer num, String username) {
//查询redis有无商品信息
OrderItem orderItem = (OrderItem) redisTemplate.boundHashOps(CART+username).get(skuId);
if (orderItem != null) {
//有 修改商品价格和数量
orderItem.setNum(orderItem.getNum() + num);
if(orderItem.getNum()<=0){
redisTemplate.boundHashOps(CART+username).delete(skuId);
return;
}
orderItem.setMoney(orderItem.getNum() * orderItem.getPrice());
orderItem.setPayMoney(orderItem.getNum() * orderItem.getPrice());
} else {
//无 把商品相关的数据添加到redis中
Sku sku = skuFeign.findById(skuId).getData();
Spu spu = spuFeign.findSpuById(sku.getSpuId()).getData();
orderItem = this.sku2OrderItem(sku, spu, num);
}
redisTemplate.boundHashOps(CART+username).put(skuId,orderItem);
}
private OrderItem sku2OrderItem(Sku sku, Spu spu, Integer num) {
OrderItem orderItem = new OrderItem();
orderItem.setSpuId(sku.getSpuId());
orderItem.setSkuId(sku.getId());
orderItem.setName(sku.getName());
orderItem.setPrice(sku.getPrice());
orderItem.setNum(num);
orderItem.setMoney(num * orderItem.getPrice()); //单价*数量
orderItem.setPayMoney(num * orderItem.getPrice()); //实付金额
orderItem.setImage(sku.getImage());
orderItem.setWeight(sku.getWeight() * num); //重量=单个重量*数量
//分类ID设置
orderItem.setCategoryId1(spu.getCategory1Id());
orderItem.setCategoryId2(spu.getCategory2Id());
orderItem.setCategoryId3(spu.getCategory3Id());
return orderItem;
}
2.查询购物车列表
实现思路
根据username查询hash,获取orderItemList,总计,金额......
具体实现
controller层
@GetMapping("/list")
public Map list(){
String username = tokenDecode.getUserInfo().get("username");
Map map = cartService.list(username);
return map;
}
service层
public Map list(String username) {
Map map = new HashMap<>();
//redis中hash结构 Key field value 这里拿到value
List<OrderItem> orderItemList = redisTemplate.boundHashOps(CART + username).values();
Integer totalNum = 0 ;
Integer totalMoney = 0 ;
for (OrderItem orderItem : orderItemList) {
totalNum += orderItem.getNum();
totalMoney += orderItem.getMoney();
}
map.put("orderItemList",orderItemList);
map.put("totalNum",totalNum);
map.put("totalMoney",totalMoney);
return map;
}
五.订单功能
1.分析
用户申请提交订单后,访问订单微服务。首先将本次订单信息用一个订单表维护,存入数据库。还要通过Feign远程调用商品微服务,完成sku商品的库存扣减操作,记录销量增加,调用user微服务实现user信息中的积分增加功能。
具体实现
1.点击提交订单,页面js出发异步请求,访问我们的订单渲染服务中增加新订单的接口。
提交订单异步请求.png
从web渲染页面服务Feign调用order微服务
远程调用order微服务.png
2.在订单服务中先解析请求头中的令牌信息,拿到当前请求发起的用户名信息,调用service层的具体业务逻辑。
订单服务controller层.png3.根据用户名去查询购物车列表数据(查redis),封装本次订单信息存入订单表,同时将订单商品的详情信息OrderItem也维护到数据库中。至此可以继续完成扣减库存和添加积分的操作了,同时因为我们已经把redis中的数据存入了数据库,删除redis中的内容。
/**
* 增加
* @param order
*/
@Override
public void add(Order order){
//1.获取购物车的数据
Map cartMap = cartService.list(order.getUsername());
Integer totalMoney = (Integer) cartMap.get("totalMoney");
Integer totalNum = (Integer) cartMap.get("totalNum");
List<OrderItem> orderItemList = (List<OrderItem>) cartMap.get("orderItemList");
//填充订单数据到tb_order表
order.setTotalNum(totalNum);
order.setTotalMoney(totalMoney);
order.setPayMoney(totalMoney);
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
order.setBuyerRate("0");//商品评价
order.setSourceType("1"); //来源,1:WEB
order.setOrderStatus("0"); //0:未完成,1:已完成,2:已退货
order.setPayStatus("0"); //0:未支付,1:已支付,2:支付失败
order.setConsignStatus("0"); //0:未发货,1:已发货,2:已收货
String orderId = idWorker.nextId() + "";
order.setId(orderId);
//插入order表
orderMapper.insertSelective(order);
//订单明细
for (OrderItem orderItem : orderItemList) {
orderItem.setId(idWorker.nextId()+"");
orderItem.setIsReturn("0");
orderItem.setOrderId(order.getId());
orderItemMapper.insertSelective(orderItem);
}
//扣减库存
skuFeign.decrCount(order.getUsername());
//添加积分
userFeign.addUserPoints(order.getPayMoney());
//已经把购物车信息维护到数据库了,可以删除redis中的购物车数据信息了
redisTemplate.delete("cart_"+order.getUsername());
}
五.分布式事务实现
1.Senta(Fescar原理)
以畅购项目为例,用户发送下单请求到订单微服务,需要远程调用商品服务实现扣减库存,因为两个数据库位于不同的服务中,所以需要用分布式事务控制。在下单方法上添加一个@GlobalTransactional注解,在分支事务上添加@Transactional注解。
事务管理器TM会向事务协调器TC注册一个唯一的全局事务Id,通过Feign向下传递给各个资源管理器RM,参与本次事务操作的所有RM就会向TC注册本次分布式事务分支(本地事务),TC将他们归并到本地全局事务的管理范围内,接着分支事务会提交事务,记录undolog日志并返回给TM事务提交结果,由TM向TC发送本次全局事务的提交或者回滚的决议。若发起全局提交,TC释放全局事务锁,异步通知各个RM移除uodolog日志。若发起全局回滚,TC同步通知各个RM基于undolog日志做数据库的反向变动后,移除undolog日志,并释放全局锁。
2.消息队列
在订单服务中:生成本次订单,向任务表中添加数据(username,orderId,point积分),设置定时任务(每隔七秒)扫描任务表刷新数据,发送任务数据给MQ
用户服务中:判断任务在redis中是否存在,若不存在,继续判断在DB中是否存在该任务,不存在,则执行业务逻辑。
@Transactional
将任务存入redis,设置过期时间;
修改用户积分记录;
增加积分日志表记录;
删除redis中的任务信息;
通知订单服务,通过MQ,订单服务增加历史任务表,并删除任务表数据。
网友评论