[AOP] 最简单的AOP概念理解

最近接触到了面向切面编程,看来很多的文档,算是有一点点了解了,趁自己还有点印象,先把它们给写出来

什么是AOP

  AOP(Aspect-Oriented Programming), 即 面向切面编程。

  AOP 中的基本单元是 Aspect(切面)

AOP-术语

  Aspect (切面)

    包含了横切逻辑的定义,也包括了连接点的定义

    Spring AOP就是负责实施切面的框架, 它将切面所定义的横切逻辑织入到切面所指定的连接点中.

    Aspect(切面)也就是封装了Advice(增强)与 Pointcut(切点 )

    AOP的工作重心在于如何将增强织入目标对象的连接点上, 这里包含两个工作:

    1. 如何通过 pointcut 和 advice 定位到特定的 joinpoint 上

    2. 如何在 advice 中编写切面代码.

  advice(增强)

    由 aspect 添加到特定的 join point(即满足 point cut 规则的 join point) 的一段代码.

  连接点(join point)

    程序运行中的一些时间点, 例如一个方法的执行, 或者是一个异常的处理.

    在 Spring AOP 中, join point 总是方法的执行点, 即只有方法连接点.

  切点(point cut)

    Advice 是和特定的 point cut 关联的, 并且在 point cut 相匹配的 join point 中执行.

    在 Spring 中, 所有的方法都可以认为是 joinpoint, 但是我们并不希望在所有的方法上都添加 Advice,

    而 pointcut 的作用就是提供一组规则(使用 AspectJ pointcut expression language 来描述) 来匹配joinpoint, 给满足规则的 joinpoint 添加 Advice.

  关于join point 和 point cut 的区别

    所有的方法执行都是 join point. 而 point cut 是一个描述信息, 它修饰的是 join point, 通过 point cut, 我们就可以确定哪些 join point 可以被织入 Advice.

    因此 join point 和 point cut 本质上就是两个不同纬度上的东西.

    advice 是在 join point 上执行的, 而 point cut 规定了哪些 join point 可以执行哪些 advice

  introduction

    为一个类型添加额外的方法或字段. Spring AOP 允许我们为 目标对象 引入新的接口(和对应的实现).

     例如我们可以使用 introduction 来为一个 bean 实现 IsModified 接口, 并以此来简化 caching 的实现.

  目标对象(Target)

    织入 advice 的目标对象. 目标对象也被称为 advised object.

    因为 Spring AOP 使用运行时代理的方式来实现 aspect, 因此 adviced object 总是一个代理对象(proxied object)
    注意, adviced object 指的不是原来的类, 而是织入 advice 后所产生的代理类.

  AOP proxy

    一个类被 AOP 织入 advice, 就会产生一个结果类, 它是融合了原类和增强逻辑的代理类.
    在 Spring AOP 中, 一个 AOP 代理是一个 JDK 动态代理对象或 CGLIB 代理对象.

  织入(Weaving)

    将 aspect 和其他对象连接起来, 并创建 adviced object 的过程.

    根据不同的实现技术, AOP织入有三种方式:

    • 编译器织入, 这要求有特殊的Java编译器.

    • 类装载期织入, 这需要有特殊的类装载器.

    • 动态代理织入, 在运行期为目标类添加增强(Advice)生成子类的方式.
      Spring 采用动态代理织入, 而AspectJ采用编译器织入和类装载期织入.

  advice 的类型

    • before advice, 在 join point 前被执行的 advice. 虽然 before advice 是在 join point 前被执行, 但是它并不能够阻止 join point 的执行, 除非发生了异常(即我们在 before advice 代码中, 不能人为地决定是否继续执行 join point 中的代码)

    • after return advice, 在一个 join point 正常返回后执行的 advice

    • after throwing advice, 当一个 join point 抛出异常后执行的 advice

    • after(final) advice, 无论一个 join point 是正常退出还是发生了异常, 都会被执行的 advice.

    • around advice, 在 join point 前和 joint point 退出后都执行的 advice. 这个是最常用的 advice.

  关于 AOP Proxy

    Spring AOP 默认使用标准的 JDK 动态代理(dynamic proxy)技术来实现 AOP 代理, 通过它, 我们可以为任意的接口实现代理.

    如果需要为一个类实现代理, 那么可以使用 CGLIB 代理. 当一个业务逻辑对象没有实现接口时, 那么Spring AOP 就默认使用 CGLIB 来作为 AOP 代理了.

     即如果我们需要为一个方法织入 advice, 但是这个方法不是一个接口所提供的方法, 则此时 Spring AOP 会使用 CGLIB 来实现动态代理.

    鉴于此, Spring AOP 建议基于接口编程, 对接口进行 AOP 而不是类.


个人理解及总结:

    Aspect是一个面向编程的实现,而它包含了pintcunt(切点)和advice(增强的东西),也就是说,要增强的操作+要增强的位置=切面

    而join point 是所有可以增强的点,可以理解为所有的方法。而point cut 则是想要切入的那个方法,也就是切入点。

    在通过上述操作之后,就产生了一个目标对象(Target),这个目标对象实际上是一个代理对象(proxied object),是 advice织入之后产生的。

    adivce中的前置通知+后置通知=环绕通知

  AOP和OOP的不同:

    OOP(面向对象编程)是通过封装把不同的功能分散到了不同的对象中去,把类细分,降低了代码的复杂度,变得更加易用和重用,

    但是在分散代码的时候,增加了代码的重复性,有时候完全是相同的代码,但是面向对象的设计方式就是将同样的代码放到不同的类中去。

    而面向对象的设计使得类和类之间无法联系,也无法将这些代码统一起来。造成了代码的冗余。

    然而,如果把这段代码写在一个独立类中的独立的方法中,然后在两个类中调用,那么这两个类就产生了耦合,

    并且这段代码的改变会影响两个类。

    为了使得这个类可以更加的灵活,也就是在运行的时候可以动态的切入到类的指定方法中。这个指定的位置就是面向切面的编程。

    AOP(面向切面编程)针对业务处理过程中的切面进行提取,它所面对的是处理过程中的某个步骤或阶段,

    以获得逻辑过程中各部分之间低耦合性的隔离效果。

    通过对这些行为的分离,将它们独立到非指导业务逻辑的方法中,进而改变这些行为的时候不影响业务逻辑的代码。

    只有等到需要的时候再切入到对象中去,从而改变原有的行为。否则原对象不会发生变化。

    AOP和OOP相比,AOP更像一种“动”的语言,而OOP则是“静”的语言

    OOP针对业务处理过程的实体及其属性和行为进行抽象封装,以获得更加清晰高效的逻辑单元划分。

    AOP是针对业务处理过程中的切面进行提取,它所面对的是处理过程中的某个步骤或阶段,以获得逻辑过程中各部分之间低耦合性的隔离效果。

  AOP的用处范围:

    主要功能:日志记录,性能统计,安全控制,事务处理,异常处理等等

    主要意图:将日志记录,性能统计,安全控制,事务处理,异常处理等代码从业务逻辑代码中划分出来,


现在使用代码来实现这些操作:

  假设这样的一个情形:

    现在有超市(SuperMarket)和银行(Bank)两个实体类,在这两个类里面有消费、存款、取款等涉及到金钱的操作,所以我们可以把它们封装起来,得到一个Money的接口,这个接口里面有消费这个方法。

    而现在的支付方式又有很多种,如现金、银行卡、在线支付等类型。所以我们之前定义的接口就应该分成不同消费方式的消费方法。

    那么我们想要在消费的时候记录金额,打印出消费信息来,应该如何做?

    1、我们可以在超市(SuperMarket)和银行(Bank)中添加打印记录的方法。但是打印记录并不是一个对象属性,显得不合理。并且代码冗余。

    2、在消费方法执行的时候打印出消费的记录。可是这样的话如果有那天不需要打印了,则需要更改源码。并且这种情况下造成了SuperMarket和Bank中都有相同的代码块,代码冗余

    3、在Money接口中封装打印记录的方法,使SuperMarket和Bank去继承它们。然而这种情况下如果我修改了Money接口中的方法,那么就会使得这两个类的源码都要进行修改。这岂不是很乱。

    4、使用代理模式,在原有的基础上添加打印日志的方法,这样不影响源码的运行,也可以方便的修改。那么如果我想记录我是在哪里消费的,则就需要两个代理对象。当我可以消费的场所更多的时候,我就相应的要增加更多的代理方法。太麻烦。

    那么是否可以按照代理模式的方式,把这串代码弄得动态化呢?这就是面向切面了。下面我们用代码来实现这个操作

    我们先分析我们要做的步骤:

    1-有一个Money的接口,有两个场所SuperMarket和Bank

    2-有要打印的方法,并且分成 前,后两部分

      

  

  Money.java

package com.facetoaop.interfaces;

/**
 * 文件名称: com.facetoaop.interfaces.Money.java<br/>
 * 初始作者: xyou<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明:消费方式 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public interface Money {

    /**
     * 方法描述: [使用现金]<br/>
     * 初始作者: Administrator<br/>
     * 创建日期: 2018-2-3-上午11:00:17<br/>
     * 开始版本: 2.0.0<br/>
     * =================================================<br/>
     * 修改记录:<br/>
     * 修改作者 日期 修改内容<br/>
     * ================================================<br/>
     * 
     * @param money
     *            使用的金额
     * @return
     *         int 消费金额
     */
    int useCase(int money);

    /**
     * 方法描述: [使用银行卡]<br/>
     * 初始作者: Administrator<br/>
     * 创建日期: 2018-2-3-上午11:01:05<br/>
     * 开始版本: 2.0.0<br/>
     * =================================================<br/>
     * 修改记录:<br/>
     * 修改作者 日期 修改内容<br/>
     * ================================================<br/>
     * 
     * @param money
     * @return
     *         int
     */
    int useBankCard(int money);

}

Bank.javaSuperMarket.java

package com.facetoaop.entity;

import com.facetoaop.interfaces.Money;

/**
 * 文件名称: com.facetoaop.entity.Bank.java<br/>
 * 初始作者: Administrator<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明: 银行类 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class Bank implements Money {

    /**
     * 用户的姓名
     */
    private String    name;

    public Bank() {

    }

    public String getName() {

        return name;
    }

    public void setName(String name) {

        this.name = name;
    }

    @Override
    public int useBankCard(int money) {

        /**
         * 这句输出语句只是为了看的,实际项目中应该没有这句
         */
        System.out.println("Bank useBankCard");
        return money;
    }

    @Override
    public int useCase(int money) {

        /**
         * 这句输出语句只是为了看的,实际项目中应该没有这句
         */
        System.out.println("Bank useCase");
        return money;
    }

}
package com.facetoaop.entity;

import com.facetoaop.interfaces.Money;

/**
 * 文件名称: com.facetoaop.entity.SuperMar.java<br/>
 * 初始作者: xyou<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明: 超市类 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class SuperMarket implements Money {

    @Override
    public int useBankCard(int money) {

        /**
         * 只是为了校验
         */
        System.out.println("SuperMarket useBankCard");
        /**
         * 假设使用银行卡有打折优惠,9折
         */
        money=(int) (money*0.9);
        return money;
    }

    @Override
    public int useCase(int money) {

        /**
         * 只是为了校验
         */
        System.out.println("SuperMarket useCase");
        return money;
    }

}

BeforMoneyAspect.java

package com.facetoaop.aspect;

import java.lang.reflect.Method;

import org.springframework.aop.MethodBeforeAdvice;

/**
 * 文件名称: com.facetoaop.aspect.BeforMoenyAspect.java<br/>
 * 初始作者: xyou<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明:消费之前的记录 <br/>
 * 
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者        日期       修改内容<br/>
 * 
 * 
 * ================================================<br/>
 *  Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class BeforMoenyAspect implements MethodBeforeAdvice {

    @Override
    public void before(Method method, Object[] args, Object obj) throws Throwable {
        //method:目标中被执行的方法
        //args:方法中传入的参数
        //obj:调用目标方法中的那个对象
        System.out.println("
------------开始进行消费-------------------
");
        System.out.println("您在:"+obj.getClass().getSimpleName());
        System.out.println("您正准备以:"+method.getName()+"的方式花费");
        /**
         * 因为知道只有一个参数,所以调用args[0]
         */
        System.out.println("您消费的金额为:"+Integer.parseInt(args[0].toString()));
    }

}

AfterMoneyAspect.java

package com.facetoaop.aspect;

import java.lang.reflect.Method;

import org.springframework.aop.AfterReturningAdvice;

/**
 * 文件名称: com.facetoaop.aspect.AfterMoney.java<br/>
 * 初始作者: Administrator<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明: 使用钱之后的记录打印 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class AfterMoneyAspect implements AfterReturningAdvice {

    @Override
    public void afterReturning(Object result, Method method, Object[] args, Object obj) throws Throwable {

        // result:切入点方法的返回值
        // method:被调用的目标对象
        // args:传入方法的参数
        // obj:调用方法的目标对象
        System.out.println("刚刚消费的场所为:" + obj.getClass().getSimpleName());
        System.out.println("消费的方式为:" + method.getName());
        System.out.println("消费金额为:" + result);
        System.out.println("
----------------账单结束----------------------
");
    }

}

AroundMoneyAspect.java

package com.facetoaop.aspect;

import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

/**
 * 文件名称: com.facetoaop.aspect.AroundMoneyAspect.java<br/>
 * 初始作者: xyou<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明: 环绕通知 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class AroundMoneyAspect implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invoke) throws Throwable {

        // invoke.getArguments():获取调用的方法的参数列表
        // invoke.getThis():获取调用方法的对象
        // invoke.getMethod():获取当前被调用的方法对象
        System.out.println("您在" + invoke.getThis().getClass().getSimpleName() + "中准备消费");
        System.out.println("您准备用" + invoke.getMethod().getName() + "的方式消费");
        /**
         * 因为只有一个参数,可以直接使用
         */
        int money = Integer.parseInt(invoke.getArguments()[0].toString());
        System.out.println("准备消费的金额为:" + money);
        // 调用了被代理类中的目标方法
        Object obj = invoke.proceed();
        /**
         * 假设,如果一个人一次消费满10000就减少100元
         */
        if (money >= 10000) {
            return money - 100;
        }
        System.out.println("您在" + invoke.getThis().getClass().getSimpleName() + "中已经消费");
        System.out.println("您用" + invoke.getMethod().getName() + "的方式消费");

        System.out.println("已经消费的金额为:" + money);
        return money;
    }

}

现在,testForAop.xml中初始值是:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
    xmlns:jdbc="http://www.springframework.org/schema/jdbc" xmlns:jee="http://www.springframework.org/schema/jee"
    xmlns:tx="http://www.springframework.org/schema/tx" xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:util="http://www.springframework.org/schema/util"
    xmlns:jpa="http://www.springframework.org/schema/data/jpa"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd
        http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc-3.2.xsd
        http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee-3.2.xsd
        http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.2.xsd
        http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa-1.3.xsd
        http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-3.2.xsd
        http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-3.2.xsd
        http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-3.2.xsd">

    
</beans>

我们要把刚刚的文件都配置进去:

先配置银行和超市对象

    <!-- Bank和SuperMarket对象 -->
    <bean id="bank" class="com.facetoaop.entity.Bank"></bean>
    <bean id="market" class="com.facetoaop.entity.SuperMarket"></bean>

配置切入点

    <!-- 配置切入点 -->
    <!-- 只需要在执行消费的时候打印。通过正则表达式匹配为了可以适配更多的方法  -->
    <!-- 我们应该匹配现金消费和银行卡消费 ,通过相同的前缀匹配-->
    <bean id="pointCut" class="org.springframework.aop.support.JdkRegexpMethodPointcut">
        <property name="pattern" value=".*use.*"></property>
    </bean>

配置前置声明,后置声明,环绕声明

    <!-- 添加前置声明 -->
    <bean id="beforMoneyAspect" class="com.facetoaop.aspect.BeforMoenyAspect"></bean>
    <!-- 声明前置的切入点 -->
    <bean id="beforMoneyAdvice" class="org.springframework.aop.support.DefaultPointcutAdvisor">
        <!-- 全小写的是参数名,不要和驼峰命名的pointCut实例弄混淆 -->
        <property name="pointcut" ref="pointCut"></property>
        <!-- 应该是使用哪个前置提示 -->
        <property name="advice" ref="beforMoneyAspect"></property>
    </bean>

    <!-- 后置声明 -->
    <bean id="afterMoneyAspect" class="com.facetoaop.aspect.AfterMoneyAspect"></bean>
    <!-- 环绕声明 -->
    <bean id="aroundMoneyAspect" class="com.facetoaop.aspect.AroundMoneyAspect"></bean>


    <!-- 对环绕和后置声明进行配置 -->
    <bean id="afterMoneyAdvice" class="org.springframework.aop.support.DefaultPointcutAdvisor">
        <property name="pointcut" ref="pointCut"></property>
        <property name="advice" ref="afterMoneyAspect"></property>
    </bean>
    
    <bean id="aroundMoneyAdvice" class="org.springframework.aop.support.DefaultPointcutAdvisor">

        <property name="pointcut" ref="pointCut"></property>

        <property name="advice" ref="aroundMoneyAspect"></property>
    </bean>

生成代理对象,所有通知都添加

<!-- 生成代理对象,JDK中方法动态生成 -->
    <!-- 生超市的代理对象 -->
    <bean id="marketProxy" class="org.springframework.aop.framework.ProxyFactoryBean">
        <!-- 需要被代理的目标对象 -->
        <property name="target">
            <ref bean="market" />
        </property>

        <!-- 使用哪种切入 -->
        <property name="interceptorNames">
            <list>
                <value>aroundMoneyAdvice</value>
                <value>afterMoneyAdvice</value>
                <value>beforMoneyAspect</value>
            </list>
        </property>
    </bean>

    <!-- 生银行的代理对象 -->
    <bean id="bankProxy" class="org.springframework.aop.framework.ProxyFactoryBean">
        <!-- 需要被代理的目标对象 -->
        <property name="target">
            <ref bean="bank" />
        </property>

        <!-- 使用哪种切入 -->
        <property name="interceptorNames">
            <list>
                <value>aroundMoneyAdvice</value>
                <value>afterMoneyAdvice</value>
                <value>beforMoneyAspect</value>
            </list>
        </property>
    </bean>

最后配置测试文件,查看结果

TestForMonery.java

package com.facetoaop.test;

import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import com.facetoaop.interfaces.Money;

/**
 * 文件名称: com.facetoaop.test.TestUseMoeny.java<br/>
 * 初始作者: xyou<br/>
 * 创建日期: 2018-2-3<br/>
 * 功能说明:测试方法 <br/>
 * =================================================<br/>
 * 修改记录:<br/>
 * 修改作者 日期 修改内容<br/>
 * ================================================<br/>
 * Copyright (c) 2010-2011 .All rights reserved.<br/>
 */
public class TestUseMoeny {

    public static void main(String[] args) {

        ApplicationContext ac = new ClassPathXmlApplicationContext("testForAop.xml");
        System.out.println(ac);

        // 通过向上造型为Money,调用的是代理对象,毕竟原来对象也没有打印记录的方法啊
        Money marketMoney = (Money) ac.getBean("marketProxy");
        Money bankMoney = (Money) ac.getBean("bankProxy");

        // 调用现金消费方式
        System.out.println("========去银行用银行卡消费1000元==========");
        bankMoney.useBankCard(10000);
        System.out.println("========去银行用银行卡消费结束==========


");

        System.out.println("========去超市用银行卡消费10000元==========");
        // 如果满一万则减少一百,只有超市有优惠
        marketMoney.useBankCard(10000);
        System.out.println("========去超市用银行卡消费结束==========


");

        System.out.println("========去银行用现金消费1000元==========");
        bankMoney.useCase(1000);
        System.out.println("========去银行用现金消费结束==========


");

        System.out.println("========去超市用现金消费10000元==========");
        marketMoney.useCase(10000);
        System.out.println("========去超市用现金消费结束==========


");
    }
}

运行结果:

========去银行用银行卡消费1000元==========
您在Bank中准备消费
您准备用useBankCard的方式消费
准备消费的金额为:10000

------------开始进行消费-------------------

您在:Bank
您正准备以:useBankCard的方式花费
您消费的金额为:10000
Bank useBankCard
刚刚消费的场所为:Bank
消费的方式为:useBankCard
消费金额为:10000

----------------账单结束----------------------

满10000,账单上少打印100元 
========去银行用银行卡消费结束==========
========去超市用银行卡消费10000元==========
您在SuperMarket中准备消费
您准备用useBankCard的方式消费
准备消费的金额为:10000

------------开始进行消费-------------------

您在:SuperMarket
您正准备以:useBankCard的方式花费
您消费的金额为:10000
SuperMarket useBankCard
刚刚消费的场所为:SuperMarket
消费的方式为:useBankCard
消费金额为:9000

----------------账单结束----------------------

满10000,账单上少打印100元 
========去超市用银行卡消费结束==========
========去银行用现金消费1000元==========
您在Bank中准备消费
您准备用useCase的方式消费
准备消费的金额为:1000

------------开始进行消费-------------------

您在:Bank
您正准备以:useCase的方式花费
您消费的金额为:1000
Bank useCase
刚刚消费的场所为:Bank
消费的方式为:useCase
消费金额为:1000

----------------账单结束----------------------

您在Bank中已经消费
您用useCase的方式消费
已经消费的金额为:1000
========去银行用现金消费结束==========
========去超市用现金消费1000元==========
您在SuperMarket中准备消费
您准备用useCase的方式消费
准备消费的金额为:10000

------------开始进行消费-------------------

您在:SuperMarket
您正准备以:useCase的方式花费
您消费的金额为:10000
SuperMarket useCase
刚刚消费的场所为:SuperMarket
消费的方式为:useCase
消费金额为:10000

----------------账单结束----------------------

满10000,账单上少打印100元 
========去超市用现金消费结束==========

理论参考:Segmentfault百度百科ImportNew

原文地址:https://www.cnblogs.com/x-you/p/8409069.html