• “过家家”版的移动离线计费系统实现


    看到一道热烈讨论的“移动用户资费统计系统”编程面试题,本文给出我的做法。

    http://blog.csdn.net/zhangxiaoxiang/archive/2011/04/06/6304117.aspx

    为避免版权纠纷,我这里就不引用原文了。

     
    完整的代码见 https://github.com/chenshuo/recipes/tree/master/java/
    其中 billing/ 目录是 Java 代码,groovy/ 目录是计费规则。这份代码依赖 Groovy、JUnit、Joda date time (JSR-310) 等第三方库,见 run.sh 中 class path 的设置。

    首先,我要声明,我没有做过真正的电信系统,这里给出的是一个“过家家”的实现(toy project),用来满足面试题的需求,不是真正的生产环境的电信系统。

    电信计费系统是个有挑战的项目。经过一个以前做过类似系统的同事的讲解,我大致明白:电信计费系统分为“离线计费”和“在线计费”两大类。“离线计费”就是本题要求的那种程序,在月末根据用户当月的消费情况生成对账单;“在线计费”是当用户通话时,实时地从账户里扣钱,如果账户余额不足或欠费太多,就直接掐断通话。可见在线计费的可靠性与实时性要求要高得多。“离线计费”系统的挑战之一在于需求多变,电信公司可能随时增减套餐,推出各项组合优惠,实施积分奖励等等,计费逻辑复杂,更在意系统的“灵活性”。

    在那篇 blog 的回复中有人一针见血地指出了问题的关键。konyel:“再复杂的业务都是 内存数据库+规则引擎 进行配置方案的,把这样的业务写进类里就是个悲剧。”

    当然,原题要求三天做出来,就不能用重型武器了,我把它当成一道普通的招聘面试题来做,把题目中的需求用普通代码实现了就算完事儿。

    分析

    拿到题目,我的第一感觉是需求不清晰,很多地方需要请出题人阐明:

    • 题目中的“月”是自然月吗,几号算月初第一天?
    • 一个电话从 1 月 31 号 23:56 打到 2 月 1 号 00:05 该怎么计费?
    • 一次网络流量的钱不足一分钱,该怎么计费?
    • 几厘钱的 rounding 是向上、向下、还是四舍五入?
    • 新入网用户的赠品能带到下个月吗?
    • 根据题目描述,新入网的用户不能选择当月的套餐,只能用基准计费,对吗?
    • 用户可以改变身份吗?(应该可以,将来公司可以安排一个升级计划,累计消费满 5000 元的普通用户有权选择升级到 VIP。)
    • 用户可以退出吗?(应该可以,没有终身合同嘛。)

    但是限于实际情况,我只好根据自己的理解来做了。

    基本分析与假设

    根据当前的题目需求,我归纳如下:

    • 一个用户的消费只与自己有关,与别人无关。(这一点很难说,将来可能有家庭套餐。)
    • 一个用户的当月消费额只与其当月的活动,与以往的消费无关。(这一点也很难说,将来可能有积分计划,累计消费满一定额度可以享受优惠。)
    • 一个用户的套餐情况在月初就确定了,一个月之内不会改动;如果更改套餐,从下个月生效。(这一点估计不会变。)
    • 电话、短信、上网三种服务相互独立,可以单独计费再累加。(这一点我不知道将来会不会变。)
    • 电话、短信、上网都可以累积计费,也就是说打 10 分钟电话的钱和打两个 5 分钟电话的钱是一样的。(这不一定,或许将来有别的规则。)
    • 题目对“打电话”做了极度的简化,打电话的资费只与时长和单价相关,大大简化了开发。(在真实系统中,如果暂不考虑给别的运营商打电话,那么一次电话的计费的输入数据至少是:开始时间(可能有时段优惠)、主叫电话的归属地、主叫电话当前的接入点、被叫电话的归属地、被叫电话的当前接入点、通话时长、等等。还有考虑用户一边走路一边打,接入点变化的情况。)
    • 如果发生跨月的电话,那么拆成两个,放到两个月里分别计费。
    • “钱”用长整数表示,单位是“基点”,即 1/100 分。
    • 用户的类型有可能会变,用户的“类型”其实不是 type,而是“身份 role”,本身就可以看作“套餐”的一部分。

    如果需求变更,会破坏以上假设,在设计的时候要适当留有余地,但又不能做得过于通用,因为你不知道需求会怎么变。悖论:过于通用的代码常反而不能应对需求变更

    根据以上分析,用户当月消费额是个纯函数(输出完全由输入决定,函数自身无状态),本文只实现这个计算用户当月消费额度的纯函数,其他功能从略。这个函数的输入是:用户的类型,用户消费的原始记录(即原始话单),用户套餐的使用情况,是否新用户,各个套餐的计费规则。是的,我把套餐计费规则(自然包括各项服务的单价)也当成输入了,听上去我们要写一个高阶函数?待后文揭晓。

    这道题目的不难,任何学过程序设计的人(不需要学过面向对象)都应该能做出来——大不了就是用 if-else 判断各种情况嘛。我这里整理了几个流程图,用于厘清需求与代码逻辑:

    1. 首先,普通用户和 VIP 用户有完全不同的计费逻辑;

    2. 新用户有赠送的额度,扣除赠送额度之和,计费规则不变;

    overall

    3a. 对于普通用户,三种服务(电话、短信、上网)是分别计费的,每种服务都有可选的套餐,然后把三种服务的费用加起来;

    normal

    3b. 对于 VIP 用户,主要是根据套餐类型来计费,每个套餐有自己一套计费规则;

    vip

    简言之,根据题目当前的需求,普通用户是三种服务分别计费,再相加;VIP 用户是先按套餐 switch-case,各套餐各自计费。

    根据以上分析,任何学过基本的结构化程序设计的同学都应该能写出代码,实现需求。当然,搞不好有的人会把全部计费逻辑写进一个函数里 :(

    测试用例

    在开始编码之前,把测试用例整理一下。这是我拍脑门想到的一些测试用例,只覆盖了基本情况。

    普通用户测试用例:

    testcase1

    VIP 用户测试用例:

    testcase2

    我就是根据这些测试用例一步一把代码写出来的,边写边重构,最后就是现在的样子。对于这种类型的任务,TDD 或许是个不错的方法。

    设计

    我的设计很简单,基本没有面向对象,用了一点“基于对象”的东西。

    我的感觉,这种极度灵活的系统用面向对象往往适得其反,比方说,要不要细分用户类型或服务类型?要不要为不同的服务类型细分输入数据?如果将来通话计费要用到短信消费的数据,会不会推翻整个设计?

    干脆,我采取一种以数据为中心的做法,把可能用到的全部数据都交给计费逻辑,让它自己选择需要的数据来用。

    为了灵活性,我把计费逻辑用内嵌的 Groovy 脚本语言实现,以脚本语言为系统的配置文件。这样一来,系统部署之后,只需要升级配置文件(配置也不一定是文件,还可能是数据库的某个表,表里存放 Groovy 代码),不需要改 Java 代码,就能实现新的套餐或者调整已有套餐的价格。

    这体现了“代码就是数据、数据就是代码”的思想。

    这个思想在下一篇《分布式程序的自动回归测试》还有用到,我们可以用 Groovy 来编写一个个复杂的测试用例,而用 Java 来实现 test harness。

    实现

    整个实现(特别是计费逻辑)是根据 test cases 一步步重构出来的,我一开始只设计了几个简单的框架 class。

    用 Java 写了 Rule 基类,并用 Groovy 脚本语言实现它,然后写一个 RuleFactory 来动态地调入脚本语言并编译执行。

    有的 Rule 是有状态的,其状态可能是“计费单价”或者“免费额度”之类,所以 Rule 是 Cloneable。

    public abstract class Rule implements Cloneable {
    
        protected RuleFactory factory;
        protected Object state;
    
        public void setFactory(RuleFactory factory) {
            this.factory = factory;
        }
    
        public void setState(Object state) {
            this.state = state;
        }
    
        @Override
        protected Object clone() { ... }
    
        public abstract long getMoneyInPips(UserMonthUsage input);
    }

    Rules 之间有依赖关系,这个依赖关系直接写在 rule 本身的代码里,不用单独配置,因为 groovy 本身就是配置文件。

    比如 root.groovy 的实现用到了 vip_user.groovy 和 normal_user.groovy:

    public class RootRule extends Rule {
    
        long getMoneyInPips(UserMonthUsage input) {
            UserType type = (UserType)input.get(UserField.kUserType);
            Rule rule = factory.create(type.getRuleName());
            return rule.getMoneyInPips(input);
        }
    }

    而 normal_user.groovy 又用到了 normal_user_newjoiner.groovy 和 normal_user_{phone_call, short_message, internet}.groovy:

    public class NormalUserRule extends Rule {
    
        @Override
        long getMoneyInPips(UserMonthUsage input) {
            UserType type = (UserType)input.get(UserField.kUserType);
            assert type == UserType.kNormal;
            boolean isNew = input.getBoolean(UserField.kIsNewUser);
            if (isNew) {
                Rule newUser = factory.create(type.getRuleName()+"_newjoiner");
                return newUser.getMoneyInPips(input);
            } else {
                Rule phoneCall = factory.create(type.getRuleName()+"_phone_call", 0L);
                Rule shortMessage = factory.create(type.getRuleName()+"_short_message", 0L);
                Rule internet = factory.create(type.getRuleName()+"_internet", 0L);
                long total = phoneCall.getMoneyInPips(input) +
                             shortMessage.getMoneyInPips(input) +
                             internet.getMoneyInPips(input);
                return total;
            }
        }
    }
    normal_user_phone_call.groovy 用到了 package_phone_call.groovy:

    public class NormalUserPhoneCallRule extends Rule {

        public static final long kPipsPhoneCallPerMinute = 6000L;

        public static final long kPipsPhoneCallPerMinuteWithPackage = 5000L;

        public static final long kPipsPhoneCallPackage = 20*10000L;

        public static final long kNoChargeMinutesInPackage = 60L;

        @Override

        long getMoneyInPips(UserMonthUsage input) {

            UserType type = (UserType)input.get(UserField.kUserType);

            assert type == UserType.kNormal;

            List<Package> packages = input.getPackages();

            if (packages.contains(PackageType.kNormalUserPhoneCall)) {

                boolean isNew = input.getBoolean(UserField.kIsNewUser);

                assert !isNew;

                Rule phoneCall = factory.create("package_phone_call",

                     [ kPipsPhoneCallPackage, kNoChargeMinutesInPackage, kPipsPhoneCallPerMinuteWithPackage ]);

                return phoneCall.getMoneyInPips(input);

            } else {

                long noChargeMinutes = (Long)state;

                Rule phoneCall = factory.create("package_phone_call",

                     [ 0L, noChargeMinutes, kPipsPhoneCallPerMinute ]);

                return phoneCall.getMoneyInPips(input);

            }

        }

    }

    package_phone_call.groovy 用 base_phone_call.groovy 来计算价格,并扣除免费额度:

    public class PackagePhoneCallRule extends Rule {
    
        @Override
        long getMoneyInPips(UserMonthUsage input) {
            long[] parameters = (long[])state;
            long packagePips = parameters[0];
            long noChargeMinutes = parameters[1];
            long pipsPhoneCallPerMinute = parameters[2];
            return calc(input, packagePips, noChargeMinutes, pipsPhoneCallPerMinute);
        }
    
        long calc(UserMonthUsage input, long packagePips, long noChargeMinutes, long pipsPerMinute) {
            Rule phoneCall = factory.create("base_phone_call", pipsPerMinute);
            long fee = phoneCall.getMoneyInPips(input);
            fee -= noChargeMinutes*pipsPerMinute;
            if (fee < 0) {
                fee = 0;
            }
            return fee + packagePips;
        }
    }

    最后,base_phone_call.groovy 实现如下,遍历所有的电话呼叫,把费用加起来:

    public class PhoneCallRule extends Rule {
    
        @Override
        long getMoneyInPips(UserMonthUsage input) {
            final long pipsPerMinute = (Long)state;
    
            long result = 0;
            List<Slip> slips = (List<Slip>)input.get(UserField.kSlips);
            for (Slip slip : slips) {
                if (slip.type == SlipType.kPhoneCall) {
                    result += slip.data * pipsPerMinute;
                }
            }
    
            return result;
        }
    }

    整个计费算法的依赖关系如下,可见普通用户确实是按服务种类计费,而 VIP 用户按套餐计费:

    algos

    由于 Groovy 会编译成 Java bytecode,其运行速度和 Java 一样快,没有多少额外的开销(最多第一次 load in 的时候慢一些。)

    我的做法估计很多人不会接受,“一个简单的逻辑搞得这么复杂,不如把代码写一块儿”。我认为我的做法能更好地适应可能出现的新需求,而且不增加目前实现的难度。

    面向对象的灵活性?

    在这道题目里,我没有做太多对象建模,因为根据我的经验,“面向对象的灵活性”只对程序员有价值(还不一定),对用户没价值。不用面向对象也能达到相同的灵活性,而且有时候成本更低。

    Linus 当年炮轰 C++ 的理由之一就是“低效的抽象编程模型”——“可能在两年之后你会注意到有些抽象效果不怎么样,但是所有代码已经依赖于围绕它设计的‘漂亮’对象模型了,如果不重写应用程序,就无法改正。”

    举两个小例子:员工薪酬支付与星巴克咖啡。

    员工分类?

    面向对象的经典例子之一是员工薪酬支付系统,Uncle Bob 的《敏捷软件开发》就拿它举过例子。

    公司把员工分三类:工程师(领月薪)、销售(有提成)、合同工(按工作时数付费);初学面向对象常犯的错误是设计出如下的继承体系:

    employee1

    这个设计在应付某种新需求时是合理的,比如增加“经理”类型,经理除了月薪还有奖金可拿。

    employee2

    但是一旦出现一个新需求“销售人员可以被提拔为经理”,那么原来的设计就得推倒重来,因为一个对象一旦创建,其 type 就无法改变。

    人们目前公认的能适应这种需求的设计是用 strategy 模式:

    employee3

    如果是我,我不会这么设计,而会把计算薪酬的逻辑放到可嵌入的脚本语言中,这样修改系统的功能就不用重新编译并发布代码,只有改改配置文件就行了。

    幸运的是,“薪酬支付系统”很多人都做过,这个设计错误前人也犯过了,读读书就能避免重蹈覆辙。如果在一个全新的领域,不知道将来需求会怎么变,还信心满满用面向对象来做设计,真不怕落入 Linus 的诅咒“可能在两年之后你会注意到有些抽象效果不怎么样,但是所有代码已经依赖于围绕它设计的‘漂亮’对象模型了,如果不重写应用程序,就无法改正”吗?

    星巴克

    星巴克咖啡也是一个经典例子,《Head First 设计模式》以它为例讲 decorator 模式。

    星巴克卖很多种咖啡:美式咖啡、浓缩咖啡、拿铁、香草拿铁、榛果拿铁、卡布奇诺、摩卡咖啡。

    如果没有学过 decorator 模式,可能会设计出如下的继承体系:

    coffee

    今天我要讲的重点不是 decorator,其实用 decorator 也就是表面上好看,骨子里一样脆弱。

    星巴克拓展业务,开始卖茶,有三种:伯爵红茶、英国早茶、薄荷茶。

    tea

    以上对象模型运转得很好,直到有一天,星巴克决定进军香港市场,推出本地化产品。

    悲剧的时候到了,香港有一种“鸳鸯奶茶”,是咖啡和奶茶兑到一起,怎么设计对象模型?

    coffeetea

    用多重继承吗?可是 Java 不支持多重继承。那把 Coffee 和 Tea 改成 interface?那么整个项目的代码都要改(extends 改为 implements)。

    或者让 CoffeeTea 直接继承更高层的 Object class?那么它的逻辑又和 Coffee 和 Tea 有重复,将来 Coffee 升级岂不是要两头改?

    这些就留给面向对象爱好者去操心了。

    说到这里,星巴克还有一个小小的需求:星巴克的店员是煮咖啡的高手,但他们不懂编程,更不懂面向对象,如何设计系统让店员能自己添加咖啡种类?(比如,考虑牛奶质量有问题,星巴克打算在内地推出“豆奶咖啡”SoyCoffee,那么这个事情是不是要重新编译部署整个系统呢?)

    以上留作思考题吧。

  • 相关阅读:
    SpringBoot 线程池配置 定时任务,异步任务
    使用Calendar类对时间进行处理
    单例模式的几种实现方式
    一些簡單的入門算法
    OO第四单元博客
    OO第三单元博客
    OO第二单元博客
    OO第一单元博客
    BUAA_OO_2020_Unit4_Wandy
    BUAA_OO_UNIT3_2020
  • 原文地址:https://www.cnblogs.com/Solstice/p/2024791.html
Copyright © 2020-2023  润新知