谈一谈凑单页的那些优雅设计(下)
下方是一个使用例子:
public class CouponCustomCommand implements CommonCommand {
@Override
public boolean check(CartContext context) {
// 如果不是跨店满减或者品类券,不进行该命令处理
return Objects.equals(BenefitEnum.kdmj, context.getRequestContext().getCouponData().getBenefitEnum())
|| Objects.equals(BenefitEnum.plCoupon, context.getRequestContext().getCouponData().getBenefitEnum());
}
@Override
public boolean execute(CartContext context) {
CartData cartData = context.getData();
// 命令处理
return true;
}
最终的成品如下,各个命令执行顺序一目了然
▐ 多算法分流设计
上面讲完了底层的一些代码结构设计,接下来讲一讲针对业务层的代码设计。凑单分为很多个模块,推荐feeds流、榜单模块、秒杀模块、搜索模块。整体效果图如下:
针对这种不同模块使用不同的算法,我们最先能想到的设计就是每个模块都是一个单独的接口。各自组装各自的逻辑。但在实现过程中会发现,这其中有很多通用的逻辑,比如推荐feeds流和限时秒杀模块,使用的都是凑单引擎的,算法逻辑完全相同,只是多了获取秒杀key的逻辑,所以我会选择使用同一个接口,让该接口能够尽量的通用。这里我选用了策略工厂模式,核心类图如下:
【SeckillEngine】:秒杀引擎,用于秒杀模块业务逻辑封装
【RecommendEngine】:推荐引擎,用于推荐feeds流业务逻辑封装
【SearchEngine】:搜索引擎,用于搜索模块业务逻辑封装
【BaseDataEngine】:通用数据引擎,将引擎的通用层抽离出来,简化通用代码
【EngineFactory】:引擎工厂,用于模块路由到合适的引擎
该模式下,针对可能不断累加的模块,能完成快速的开发并投入使用,该模式也是比较通用,大家都会选择的模式,我这里就不再过多的业务阐述了,就讲讲我对策略模式的理解吧,一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。
*P.S. 实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。*
取巧的功能设计
▐ 凑单购物车部分
设计的背景
凑单是跨店优惠工具使用链路上的核心环节,用户对凑单有很高的诉求,但目前由于凑单页不支持实时凑单进度提示等问题,导致用户凑单体验较差,亟需优化凑单体验进而提升流量转化效率。但由于某些原因,我们不得不独立开发一套凑单购物车,同时加上凑单进度,其中商品数据源以及动态计算能力还是使用的淘宝购物车。
基本框架结构设计
凑单页购物车是需要展示满足某个跨店满减活动的商品(套购同理),我不能直接使用购物车的接口直接返回所有商品数据以及优惠明细。所以我这里将购物车的访问拆成了两个部分,第一步先通过购物车的data.query接口查询出该用户所有加购的商品(该商品数据只有id,数量,时间相关的信息)。在凑单页先进行一次活动商品过滤后,再将剩余的商品调用购物车的动态计算接口,完成整个凑单购物车内所有数据的展示。流程如下:
分页排序设计
大促期间,购物车大部分加购的品都是满足跨店满减活动的,如果每次都所有的商品参与动态计算并一次返回,性能会非常的差,所以这里就需要做到分页,页面展示如果涉及到了分页,难度系数将成倍的上升。首先我们来看凑单购物车的排序需求:
首次进入凑单页商品的顺序需要和购物车保持一致
同一个店铺的需要放在一起,按加购时间倒序排
店铺间按最新加购的某个商品的加购时间倒序排
如果是从某个店铺点进来的,该店铺需要在凑单页置顶,并主动勾选
如果过程中发现有新加入的品,该品需要置顶(不用将该店铺的其他品置顶)
如果过程中发现有失效的商品需要沉底(放到最后一页并沉底)
如果过程中发现有失效的品转成生效,需移上来
难点分析
排序并不是简单的按照时间维度排,增加的店铺维度,以及店铺置顶的能力
我们没有自己的数据源,每次查出来都得重新排序
第一次进入的排序和后续新加购的商品排序不同
支持分页
技术方案
首先能想到的就是找个地方存一下排序好的顺序,第一选择肯定是使用redis,但根据评估如果按用户维度去存储商品顺序,亿级的用户量 * 活动量需要耗费几百G的缓存容量,同时还需要维护该缓存的生命周期,相对还是比较麻烦。这种用户维度的缓存最好是通过客户端来进行缓存,我该如何利用前端来做该缓存,并让前端无感知呢?以下是我的接口设计:
itemList | [{"cartId": 11111,"quantity":50,"checked": 是否勾选}] | 当前所有前端的品 |
---|---|---|
sign | {} | 标志,前端不需要关注里面的东西,后端返回直接传,如果没有就不传 |
next | true | 是否继续加载 |
allChecked | true | 是否全选 |
handle | [{"cartId":1111,"quantity": 5,"checked":true,"type": modify}] | type=modify更新,checked勾选,nonChecked去掉勾选 |
其中sign对象服务端返回给前端,下一次请求需要将sign对象原封不动的传给服务端,sign中存储了分页信息,以及需要商品的排序,sign对象如下:
public class Sign {
/**
* 已加载到到权重
*/
private Integer weight;
/**
* 本次查询购物车商品最晚加购时间
*/
private Long endTime;
/**
* 上一次查询购物车所有排序好的商品
*/
private List activityItemList;
}
具体方案
首次进入按商品加购时间以及店铺维度做好初始排序,并标记weight(第一个200,第二个199,依次类推),并保存在sign对象的activityItemList中,取第一页数据,并将该页最小weight和所有商品的最晚加购时间endTime同步记录到sign中。并将sign返回给前端
前端在加载下一页时将上次请求后端返回的sign字段重新传给后端,后端根据sign中的weight大小判断,依次取下一页数据,同时将最新的最小weight写入sign,返回给前端。
期间如果发现有商品的加购时间大于sign中的endTime,则主动将其置顶,weight使用默认最大数字200。
由于在排序时无法知道商品是否失效以及能够勾选,所以需要在商品补全后(调用购物车的动态计算接口)重新对失效商品排序。
如果本页没有失效的品,不做处理
如果本页全是失效的品,不做处理(为了处理最后几页都是失效品的情况)
如果有下一页,将失效的品放到后面页沉底
如果当前页是最后一页,则直接沉底
方案时序图如下:
商品勾选设计
购物车的商品勾选后就会出现勾选商品的下单价格以及能享受的各类优惠,勾选情况主要分为:
勾选、反勾选、全选
全选情况下加载下一页
勾选的商品数量变化
效果图如下:
难点
勾选的品越多,动态计算的rt越长,当50个品一起勾选,页面接口返回时间将近1.5s
全选的情况下,下拉加载需要将新加载出来的品主动勾选上
尽可能的减少调用动态计算(比如加载非勾选的品,修改非勾选的商品数量)
设计方案
由于可能需要计算所有勾选的商品,所以前端需要将当前所有已加载的商品数据的勾选状态告知服务端
超过50个勾选商品时,不再调用动态计算接口,直接用本地价格计算总价,同时降级优惠明细和凑单进度
前端根据后端返回结果进行合并操作,减少不必要的计算开销
整体逻辑如下:
同时针对勾选处理,我将各类获取商品信息的动作封装进领域模型中(比如已勾选品,全部品,下一页品,操作的品,方便复用,⬆️代码设计已经讲过),获取各类商品的逻辑代码如下:
List activityItemList = cartData.getActivityItemList();
Map alreadyMap = requestContext.getAlreadyMap();,>
Map checkedItemMap = requestContext.getCheckedItemMap();,>
Map addNextItemMap = Optional.ofNullable(cartData.getAddNextItemList()),>
.map(o -> o.stream().collect(Collectors.toMap(CartItemData::getCartId, Function.identity())))
.orElse(Collections.emptyMap());
Map checkedHandleMap = context.getCheckedHandleMap();,>
Map nonCheckedHandleMap = context.getNonCheckedHandleMap();,>
Map modifyHandleMap = context.getModifyHandleMap();,>
勾选处理的逻辑代码如下:
boolean calculateAllChecked = isCalculateAllChecked(context, activityItemList);
activityItemList.forEach(v -> {
CartItemDetail cartItemDetail = CartItemDetail.build(v);
// 新加入的品,加入动态计算列表,并勾选
if (v.getLastAddTime() > context.getEndTime()) {
cartItemDetail.setChecked(true);
cartData.addCalculateItem(cartItemDetail);
// 勾选操作的品,加入动态计算列表,并勾选
} else if (checkedHandleMap.containsKey(v.getCartId())) {
cartItemDetail.setChecked(true);
cartData.addCalculateItem(cartItemDetail);
// 取消勾选的品,加入动态计算列表,并去勾选
} else if (nonCheckedHandleMap.containsKey(v.getCartId())) {
cartItemDetail.setChecked(false);
cartData.addCalculateItem(cartItemDetail);
// 勾选商品的数量修改,加入动态计算
} else if (modifyHandleMap.containsKey(v.getCartId())) {
cartItemDetail.setChecked(modifyHandleMap.get(v.getCartId()).getChecked());
cartData.addCalculateItem(cartItemDetail);
// 加载下一页,加入动态计算,如果是全选动作下,则将该页商品勾选
} else if (addNextItemMap.containsKey(v.getCartId())) {
if (context.isAllChecked()) {
cartItemDetail.setChecked(true);
}
cartData.addCalculateItem(cartItemDetail);
// 判断是否需要将之前所有勾选的商品加入动态计算
} else if (calculateAllChecked && checkedItemMap.containsKey(v.getCartId())) {
cartItemDetail.setChecked(true);
cartData.addCalculateItem(cartItemDetail);
}
});
P.S. 这里可能有人会发现,这么多的if-else就觉得它是烂代码。如果 if-else 分支判断不复杂、代码不多,这并没有任何问题,毕竟 if-else 分支判断几乎是所有编程语言都会提供的语法,存在即有理由。遵循 KISS 原则,怎么简单怎么来,就是最好的设计。非得用策略模式,搞出 n 多类,反倒是一种过度设计。
▐ 营销商品引擎key设计
设计的背景
跨店满减和品类券从引擎中筛选是通过couponTagId + couponValue来召回的,couponTagId是ump的活动id,couponValue则是记录了满减信息。随着需求的迭代,我们需要展示满足跨店满减并同时满足其他营销玩法(比如限时秒杀)的商品,这里我们已经能筛选出满足跨店满减的品,但如果筛选出当前正在生效的限时秒杀的品呢?
详细索引设计
导购的召回主要依赖倒排索引,而我们秒杀商品召回的关键是正在生效,所以我的设想是将时间写入key中,就有了如下设计:
字段示例:mkt_fn_t_60_08200000_60
index | 例子 | 描述 |
---|---|---|
0 | mkt | 营销工具平台 |
1 | fn | 前N |
2 | t | 前N分钟 |
3 | 60 | 开始前60分钟为预热时间 |
4 | 08200000 | 8月20号0点0分 |
5 | 60 | 开始后60分钟为结束时间 |
使用方可以遍历当前所有key,本地计算出当前正在生效的key再进行召回,具体细节这里就不做阐述了
最后的总结
▐ 设计的初衷是提高代码质量
我们经常会讲到一个词:初心。这词说的其实就是,你到底是为什么干这件事。不管走多远、产品经过多少迭代、转变多少次方向,“初心”一般都不会随便改。实际上,写代码也是如此。应用设计模式只是方法,最终的目的是提高代码的质量。具体点说就是,提高代码的可读性、可扩展性、可维护性等。所的设计都是围绕着这个初心来做的。
所以,在做代码设计的时候,一定要先问下自己,为什么要这样设计,为什么要应用这种设计模式,这样做是否能真正地提高代码质量,能提高代码质量的哪些方面。如果自己很难讲清楚,或者给出的理由都比较牵强,那基本上就可以断定这是一种过度设计,是为了设计而设计。
▐ 设计的过程是先有问题后有方案
在设计的过程中,我们要先去分析代码存在的痛点,比如可读性不好、可扩展性不好等等,然后再针对性地利用设计模式去改善,而不是看到某个场景之后,觉得跟之前在某本书中看到的某个设计模式的应用场景很相似,就套用上去,也不考虑到底合不合适,最后如果有人问起了,就再找几个不痛不痒、很不具体的伪需求来搪塞,比如提高了代码的扩展性、满足了开闭原则等等。
▐ 设计的应用场景是复杂代码
设计模式的主要作用就是解耦,也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类,让其满足高内聚低耦合等特性。而解耦的主要目的是应对代码的复杂性。设计模式就是为了解决复杂代码问题而产生的。
因此,对于复杂代码,比如项目代码量多、开发周期长、参与开发的人员多,我们前期要多花点时间在设计上,越是复杂代码,花在设计上的时间就要越多。不仅如此,每次提交的代码,都要保证代码质量,都要经过足够的思考和精心的设计,这样才能避免烂代码。
相反,如果你参与的只是一个简单的项目,代码量不多,开发人员也不多,那简单的问题用简单的解决方案就好,不要引入过于复杂的设计模式,将简单问题复杂化。
▐ 持续重构能有效避免过度设计
应用设计模式会提高代码的可扩展性,但同时也会带来代码可读性的降低,复杂度的升高。一旦我们引入某个复杂的设计,之后即便在很长一段时间都没有扩展的需求,我们也不可能将这个复杂的设计删除,后面一直要背负着这个复杂的设计前行。
为了避免错误的预判导致过度设计,我比较喜欢持续重构的开发方法。持续重构不仅仅是保证代码质量的重要手段,也是避免过度设计的有效方法。我上面的核心流程处理的框架代码,也是在一次又一次的重构中才写出来的。
作者:鸣翰(郑健) 大淘宝技术