陈硕的 Blog

吾尝终日而思矣,不如须臾之所学也。吾尝跂而望矣,不如登高之博见也。……君子生非异也,善假于物也。

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

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

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,那么这个事情是不是要重新编译部署整个系统呢?)

以上留作思考题吧。

posted on 2011-04-22 13:04 陈硕 阅读(...) 评论(...) 编辑 收藏

导航

统计

公告