关于后端:谈一谈凑单页的那些优雅设计

31次阅读

共计 18016 个字符,预计需要花费 46 分钟才能阅读完成。

本文将具体介绍作者如何在业务增长的状况下重构与优化零碎设计。

写在后面

凑单页存在的历史也算是比拟悠久了,我从去年接手到当初也经验不少的版本变更,最开始只是简略 feeds 流,为晋升用户体验,更好的帮忙用户去凑到称心的商品,咱们在重构整个凑单页的同时,还新增了榜单、限时秒杀模块,在双十一期间,加购率和转化率失去显著晋升。往年 618 还新增了凑单进度购物栏模块,反对了实时凑单进度展现以及结算下单的能力,晋升用户凑单体验。并且在凑单页实现业务迭代的同时,也一路积淀了些通用的能力撑持其余业务疾速迭代,本文我将具体介绍我是如何在业务增长的状况下重构与优化零碎设计的。

针对一些段时间内不会变动的,数量比拟无限的数据,为了缩小上游的压力,并进步本身零碎的性能,咱们经常会应用多级缓存来达到该目标。最常见的就是本地缓存 + redis 缓存来承接,如果本地缓存不存在,则取 redis 缓存的数据,并本地缓存起来,如果 redis 也不存在,则再从数据源获取,根本代码(获取榜单数据)如下:

return LOCAL_CACHE.get(key, () -> {String cache = rdbCommonTairCluster.get(key);
    if (StringUtils.isNotBlank(cache)) {return JSON.parseObject(cache, new TypeReference<List<ItemShow>>(){});
    }
    List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
    rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
    return itemShows;
});

逐步的就呈现了问题,线上偶现某些用户一段时间看不到榜单模块。榜单模块示意图如下:

这种问题排查起来最是辣手,须要肯定的我的项目教训,我第一次遇到这类问题也是费了老大劲。总结一下,如果某次缓存过期,上游服务刚好返回了空后果,就会导致本次申请被缓存了空后果。那该缓存的生命周期内,榜单模块都会隐没,但因为某些机器本地缓存还有旧数据,就会导致局部用户能看到,局部用户看不到的场景。
上面来看看我是如何优化的。外围次要关注:辨别上游返回的后果是真的空还是假的空,自身就为空的状况下,就该缓存空集合(非大促期间或者某些榜没有数据,数据自身就为空)

在 redis 中拉长 value 缓存的工夫,同时新增一个可更新工夫的缓存(比方 60s 过期),当判断更新工夫缓存过期了,就从新读取数据源,将 value 值从新赋值,这里须要留神,我会比照新老数据,如果新数据为空,老数据不为空,则只是更新工夫,不置换 value。value 随着本人的过期工夫完结,革新后的代码如下:

return LOCAL_CACHE.get(key, () -> {String updateKey = getUpdateKey(key);
    String value = rdbCommonTairCluster.get(key);
    List<ItemShow> cache = StringUtils.isBlank(cache) ? Collections.emptyList()
        : JSON.parseObject(value, new TypeReference<List<ItemShow>>(){});
    if (rdbCommonTairCluster.exists(updateKey)) {return cache;}
    rdbCommonTairCluster.set(updateKey, currentTime, cacheUpdateSecond);
    List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
    if (CollectionUtils.isNotEmpty(itemShows)) {rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
    }
    return itemShows;
});

为了使这段代码可能复用,我将该多级缓存形象进去一个独立对象,代码如下:

public class GatherCache<V> {
    @Setter
    private Cache<String, List<V>> localCache;
    @Setter
    private CenterCache centerCache;

    public List<V> get(boolean needCache, String key, @NonNull Callable<List<V>> loader, Function<String, List<V>> parse) {
        try {
            // 是否须要是否缓存
            return needCache ? localCache.get(key, () -> getCenter(key, loader, parse)) : loader.call();} catch (Throwable e) {GatherContext.error(this.getClass().getSimpleName() + "get catch exception", e);
        }
        return Collections.emptyList();}

    private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {String updateKey = getUpdateKey(key);
        String value = centerCache.get(key);
        boolean blankValue = StringUtils.isBlank(value);
        List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
        if (centerCache.exists(updateKey)) {return cache;}
        centerCache.set(updateKey, currentTime, cacheUpdateSecond);
        List<V> newCache = loader.call();
        if (CollectionUtils.isNotEmpty(newCache)) {centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
        }
        return newCache;
    }
}

将从数据源获取数据的代码交与内部实现,应用 Callable 的模式,同时通过泛型束缚数据源类型,这里还有一点瑕疵还没失去解决,就是通过 fastJson 转换 String 到对象时,没法应用泛型间接转,我这里就采纳了内部化的解决,就是跟获取数据源形式一样,由内部来决定如何解析从 redis 中获取到的字符串 value。调用形式如下:

List<ItemShow> itemShowList = gatherCache.get(true, rankingRequest.getKey(),
    () -> getRankingItemOriginal(rankingRequest, context.getRequestContext()),
    v -> JSON.parseObject(v, new TypeReference<List<ItemShow>>() {}));

同时我还采纳的建造者模式,不便 gatherCache 类疾速生成,代码如下:

@PostConstruct
public void init() {this.gatherCache = GatherCacheBuilder.newBuilder()
        .localMaximumSize(500)
        .localExpireAfterWriteSeconds(30)
        .build(rdbCenterCache);
}

以上的代码绝对比拟完满了,却疏忽了一个细节点,如果多台机器的本地缓存同时生效,恰好 redis 的可更新工夫生效了,这时就会有多个申请并发打到上游(因为凑单有本地缓存兜底,并发打到上游的个数十分无限,根本能够疏忽)。但遇到问题就须要去解决,谋求完满代码。我做了如下的革新:

private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {String updateKey = getUpdateKey(key);
    String value = centerCache.get(key);
    boolean blankValue = StringUtils.isBlank(value);
    List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
    // 如果抢不到锁,并且 value 没有过期
    if (!centerCache.setNx(updateKey, currentTime) && !blankValue) {return cache;}
    centerCache.set(updateKey, currentTime, cacheUpdateSecond);
    // 应用异步线程去更新 value
    CompletableFuture.runAsync(() -> updateCache(key, loader));
    return cache;
}

private void updateCache(String key, Callable<List<V>> loader) {List<V> newCache = loader.call();
    if (CollectionUtils.isNotEmpty(newCache)) {centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
    }
}

本计划应用分布式锁 + 异步线程的形式来解决更新。只会有一个申请抢到更新锁,并发状况下,其余申请在可更新时间段内还是返回老数据。因为 redis 封装的办法中并没有抢锁后同时设置过期工夫的原子性操作,我这里用了先抢锁,再赋值过期工夫的形式,在极其场景下可能会呈现死锁的状况,就是刚好抢到了锁,而后机器出现异常宕机,导致过期工夫没有赋值下来,就会呈现永远无奈更新的状况。这种状况尽管极其,但还是要解,以下是我能想到的两个计划,我抉择了第二种形式:

  • 通过应用 lua 脚本将两步操作合成一个原子性操作
  • 利用 value 的过期工夫来解该死锁问题

P.S. 一些从 ThreadLocal 中拿的通用信息,在应用异步线程解决的时候是拿不到的,得从新赋值

凑单核心解决流程设计

凑单自身是没有本人的数据源的,都是从其余服务读取,做各种加工后展现。这样的代码是最好写的,也是最难写的。就好比最简略的组装商品信息,个别的代码都会这么写:

// 获取举荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {ItemShow itemShow = new ItemShow();
    // 设置商品根本信息
    itemShow.setItemId(NumberUtils.createLong(v.get("itemId")));
    itemShow.setItemImg(v.get("pic"));
    // 获取利益点
    GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
    AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
        .extract(guideInfoDTO, "gather", "item");
    List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
    itemShow.setItemBenefits(iconTexts);
    // 预售解决
    String preSalePrice = getPreSale(v);
    if (Objects.nonNull(preSalePrice)) {itemShow.setItemPrice(preSalePrice);
    }
    // ......
    return itemShow;
}).collect(Collectors.toList());

能疾速写好代码并投入使用,但代码有点横七竖八,对代码要求比拟高的开发者可能会做如下的改良

// 获取举荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {ItemShow itemShow = new ItemShow();
    // 设置商品根本信息
    buildCommon(itemShow, v);
    // 获取利益点
    buildAtmosphere(itemShow, v);
    // 预售解决
    buildPreSale(itemShow, v);
    // ......
    return itemShow;
}).collect(Collectors.toList());

个别这样的代码算是比拟优质的解决了,但这仅仅是针对单个业务,如果遇到多个业务须要应用该组装后,最简略但就是须要判断是来自 feeds 流模块的申请商品组装不须要利益点,来自前 N 秒杀模块的不须要解决预售价格。

// 获取举荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {ItemShow itemShow = new ItemShow();
    // 设置商品根本信息
    buildCommon(itemShow, v);
    // 获取利益点
    if (!Objects.equals(soluction, FiltrateFeedsSolution.class)) {buildAtmosphere(itemShow, v);
    }
    // 预售解决
    if (!Objects.equals(source, "seckill")) {buildPreSale(itemShow, v);
    }
    // ......
    return itemShow;
}).collect(Collectors.toList());

该计划能够清晰看到整个主流程的分流构造,但会使得主流程不够整洁,升高可读性,很多人都习惯把该判断写到各自的办法里如下。(当然也有人每个模块都独自写一个主流程,以上只是为了文章易懂简化了代码,理论主流程较长,并且大部分都是须要解决的,如果每个模块都独自本人创立主流程,会带来很多反复代码,不举荐)

private void buildAtmosphere(ItemShow itemShow, Map<String, String> map) {if (Objects.equals(soluction, FiltrateFeedsSolution.class)) {return;}
    GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
    AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
        .extract(guideInfoDTO, "gather", "item");
    List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
    itemShow.setItemBenefits(iconTexts);
}

纵观整个凑单的业务逻辑,不论是参数组装,商品组装,购物车组装,榜单组装,都须要信息组装的能力,并且他们都有如下的个性:

  • 每个或每几个字段的组装都不影响其余字段,就算出现异常也不应该影响其余字段的拼装
  • 在消费者链路下,性能的要求会比拟高,能不必拜访的组装逻辑就不去拜访,能不调用上游,就不去调用上游
  • 如果在组装的过程中发现有写字段是必须要的,但没有补全,则提前终止流程
  • 每个办法的解决须要记录耗时,开发能分明的晓得耗时在哪些地方,不便找到须要优化的代码

以上的点都很小,不做或者独自做都不影响整体,凑单页含有这么多组装逻辑的状况下,如果以上逻辑全副都写一遍,将产生大量的冗余代码。但对本人代码要求比拟高的人来说,这些点不加上去,心里总感觉有根刺在。缓缓的就会因为本人之前设计思考的不全,打各种补丁,就好比想晓得某个办法的耗时,就会写如下代码:

long startTime = System.currentTimeMillis();
// 次要解决
buildAtmosphere(itemShow, summaryMap);
long endTime = System.currentTimeMillis();
return endTime - startTime;

凑单各域都是做此类型的组装,有商品组装,参数组装,榜单组装,购物车组装。针对凑单业务的个性,寻遍各类设计模式,最终抉择了责任链 + 命令模式。
在 GoF 的《设计模式》中,责任链模式是这么定义的:

将申请的发送和接管解耦,让多个接管对象都有机会解决这个申请。将这些接管对象串成一条链,并沿着这条链传递这个申请,
直到链上的某个接管对象可能解决它为止。

首先,咱们来看,职责链模式如何应答代码的复杂性。
将大块代码逻辑拆分成函数,将大类拆分成小类,是应答代码复杂性的罕用办法。利用职责链模式,咱们把各个商品组装持续拆分进去,设计成独立的类,进一步简化了商品组装类,让类的代码不会过多,过简单。
其次,咱们再来看,职责链模式如何让代码满足开闭准则,进步代码的扩展性。
当咱们要扩大新的组装逻辑的时候,比方,咱们还须要减少价格暗藏过滤,依照非职责链模式的代码实现形式,咱们须要批改主类的代码,违反开闭准则。不过,这样的批改还算比拟集中,也是能够承受的。而职责链模式的实现形式更加优雅,只须要新增加一个 Command 类 (理论解决类采纳了命令模式做一些业务定制的扩大),并且通过 addCommand() 函数将它增加到 Chain 中即可,其余代码齐全不须要批改。
接下来就是应用该模式,对凑单全域进行革新降级,外围架构图如下

各个域须要满足如下条件:

  • 反对单个解决和批量解决
  • 反对提前阻断
  • 反对前置判断是否须要解决

解决类类图如下

  • 【ChainBaseHandler】:外围解决类
  • 【CartHandler】:加购域解决类
  • 【ItemSupplementHandler】:商品域解决类
  • 【RankingHandler】:榜单域解决类
  • 【RequestHanlder】:参数域解决类

咱们首先来看外围解决层:

public class ChainBaseHandler<T extends Context> {
    /**
     * 工作执行
     * @param context
     */
    public void execute(T context) {List<String> executeCommands = Lists.newArrayList();
        for (Command<T> c : commands) {
            try {
                // 前置校验
                if (!c.check(context)) {continue;}
                // 执行
                boolean isContinue = timeConsuming(() -> execute(context, c), c, executeCommands);
                if (!isContinue) {break;}
            } catch (Throwable e) {
                // 打印异样信息
                GatherContext.debug("exception", c.getClass().getSimpleName());
                GatherContext.error(c.getClass().getSimpleName() + "catch exception", e);
            }
        }
        // 打印个命令工作耗时 
        GatherContext.debug(this.getClass().getSimpleName() + "-execute", executeCommands);
    }
}

两头的 timeConsuming 办法用来计算耗时,耗时须要前后包裹执行办法

private boolean timeConsuming(Supplier<Boolean> supplier, Command<T> c, List<String> executeCommands) {long startTime = System.currentTimeMillis();
    boolean isContinue = supplier.get();
    long endTime = System.currentTimeMillis();
    long timeConsuming = endTime - startTime;
    executeCommands.add(c.getClass().getSimpleName() + ":" + timeConsuming);
    return isContinue;
}

具体执行如下:

/**
 * 执行每个命令
 * @return 是否继续执行
 */
private <D extends ContextData> boolean execute(Context context, Command<T> c) {if (context instanceof MuchContext) {return execute((MuchContext<D>) context, c);
    }
    if (context instanceof OneContext) {return execute((OneContext<D>) context, c);
    }
    return true;
}

/**
 * 单数据执行
 * @return 是否继续执行
 */
private <D extends ContextData> boolean execute(OneContext<D> oneContext, Command<T> c) {if (Objects.isNull(oneContext.getData())) {return false;}
    if (c instanceof CommonCommand) {return ((CommonCommand<OneContext<D>>) c).execute(oneContext);
    }
    return true;
}

/**
 * 批量数据执行
 * @return 是否继续执行
 */
private <D extends ContextData> boolean execute(MuchContext<D> muchContext, Command<T> c) {if (CollectionUtils.isEmpty(muchContext.getData())) {return false;}
    if (c instanceof SingleCommand) {muchContext.getData().forEach(data -> ((SingleCommand<MuchContext<D>, D>) c).execute(data, muchContext));
        return true;
    }
    if (c instanceof CommonCommand) {return ((CommonCommand<MuchContext<D>>) c).execute(muchContext);
    }
    return true;

入参都是对立的 context,其中的 data 为须要拼装的数据。类图如下

MuchContext(多值的数据拼装上下文),data 是个汇合

public class MuchContext<D extends ContextData> implements Context {

    protected List<D> data;

    public void addData(D d) {if (CollectionUtils.isEmpty(this.data)) {this.data = Lists.newArrayList();
        }
        this.data.add(d);
    }

    public List<D> getData() {if (Objects.isNull(this.data)) {this.data = Lists.newArrayList();
        }
        return this.data;
    }
}

OneContext(单值的数据拼装上下文),data 是个对象

public class OneContext <D extends ContextData> implements Context {protected D data;}

各域可依据本人须要实现,各个实现的 context 也应用了畛域模型的思维,将对入参的一些操作封装在此,简化各个命令处理器的获取老本。举个例子,比方入参是一系列操作汇合 List<HandleItem> handle。但理论应用是须要辨别各个操作,那咱们就须要在 context 中做好初始化,不便获取:

private void buildHandle() {
    // 勾选操作汇合 
    this.checkedHandleMap = Maps.newHashMap();
    // 去勾选操作汇合
    this.nonCheckedHandleMap = Maps.newHashMap();
    // 批改操作汇合
    this.modifyHandleMap = Maps.newHashMap();
    Optional.ofNullable(requestContext.getExtParam())
        .map(CartExtParam::getHandle)
        .ifPresent(o -> o.forEach(v -> {if (Objects.equals(v.getType(), CartHandleType.checked)) {checkedHandleMap.put(v.getCartId(), v);
            }
            if (Objects.equals(v.getType(), CartHandleType.nonChecked)) {nonCheckedHandleMap.put(v.getCartId(), v);
            }
            if (Objects.equals(v.getType(), CartHandleType.modify)) {modifyHandleMap.put(v.getCartId(), v);
            }
        }));
}

上面来看各个命令处理器,类图如下:

命令处理器次要分为 SingleCommand 和 CommonCommand,CommonCommand 为一般类型,行将 data 交与各个命令自行处理,而 SingleCommand 则是针对批量解决的状况下,将 data 汇合提前拆好。两个外围区别就在于一个在框架层执行 data 的循环,一个是在各个命令层解决循环。次要作用在于:

  • SingleCommand 缩小反复循环代码
  • CommonCommand 针对上游须要批量解决的可进步性能

下方是一个应用例子:

public class CouponCustomCommand implements CommonCommand<CartContext> {
    @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<CartItemData> 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<CartItemData> activityItemList = cartData.getActivityItemList();
Map<Long, CartItem> alreadyMap = requestContext.getAlreadyMap();
Map<Long, CartItem> checkedItemMap = requestContext.getCheckedItemMap();
Map<Long, CartItemData> addNextItemMap = Optional.ofNullable(cartData.getAddNextItemList())
    .map(o -> o.stream().collect(Collectors.toMap(CartItemData::getCartId, Function.identity())))
    .orElse(Collections.emptyMap());
Map<Long, HandleItem> checkedHandleMap = context.getCheckedHandleMap();
Map<Long, HandleItem> nonCheckedHandleMap = context.getNonCheckedHandleMap();
Map<Long, HandleItem> 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 再进行召回,具体细节这里就不做论述了

最初的总结

设计的初衷是进步代码品质

咱们常常会讲到一个词:初心。这词说的其实就是,你到底是为什么干这件事。不论走多远、产品通过多少迭代、转变多少次方向,“初心”个别都不会轻易改。实际上,写代码也是如此。利用设计模式只是办法,最终的目标是进步代码的品质。具体点说就是,进步代码的可读性、可扩展性、可维护性等。所有的设计都是围绕着这个初心来做的。
所以,在做代码设计的时候,肯定要先问下本人,为什么要这样设计,为什么要利用这种设计模式,这样做是否能真正地进步代码品质,能进步代码品质的哪些方面。如果本人很难讲清楚,或者给出的理由都比拟牵强,那基本上就能够判定这是一种适度设计,是为了设计而设计。

设计的过程是先有问题后有计划

在设计的过程中,咱们要先去剖析代码存在的痛点,比方可读性不好、可扩展性不好等等,而后再针对性地利用设计模式去改善,而不是看到某个场景之后,感觉跟之前在某本书中看到的某个设计模式的利用场景很类似,就套用下来,也不思考到底合不适合,最初如果有人问起了,就再找几个不痛不痒、很不具体的伪需要来搪塞,比方进步了代码的扩展性、满足了开闭准则等等。

设计的利用场景是简单代码

设计模式的次要作用就是解耦,也就是利用更好的代码构造将一大坨代码拆分成职责更繁多的小类,让其满足高内聚低耦合等个性。而解耦的次要目标是应答代码的复杂性。设计模式就是为了解决简单代码问题而产生的。
因而,对于简单代码,比方我的项目代码量多、开发周期长、参加开发的人员多,咱们后期要多花点工夫在设计上,越是简单代码,花在设计上的工夫就要越多。不仅如此,每次提交的代码,都要保障代码品质,都要通过足够的思考和精心的设计,这样能力防止烂代码。
相同,如果你参加的只是一个简略的我的项目,代码量不多,开发人员也不多,那简略的问题用简略的解决方案就好,不要引入过于简单的设计模式,将简略问题复杂化。

继续重构能无效防止适度设计

利用设计模式会进步代码的可扩展性,但同时也会带来代码可读性的升高,复杂度的升高。一旦咱们引入某个简单的设计,之后即使在很长一段时间都没有扩大的需要,咱们也不可能将这个简单的设计删除,前面始终要背负着这个简单的设计前行。
为了防止谬误的预判导致适度设计,我比拟喜爱继续重构的开发方法。继续重构不仅仅是保障代码品质的重要伎俩,也是防止适度设计的无效办法。我下面的外围流程解决的框架代码,也是在一次又一次的重构中才写进去的。

正文完
 0