重构之改善既有代码的设计(二)

重构之改善既有代码的设计(一)

三 重构的手段

3.1 第一组重构

“一组我认为最有用的重构。”

我最常用到的重构就是用提炼函数提炼变量,也经常使用这两个重构的反向重构:内联函数内联变量

提炼的关键就在于命名,随着理解的加深,我经常需要改名:改变函数声明变量改名;不过需要先做封装变量。在给函数的形式参数改名时,不妨先用引入参数对象把常在一起出没的参数组合成一个对象。

形成函数并给函数命名,这是低层级重构的精髓。有了函数以后,就需要把它们组合成更高层级的模块。我会使用函数组合成类,把函数和它们操作的数据一起组合成类。另一条路径是用函数组合成变换将函数组合成变换式(transform),这对于处理只读数据尤为便利。再往前一步,常常可以用拆分阶段将这些模块组成界限分明的处理阶段。

3.1.1 提炼函数(Extract Function)

重新组织函数的前提:过长函数Long Methods

有一段代码可以被组织在一起并独立出来。可以通过 Extract Method(提炼函数) 把一段代码从原先函数中提取出来,并让函数名称解释该函数的用途。

void printOwing() {
  printBanner();

  // Print details.
  System.out.println("name: " + name);
  System.out.println("amount: " + getOutstanding());
}
void printOwing() {
  printBanner();
  printDetails(getOutstanding());
}

void printDetails(double outstanding) {
  System.out.println("name: " + name);
  System.out.println("amount: " + outstanding);
}

3.1.2 内联函数(Inline Function)

有时候遇到某些函数,其内部代码和函数名称同样清晰易读,此时可以通过 Inline Method(内联函数) 去掉这个函数。

class PizzaDelivery {
  // ...
  int getRating() {
    return moreThanFiveLateDeliveries() ? 2 : 1;
  }
  boolean moreThanFiveLateDeliveries() {
    return numberOfLateDeliveries > 5;
  }
}
class PizzaDelivery {
  // ...
  int getRating() {
    return numberOfLateDeliveries > 5 ? 2 : 1;
  }
}

3.1.3 内联变量 (Inline Variable)

如果有一个临时变量,只被简单表达式赋值一次,而它妨碍了其他重构手法,那么可以通过 内联变量 对该变量所有的引用动作替换为对他赋值的那个表达式自身。

当变量并不比表达式更具表现力或者变量可能会妨碍重构附近代码时,就要消除该变量。

boolean hasDiscount(Order order) {
  double basePrice = order.basePrice();
  return basePrice > 1000;
}
boolean hasDiscount(Order order) {
  return order.basePrice() > 1000;
}

3.1.4 提炼变量(Extract Variable)

当存在难以理解的表达式的时候,可以尝试提炼变量将表达变得更加可“自我说明”。

void renderBanner() {
  if ((platform.toUpperCase().indexOf("MAC") > -1) &&
       (browser.toUpperCase().indexOf("IE") > -1) &&
        wasInitialized() && resize > 0 )
  {
    // do something
  }
}
void renderBanner() {
  final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
  final boolean isIE = browser.toUpperCase().indexOf("IE") > -1;
  final boolean wasResized = resize > 0;

  if (isMacOs && isIE && wasInitialized() && wasResized) {
    // do something
  }
}

3.1.5 改名(Rename)

改变函数声明(Change Function Declaration)、变量改名(Rename Variable):

如果一个函数未能揭示函数的用途,为了增加代码可读性,请通过 改变函数声明 修改函数名称。变量同理。

// 坏味道
var a = height * width
// 好味道
var area = height * width

3.1.6 拆分变量(Split Variable)

当处理一个拥有大量局部变量的算法的时候,直接使用 Extract Method(提炼函数)绝非易事。这种情况下可以使用 拆分变量 来理清代码,引入解释性变量使得代码更加容易理解。梳理清楚逻辑之后,再使用 Replace Temp with Query(以查询取代临时变量) 把中间引入的解释性临时变量去掉,方便重构。况且,如果我最终使用 Replace Method with Method Object(以函数对象取代函数) ,那些引入的解释性临时变量也有其价值。

🚀[点击我,demo](#_2.1 Mysterious Name(神秘命名))🚀

double temp = 2 * (height + width);
System.out.println(temp);
temp = height * width;
System.out.println(temp);
final double perimeter = 2 * (height + width);
System.out.println(perimeter);
final double area = height * width;
System.out.println(area);

3.1.7 封装变量(Encapsulate Variable)

当你有一个公共的field时,创建封装函数,在其中访问和更新变量值。

好处:隐藏对象内部状态的细节,并提供对外部的接口来操作这些状态。这可以提高代码的灵活性,防止误用,并使代码更易于维护。

class Person {
  public String name;
}
class Person {
  private String name;

  public String getName() {
    return name;
  }
  public void setName(String arg) {
    name = arg;
  }
}

3.1.8 引入参数对象(Introduce Parameter Object)

将多个独立的参数合并为一个对象,从而减少代码的复杂性和难以维护。通过将多个参数组合成一个对象,可以将代码组织成更简洁易懂的结构,并易于理解和维护。此外,引入参数对象还有助于将聚合关系和关联关系从代码中抽象出来,并为将来的更改和扩展预留空间。

image-20230201163216408

3.1.9 函数组合成类(Combine Functions into Class)

当我们发现一组(个)函数在总是在操作同一块数据类, 我们可以把这种处理提炼到这个数据类中。

data class CombineFunReading(val charge: Int)

fun base(reading: CombineFunReading){..}
fun taxableCharge(reading: CombineFunReading){..}
fun calculateBaseCharge(reading: CombineFunReading){..}

组合成:

data class CombineFunReading(val charge: Int){
    fun base() {}
    fun taxableCharge() {}
    fun calculateBaseCharge() {}
}

3.1.10 函数组合成变换(Combine Functions into Transform)

在需求的变更后,我们有可能需要在原来的数据上扩展一些原来没有的函数来计算一些东西。 所以我们就会加一些补丁函数,例如下面例子中,对类扩展了两个函数,分别来计算 底款base税额taxableCharge,这种行为就是所谓的“打补丁”。

这种逻辑可能会经常用到派生数据的各个地方, 这个时候我们需要将计算派生数据的逻辑收拢到一处,避免到处重复。

fun base(reading: CombineFunReading): Int {..}
fun taxableCharge(reading: CombineFunReading): Int {...}

优化成:

fun enrichReading(argReading: CombineFunReading): CombineFunReadingWrapper {
    val readingWrapper = CombineFunReadingWrapper(argReading)
    readingWrapper.base = base(argReading)
    readingWrapper.taxableCharge = taxableCharge(argReading)
    return readingWrapper
}

data class CombineFunReadingWrapper(val reading: CombineFunReading, var base: Int = 0, var taxableCharge: Int = 0)

我们可以根据代码中的已有风格决定选择函数组合成类还是函数组合成变换,但两者有一个重要区别:如果代码会对源数据进行修改, 那么使用 “将函数组合成类” 会好很多;如果使用变换,派生数据会被存储在新生成的记录中,一旦源数据遭到修改,我们就会遭遇数据不一致。

3.1.11 拆分阶段(Split Phase)

拆分成各自独立的模块:当一段代码在同时处理两件不同的事,就要想把它拆分成各自独立的模块

🚀[点击我,demo](#_3.1.1 提炼函数(Extract Function))🚀

3.2 封装

3.2.1 以对象取代基本类型(Replace Primitive with Object)

当类包含的字段有自己的行为和相关数据时,我们可以创建一个新类存储。

当使用对象而不是基本数据类型时,可以在对象内部存储一些关于数据的元数据,以及对数据的处理方式的抽象,也更容易进行拓展,使代码变得更加清晰和可维护。

image-20230203104345567

3.2.2 以查询取代临时变量(Replace Temp With Query)

当临时变量保存一个表达式结果,将临时变量的表达式结果逻辑整合,将逻辑放到单独的函数中,返回结果。以用途名命名这个单独的函数,以此函数取代临时变量所有引用点。

查询函数的作用:

  • 解释性(对应用途的函数名称就是良好的注释)
  • 可读性(减小父函数的体积,在查询函数中可将逻辑表达式分块)
  • 方便重构(当临时变量影响到了重构步骤,用查询函数替代)

示例:

   private int month = 7; 

    //原init函数
    private void init(){
        boolean flag =  month >=6 && month <= 9;
        if (flag){
            System.out.println("这是夏天");
        }
    }
 //repalce temp with query, 改进后的init函数
    private void init(){
        if (isSummer()){
            System.out.println("这是夏天");
        }
    }

    private boolean isSummer(){
        return month >=6 && month <= 9;
    }

3.2.3 提炼类

一个类承担过多的责任会导致臃肿不堪,这个时候可以使用 Extract Class(提炼类) 将相关的字段和函数从旧类搬移到新类。从而使一部分责任分离出去。

原始代码:

public class Person {
  private String name;
  private String officeAreaCode;
  private String officeNumber;

  public String getTelephoneNumber() {
    return "(" + officeAreaCode + ") " + officeNumber;
  }
}

提炼后:

public class Person {
  private String name;
  private TelephoneNumber telephoneNumber;

  public String getTelephoneNumber() {
    return telephoneNumber.getTelephoneNumber();
  }
}

public class TelephoneNumber {
  private String officeAreaCode;
  private String officeNumber;

  public String getTelephoneNumber() {
    return "(" + officeAreaCode + ") " + officeNumber;
  }
}

image-20230201145418613

3.2.4 将类内联化

如果一个类变得“不负责任”(几乎不干任何事情或者不对任何事情负责也没有计划往这个类添加任何职责),则可以使用 Inline Class(将类内联化) 将它容易另一个类。

image-20230201145537378

3.2.5 隐藏委托关系

为了保持对象的封装性,应该避免对外暴露过多的细节。如果一个类使用了另一个类,可以使用 Hide Delegate(隐藏委托关系) 将这种关系隐藏起来。

image-20230201150625710

manager = aPerson.department.manager;

重构:

manager = aPerson.manager; 

class Person {
  get manager() {return this.department.manager;}

3.3.6 移除中间人

隐藏委托关系的反向重构。

封装受托对象也是要付出代价的:每当客户要使用受托类的新特性的时候,就必须在服务端添加一个简单的委托函数,随着受托类特性越来越多,这个过程会让你痛苦不堪。服务类完全变成了一个“中间人”,此时应该让客户直接调用次受托类。

有时候隐藏委托类会导致拥有者的接口经常变换,这个时候要用 Remove Middle Man(移除中间人)

image-20230201150801808

manager = aPerson.manager; 

class Person {
 get manager() {return this.department.manager;}

重构:

manager = aPerson.department.manager;

3.3.7 替换算法

针对不够好的算法,可以使用 Substitute Algorithm(替换算法) 引入更加清晰的算法。

替换一个巨大而复杂的算法是非常困难的,只有先降它分解为较简单的小型函数,才能更有把握地进行算法替换。

String foundPerson(String[] people){
  for (int i = 0; i < people.length; i++) {
    if (people[i].equals("Don")){
      return "Don";
    }
    if (people[i].equals("John")){
      return "John";
    }
    if (people[i].equals("Kent")){
      return "Kent";
    }
  }
  return "";
}

重构:

String foundPerson(String[] people){
  List candidates =
    Arrays.asList(new String[] {"Don", "John", "Kent"});
  for (int i=0; i < people.length; i++) {
    if (candidates.contains(people[i])) {
      return people[i];
    }
  }
  return "";
}

3.3 搬移特性

3.3.1 搬移函数

在程序中,有个函数与其所驻类之外的另一个类交流更多:调用后者,或者被后者调用。这个时候可以使用 Move Method(搬移函数) 在该函数最长引用的类中建立一个有着类似行为的新函数。将就函数变成一个单纯的委托函数,或是将旧函数完全移除。

image-20230201144624542

3.3.2 搬移字段

在程序中,某个字段被其所驻类之外的另一个类更多地用到。这个时候可以使用 Move Field(搬移字段) 在目标类新建一个字段,修改源字段的所有用户,令它们改用新字段。

如果两者都需要用到,则先用 Move Field(搬移字段) ,再用 Move Method(搬移函数)

image-20230201144730658

3.3.3 以管道取代循环(Replace Loop with Pipeline)

迭代一组集合时得使用循环是我入行时就管用的手法,但如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫做集合管道(collection pipeline)。
集合管道允许我使用一组运算来描述集合的迭代过程,其中每种运算接受的入参和返回值都是一个集合。
这类运算有很多种,最常见的的非mapfilter莫属:map运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 使用循环
int sum = 0;
for (int num : numbers) {
  if (num > 5) {
    sum += num;
  }
}

// 使用管道
sum = numbers.stream()
  .filter(num -> num > 5)
  .mapToInt(Integer::intValue)
  .sum();

示例中,使用了 stream() 方法将集合转换为管道,并使用 filter()mapToInt() 方法对其进行操作,最后使用 sum() 方法计算总和。

image-20230203143455477

3.3.3 移除死代码(Remove Dead Code)

一旦代码不再被使用,我们就该立马删除它。
有可能以后又会需要这段代码,但我从不担心这种情况;就算真的发生,我也可以从版本控制系统里再次将它翻找出来。
如果我真的觉得日后它极有可能再度启用,那还是要删掉它,只不过可以在代码里留一段注释,提一下这段代码的存在,以及它被诶移除的那个提交版本号。

if(false){
   doSomethingThatUsedToMatter();
}

3.4 简化条件逻辑

3.4.1 分解条件表达式(Decompose Conditional)

程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。我必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后,我很快就会得到一个相当长的函数。
大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。

本重构手法其实只是提炼函数的一个应用场景。
但我要特别强调这个场景,因为我发现它经常会带来很大的价值。

if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
    charge = quantity * plan.summerRate;
else
    charge = quantity * plan.regularRate + plan.regularServiceCharge;

重构:

if (summer())
    charge = summerCharge();
else
    charge = regularCharge();

3.4.2 合并条件表达式(Consolidate Conditional Expression)

有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。
如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。

if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;

重构:

if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
    return ((anEmployee.seniority < 2)
            || (anEmployee.monthsDisabled > 12)
            || (anEmployee.isPartTime));
}

3.4.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)

条件表达式通常有两种风格。
第一种风格是:两个条件分支都属于正常行为。
第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。

function getPayAmount() {
    let result;
    if (isDead)
        result = deadAmout();
    else {
        if (isSeparated)
            result = separatedAmount();
        else {
            if (isRetired)
                result = retiredAmount();
            else
                result = normalPayAmount();
        }
    }
    return result;
}

重构:

function getPayAmount() {
    if (isDead) return deadAmount();
    if (isSeparated) return separatedAmount();
    if (isRetired) return retiredAmount();
    return normalPayAmount();
}

3.4.4 以多态取代条件表达式(Replace Conditional with Polymorphism)

如果你手上有个条件表达式,根据对象类型的不同而选择不同的行为,那么可以尝试使用 Replace Conditional with Polymorphism(以多态取代条件表达式),创建与条件的分支匹配的子类,将这个表达式的每个分支放进一个子类内的覆写函数中,然后将原始函数声明为抽象函数。用相关的方法调用替换条件,从而达到以多态取代条件表达式。

class Bird {
  // ...
  double getSpeed() {
    switch (type) {
      case EUROPEAN:
        return getBaseSpeed();
      case AFRICAN:
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
      case NORWEGIAN_BLUE:
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }
    throw new RuntimeException("Should be unreachable");
  }
}

重构后:

abstract class Bird {
  // ...
  abstract double getSpeed();
}

class European extends Bird {
  double getSpeed() {
    return getBaseSpeed();
  }
}
class African extends Bird {
  double getSpeed() {
    return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
  }
}
class NorwegianBlue extends Bird {
  double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}

// Somewhere in client code
speed = bird.getSpeed();

3.4 其他

3.4.1 以委托取代继承(Replace Inheritance with Delegation)

超类中的许多操作并不真正适用于子类

1、某个子类只使用了超类接口中的一部分,或是根本不需要继承而来的数据;

2、在子类中新建一个字段用以保存超类;调整子类函数,令它改而委托超类;然后去掉两者之间的继承关系;

class List {...}
class Stack extends List {...}

重构后:

class Stack { 
  constructor() {
    this._storage = new List();
  }
}
class List {...}

3.4.2 以函数对象取代函数(replace method with method object)

如果有一个大型函数,变量太混乱,难以替换,其中对局部变量的使用是你无法采用 Extract Method(提炼函数) ,可以通过 Replace Method with Method Object(以函数对象取代函数) ,将这个函数放进一个单独的对象中,如此一来,局部变量就编程了对象内的字段,这样就可以很方便的在同一个对象中将这个大型函数分解为多个小型函数了。

class Order {
  // ...
  public double price() {
    double primaryBasePrice;
    double secondaryBasePrice;
    double tertiaryBasePrice;
    // Perform long computation.
  }
}
class Order {
  // ...
  public double price() {
    return new PriceCalculator(this).compute();
  }
}

class PriceCalculator {
  private double primaryBasePrice;
  private double secondaryBasePrice;
  private double tertiaryBasePrice;

  public PriceCalculator(Order order) {
    // Copy relevant information from the
    // order object.
  }

  public double compute() {
    // Perform long computation.
  }
}

3.4.3 以工厂函数取代构造函数(Replace Constructor with Factory Method)

你希望在创造对象的时候不仅仅是做简单的建构动作,那么就将构造函数替换为工厂函数。

改造过程主要是开发工厂方法,并将根据入参创建对象的代码放置到工厂函数中,并将原始的创建函数私有化。

构造函数的缺点:无法根据环境或参数信息返回子类实例或代理对象 ; 无法使用比构造函数名字更清晰的函数名 ;

// 坏味道
leadEngineer = new Employee(document.leadEngineer, 'E');
// 好味道
leadEngineer = createEngineer(document.leadEngineer);

3.4.4 移除对参数的赋值

一般情况下,函数的参数都应该被当成final类型来处理,假设代码中对一个参数进行了赋值,请通过 Remove Assignments to Parameters(移除对参数的赋值) 以一个临时变量取代该参数的位置。如果函数体很长,可以尝试在参数前添加final关键词,检查方法体中是否有对参数进行重新赋值。

int discount(int inputVal, int quantity) {
  if (inputVal > 50) {
    inputVal -= 2;
  }
  // ...
}
int discount(int inputVal, int quantity) {
  int result = inputVal;
  if (quantity > 50) {
    result -= 2;
  }
  // ...
}

四 重构的基本步骤

代码分析

通读代码,分析现状,找到代码在各个层面的坏味道。

重构计划

重构应该永远是一种经济驱动的决定。

对坏味道进行宣讲,并向团队给出重构的理由,以及重构的计划。

确定重构的目标,明确的描述出重构后能达到的预期是什么。

重构计划中必须给出测试验证方案,保证重构前与重构后软件的行为一致。

如果没有这样的方案,那就必须先让软件具有可测试性。

如果无法得到团队的认可,那就偷偷进行,因为重构始终是对自己有利的(减少工作量以及获得同事的认可)

将重构任务当作项目来管理,对指定任务的人明确的排期和进度同步。

小步子策略

将重构任务拆分成每周都能见到一点效果的小任务

每一步重构都要具有收益,并且可测试,不能阻断当前需求的迭代。

重构任务必须被跟踪,要定期的开会同步进度,来不断加强团队的重构意识。

测试驱动

对于小型软件,需要先补充单元测试再进行重构。

对于大型软件,先搭建自动化测试流程,再进行重构。

对于复杂的不确定性业务,也可以使用ab test来验证重构对指标的影响,避免造成效果/广告的损失。

要保证测试的完备性与可复用性,尽可能的做到团队级的复用。

保证测试环境与生产环境的一致性也是测试驱动的重要环节。

提交规范

每次提交尽量控制在2分钟可以给code review的同事讲明白的程度

重构应该被当作一次专门的commit中完成,在commit中写清楚改动点&测试点

提交规范有助于定位bug,也是代码可读性的一个重要环节

自动化测试

构建可测试的软件,首先要构建可测试的环境。

对于简单应用软件可以使用单元测试,mock数据进行测试,并与ci/cd流程集成。

对于复杂应用软件可以采样收集线上真实用户行为日志,mock数据周期性巡检测试。

对于幂等性业务,可以mock user进行全方位的端到端自动化巡检测试。

每一次功能的提交应该对应一套完整的自动化测试的策略脚本以及&监控指标与报警规则

调试BUG

  1. 亲自复现问题,关注第一现场,确定是必现还是偶现?
  2. 区分是人的问题还是环境的问题?
  3. 如果是人的问题,那是配置参数的问题还是代码逻辑的问题?
  4. 如果是配置参数的问题,则通过对比正常运行的配置参数发现问题
  5. 如果是代码逻辑的问题,则通过cimmit的历史二分查找缩小出现问题的逻辑范围
  6. 如果是机器的问题,确定是单机问题还是集群问题。
  7. 如果是单机问题,则替换机器,如果是集群问题则考虑升级硬件设备。

高质量上线

每次一次上线都必须具有上线计划,发布上线单可追溯可排查问题,关注上线前和上线后指标变化。

上线单写明: 改动点,风险点,止损方案,变更代码,相关负责上下游人员。

五 一些实际的问题

代码所有权

代码仓库的所有权会阻碍重构,调用方难以重构被调用方的代码(接口),进而导致自身重构的受阻,使得效率降低,为提高开发的效能,允许代码仓库在内部开源化,其他团队的工程师可以通过pr自己来实现代码,并提交给仓库的onwer,来code review即可。

没有时间重构

这是重构所面临最多的借口,是自己也是团队的借口。 为此必须要明确重构是经济行为而不是一种道德行为,重构使得开发效率变得更高,因此仅对必要的代码进行重构,某个工作行为如果重复三次就可以认为未来也会存在重复,因此通过重构使得下次工作更加高效,这是一种务实的作法,而重构不一定是需要大规模的展开的任务,重构应该是不断持续进行的,将任务拆解为多个具有完备性的任务,每周完成一个,每个任务的上线都不会引起问题,并使项目变得更好,这是一种持续重构的精神态度,是高效能程序员最应该具有的工作习惯。

如果你在给项目添加新的特性,发现当前的代码不能高效的完成这个任务,并且同样的任务出现三次以上,那么这时你应该先重构,再开发新特性。

重构导致bug

历史遗留的代码实在太多,难以阅读理解,如果无法理解谁也不敢轻易重构,害怕招致bug引起线上事故,因此在重构之前必须有一套相对完备的测试流程,他能给予程序员信心,也是重构的开始,反过来想对于谁也不愿意重构的代码进行重构,将收益巨大(这个项目还会继续迭代时)

六 参考资料

https://refactoringguru.cn/

速看笔记版

https://www.itzhai.com/articles/refactoring-cheat-sheet.html

https://www.itzhai.com/articles/bad-code-small.html

《重构》笔记—坏代码的味道与处理

坏味道与重构手法速查表

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

许进进

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值