框架设计原则和规范(二)

此文是《.NET:框架设计原则、规范》的读书笔记,本文内容较多,共分九章,将分4天进行推送,今天推送4-5章。

1. 什么是好的框架

2. 框架设计原则

3. 命名规范

4. 类型设计规范

5. 成员设计规范

6. 扩展性设计

7. 异常

8. 使用规范

9. 设计模式

1. 类型设计规范

确保每个类型由一组定义明确、互相关联的成员组成,而不仅仅是一些无关功能的随机集合

1.1. 类型和名字空间

1.1.1. 用名字空间把类型组织成一个相关的功能区结构

名字空间并不是仅仅为了解决名字冲突。而应该是组织类型的工具,让类型变成一个有条理的,易于浏览和理解的层次结构

1.1.2. 避免非常深的名字空间层次

1.1.3. 避免太多的名字空间

1.1.4. 避免把高级方案的类型和常见任务的类型放到同一个名字空间中

IDE的类型下来列表过长,就不能很容易的被浏览和发现

1.1.5. 每个类型都应该指定名字空间

1.1.6. 标准名字空间命名

1.1.6.1. .Design 存放设计时用的类型所空间

1.1.6.2. .Permission 存放自定义权限类型空间

1.1.6.3. .Interop 存放与旧系统互操作的类型

1.2. 选择类还是结构

1.2.1. 考虑使用结构:如果实例比较小而且生命周期短,或者经常被内嵌在其他对象中

1.2.2. 仅在以下情况使用结构:

1.2.2.1. 逻辑上代表一个独立的值

1.2.2.2. 实例大小小于16个字节

1.2.2.3. 不可变

1.2.2.4. 不需要经常被装箱

1.3. 选择类还是接口

1.3.1. 优先定义类而不是接口

我们可以给类添加成员,而接口则需要使用者修改代码

1.3.2. 要用抽象类而不是接口解除契约与实现之间的耦合

1.3.3. 如果需要提供多态层次结构的值类型,定义接口

值类型(结构)不能继承其他类型,但可以实现接口

1.3.4. 考虑通过定义接口来达到多重继承类似的效果

1.4. 抽象类设计

1.4.1. 构造函数应该是protected或者private的

1.4.2. 至少定义一个该抽象类的具体实现类型

要通过实际使用验证抽象类设计的问题

1.5. 静态类设计

1.5.1. 尽量少用静态类

1.5.2. 不要用作杂物箱

1.5.3. 不要声明或覆盖静态类中的实例成员

1.5.4. 静态类应该是密封的、抽象的,并且有一个私有的实例构造函数

1.6. 接口的设计

1.6.1. 如果想让一组类型支持一些公共的API,则定义接口

1.6.2. 如果想让已经继承其他基类的类型支持一个接口提供的功能,定义接口

1.6.3. 避免使用记号接口(没有成员的接口)

应该使用自定义的修饰属性(Attribute)

[Immutable]

public class Key{

...

}

而不是:

public interface IImmutable{}

public class Key : IImmutable{...}

1.6.4. 自己最少实现一次自己设计的接口

1.6.5. 每个接口都最少有一个使用它的API(以此接口为参数,或者一个类型为该接口的属性)

1.6.6. 不要给已经发行的接口添加成员

1.7. 结构的设计

1.7.1. 不要提供默认构造函数

1.7.2. 不要定义可变的值类型

1.7.3. 确保所有字段都是0、false、null时结构任然是有效状态

1.7.4. 实现IEquatable<T>,用于提高Object.Equals()的效率

1.7.5. 不要显式扩展System.ValueType

1.8. 枚举的设计

1.8.1. 用枚举加强一些数值的类型性

1.8.2. 优先使用枚举而不是静态常量

1.8.3. 不要把枚举用于开放的集合(如操作系统版本、朋友名字这类)

1.8.4. 不要提供为了今后使用而保留的枚举值

MIDAS里面就有一个这样的问题,导致使用者误用了这个错误的值

1.8.5. 避免显式的暴露只有一个值的枚举

1.8.6. 不要在枚举中包含sentinel值(多个名字一个值)

1.8.7. 要为简单枚举提供零值

1.8.8. 一般使用Int32为载体来实现枚举

1.8.8.1. 如果预计超过32个标记,则不应该用Int32

1.8.8.2. 除非需要与非托管代码交互,而非托管代码不是用的Int32

1.8.8.3. 使用更小的类型可能会节省很大空间

1.8.9. 要用复数名词或名词短语命名“标记枚举”

1.8.10. 不要扩充System.Enum

1.8.11. 标记枚举的设计

用来表示Flag的枚举

1.8.11.1. 对标记枚举使用System.FlagsAttrubue

[Flags]

public enum AttributeTargets{

...

}

1.8.11.2.用2的幂次方用作标记枚举的值,这样可以使用按位OR操作来组合他们

1.8.11.3. 考虑为常用的标记组合提供特殊的枚举值

[Flags]

public enum FileAccess{

Read = 1,

Write = 2,

ReadWrite=Read|Write,

}

1.8.11.4.避免包含某些无效的组合

1.8.11.5. 避免使用0作为枚举的值,除非表示“所有标记被清除”

1.8.11.6. 把标识枚举值为0的值命名为None.

1.8.12. 给枚举添加值

1.8.12.1. 可以考虑给枚举增加值

1.9. 嵌套类型

1.9.1. 想要让一个类型能访问另外一个类型的成员时,才使用嵌套类型

比如一个类型要提供一个特定接口的数据对象,对于接口的实现代码就适合定义嵌套类型来处理,这样实现那些接口的代码可以分割到嵌套类里面去。

public OrderCollection :IEnumerable<Order> {

Order[] data = ...;

public IEnumerator<Order> GetEnumerator() {

return new OrderEnumerator(this);

}

//实现接口的嵌套类,因此外层类的实现接口代码可以被关联到此处

class OrderEnumerator : IEnumerator<Order> {

}

}

1.9.2. 不要使用公共嵌套类型来做逻辑分组,而应该使用名字空间

1.9.3. 避免公开的暴露嵌套类型

1.9.4. 嵌套类如果会被他的外层类之外的类引用,则不应该定义嵌套类

1.9.5. 如果嵌套类会被客户代码来实例化,不应该设计嵌套类

1.9.6. 不要把嵌套类型定义为接口的成员

1.10. 类型和程序集元数据

1.10.1. 在包含公共类型的程序集中使用CLSCompliant(true)的修饰属性

表示符合CLS规范

1.10.2. 在包含公共类型的程序集中使用AssemblyVersionAttrubue的修饰属性

1.10.3. 考虑在程序集版本号中使用<V><S><B><R>格式

1.10.3.1. V:主版本号

1.10.3.2. S:服务版本号

1.10.3.3. B:构建号

1.10.3.4. R:构建修订号

1.10.4. 使用一些修饰属性让Visual Studio来提供功能

1.10.5. 使用ComVisible(false)避免暴露给COM

1.10.6. 考虑使用AssemblyFileVersionAtturbue和AssemblyCopyrightAttribute

2. 成员设计规范

2.1. 通用规范

2.1.1. 成员重载

2.1.1.1. 在较长的方法中的参数名,要尽量能用参数名说明那些较短的重载方法中的默认值

public class Type {

public MethodInfo GetMethod(string name); // ignoreCase = false

public MethodInfo GetMethod(string name, Boolean ignoreCase);

}

2.1.1.2. 避免在重载中随意的改变参数的名字

2.1.1.3. 避免使重载的成员参数不一致

2.1.1.4. 要把最长的重载成员定义为重载成员中唯一的虚成员(如果需要扩展性)

2.1.1.5. 不要用ref或out修饰符来对成员进行重载

2.1.1.6. 不要定义这种重载:位于同一位置的参数,有相似的类型,但却有不同的语义

2.1.1.7. 要允许在传递参数是,将可选参数设为null

2.1.1.8. 要有限使用成员重载,而不是定义有默认参数的成员

2.1.2. 显式实现接口成员

C#中实现一个接口有显式和隐式两种。其中显式的实现,要求实现者类的实例,必须被转换成其实现的接口类型才能调用其实现方法。

2.1.2.1. 避免显式的实现接口成员,除非有很强的理由

显示实现的方法,不会出现在公有成员列表中。

但适合用来用在框架内部的互相调用的接口处理上。

2.1.2.2. 如果希望一个类的实例只能通过某接口来使用,考虑显式的实现接口

2.1.2.3. 可以通过显式实现接口成员来模拟变体:当此实例是接口类型时,同样的方法拥有不同的参数或返回值类型

public class StringCollection: IList {

public string this[int index] {...}

objectILIst.this[int index]{...}

...

}

2.1.2.4. 在需要隐藏一个成员,并且增加另外一个名字更合适的成员时,可以显式的实现接口成员

相当于对成员进行重命名

public class FileStream : IDisposable{

void IDisposable.Dispose { Close(); } // 隐藏因为接口而存在的Dispose方法。

public void Close() { ... } // 真正的关闭方法

}

2.1.2.5. 不要把接口成员的显式实现当作安全壁垒

2.1.2.6. 如果希望让派生类对于显式实现接口的成员进行定制,应该提供具备实现接口成员的相同功能的受保护的虚成员。

派生类不能直接覆盖那些显式实现接口的方法

2.1.3. 属性和方法之间的选择

2.1.3.1. 如果一个成员表示类型的一种逻辑属性,考虑使用属性

2.1.3.2. 如果一个方法仅仅是为了访问一个存储在进程内存中的值,考虑用属性而不是方法。

2.1.3.3. 下列情况应该使用方法,而不是属性

2.1.3.3.1. 该操作比字段访问要慢几个数量级
2.1.3.3.2. 该操作是一个转换操作

如Object.ToString

2.1.3.3.3. 该操作在没死调用时都返回不通的结果,即使传入的参数不变
2.1.3.3.4. 该操作有严重的、显而易见的副作用
2.1.3.3.5. 该操作返回内部状态的一个副本
2.1.3.3.6. 该操作返回一个数组

2.2. 属性的设计

2.2.1. 如果调用方不应该改变属性的值,要创建只读属性

2.2.2. 不要提供只写属性,也不要让setter的可访问性比getter更广

2.2.3. 腰围所有的属性提供合理的默认值,确保其不会导致安全流动或效率低下

2.2.4. 要允许用户以任何顺序来设置属性的值,即使这样可能会使对象在短事件处于无效状态

如果某些属性组合是无效的,应该抛出异常来指示此使用错误

2.2.5. 如果setter抛出异常,则应该保留属性原来的值

2.2.6. 避免在getter中抛出异常

2.2.7. 索引属性

public class String {

public char this[int index]{

get { ... }

}

}

...

string city = ""Seattle"";

Console.WriteLine(city[0]); // 打印's'

2.2.7.1. 考虑通过索引器方式让用户访问储存农户在内部数组中的数据

2.2.7.2. 考虑为代表元素集合的类型提供索引器

2.2.7.3. 避免使用有一个以上参数的索引属性

2.2.7.4. 避免下列之外的类型来做索引器的参数
  • ‍System.Int32
  • System.Int64
  • System.String
  • System.Object
  • 枚举
  • 泛型

2.2.7.5. 要将Item名称用于索引属性,除非有明显更好的名字

如System.String的Chars属性

2.2.7.6. 不要同时提供索引器和类似功能的方法

2.2.7.7. 不要在一个类型中提供具有不同名字的索引器

C#编译器强制

2.2.7.8. 不要使用非默认的索引属性

C#编译器强制

2.2.8. 属性变化的通知事件

public class Control : Component {

string text = String.Emplty;

public event eventHandler<EventArgs> TextChanged;

public string Text {

get { return test; }

set{

if(text != value) { //属性变化了

text= value;

OnTextChanged();//调用通知事件

}

}

}

2.2.8.1. 考虑在高层API(通常是设计器组件)的属性值被修改是触发属性改变的通知事件。

2.2.8.2. 考虑在属性值被外界修改时(而不是调用了对象的方法)触发通知事件

2.3. 构造函数的设计

public class Customer {

publicCustomer() { ... } //实例构造函数

staticCustomer() { ... } //类型构造函数

* CLR具有特别的类型构造函数,会在使用该类型之前运行他。

* 此构造函数不能带任何参数。

2.3.1. 考虑提供简单的构造函数,最好是默认构造函数

2.3.2. 考虑用静态工厂方法代替构造函数,如果无法让想要执行的操作的语义与新实例的构造函数直接对应,或者遵循构造函数的设计规范让问觉得感觉不合理

2.3.3. 要把构造函数的参数列表当作设置主要属性的快捷方式

2.3.4. 要用相同的名字来命名构造函数的参数和属性,如果定义该构造函数参数的目的就是为了设置对应的属性。

这类参数和对应属性之间的区别应该仅仅是大小写。

public class eventLog {

publicEventLog(string logName) {

this.LogName= logName; //注意使用了this.

}

publicstring LogName {

get{ ... }

set{ ...}

}

}

2.3.5. 构造函数中只应该做最少的工作

处理所引起的开销应该推迟到真正需要的时候

2.3.6. 要在适当的时候从实例构造函数中抛出异常

就算在构造函数抛出异常,那么垃圾收集器还是会回收该对象,并且可能调用其Finalize方法。因此如果写了Finalize方法,应该确保在构造抛异常的时候也能正确运行此方法。

2.3.7. 要在类中显式的声明公有的默认构造函数,如果这样的构造函数是必须的

如果原来的类型没有显式的默认构造函数,编译器会自动给一个,客户端代码很可能会写上:MyClassobj = new MyClass();

但是如果后来此类增加了一个带参数的构造函数,编译器会自动取消掉那个自动生成的“默认构造函数”,导致之前的客户端代码编译失败。

2.3.8. 避免在结构中显式的定义默认构造函数

C#编译器在没有显式的某人构造函数时,结构的创建会更快。

2.3.9. 避免在对象的构造函数内部调用虚成员,除非能规范用户正确的覆盖它们

虚成员在基类初始化时很可能是没初始化的,会导致异常。

2.3.10. 类型构造函数的规范

2.3.10.1. 要把静态构造函数声明为私有

2.3.10.2. 不要从静态构造函数中抛出异常

2.3.10.3. 考虑以内联的形式来初始化静态字段,而不要显式的定义静态构造函数

运行库能对没有显式定义静态构造函数的类型进行性能优化

//不能优化的代码

public class Foo {

publicstatic readonly int Value;

staticFoo() {

Value= 63;

}

publicstatic void PrintValue() {

Console.WriteLine(Value);

}

}

//可以优化的代码

public class Foo {

publicstatic readonly int Value = 63;

publicstatic void PrintValue() {

Console.WriteLine(Value);

}

}

2.4. 事件的设计

事件处理方法的约定:

1)方法返回类型为void

2)方法有两个参数:Objectsender, EventArgs e

事件分类:

1)前置事件Doing

2)后置事件Done

2.4.1. 要在事件中使用术语""raise"",而不是""fire"" 和""trigger""

2.4.2. 要用System.Eventhandler<T>来定义事件处理函数,而不是手工创建新的委托来定义事件处理函数

2.4.3. 考虑使用EventArgs的子类来做事件参数,除非100%确信该事件不需要给事件处理方法传递任何数据,这种情况下可以直接使用EventArgs

2.4.4. 要用受保护的虚方法来触发事件

约定:方法名字应该以""On""开头,随后是事件的名字

2.4.5. 要让触发事件的受保护的方法带一个参数,该参数的类型为事件参数类,该参数的名字应该为e

protected virtual voidOnAlarmRaised(AlarmRaisedEventArgs e) {

EventHandler<AlarmRaisedEventArgs>handler = AlarmRaised;

if(handler!= null) {

handler(this,e);

}

}

}

2.4.6. 不要在触发非静态事件时把null作为sender参数传入

2.4.7. 不要在触发事件时把null作为数据参数传入。

如果没有数据,应该使用EventArgs.Empty

2.4.8. 考虑触发能够被最终用户取消的事件,这只适用于前置事件

2.4.9. 自定义事件处理函数的设计

有些情况下不能使用EventHandler<T>

2.4.9.1. 要把事件处理函数的返回类型定义为void

2.4.9.2. 要用object作为事件处理函数的第一个参数的类型,并将其命名为sender

2.4.9.3. 要用System.EventArgs或其子类作为事件处理函数的第二个参数的类型,并将其命名为e

2.4.9.4. 不要在事件处理函数中使用两个以上的参数

2.5. 字段的设计

2.5.1. 不要提供公有的或受保护的实例字段

2.5.2. 要用常量字段来表示永远不会改变的常量

public struct Int32 {

publicconst int MaxValue = 0x7fffffff;

publicconst int MinValue = unchecked{(int)0x80000000};

}

2.5.3. 要有公有的静态只读字段来定义预定义的对象实例

public struct Color {

publicstatic readonly Color Red = new Color(0x0000FF); //预定义对象-红色

publicstatic readonly Color Green = new Color(0x00FF00);//预定义对象-绿色

publicstatic readonly Color Blue = new Color(0xFF0000);//预定义对象-蓝色

...

}

2.5.4. 不要把可变类型的实例复制给只读字段

避免浅度不可变误解为深度不可变

2.6. 扩展方法

使用this修饰符来增加一个现有类的方法

2.6.1. 避免草率的定义扩展方法,尤其是为别人的类型定义扩展方法

2.6.2. 考虑在下列场景中使用扩展方法

2.6.2.1. 为一个接口的所有实现提供相关的辅助功能,而且这些功能可以通过核心接口来表达。

面向接口的切面编程

如果一个接口,有很多实现类,而你想为所有这些类所实现的接口,增加一个统一的方法,但是不想挨个实现类去写代码,就可以用此功能。但是要注意,这种新增的方法,只能使用这个接口所公开的功能。

如为所有的IEnumerable<T>的实现类增加了一个LINQ的功能(很多方法)

2.6.2.2. 如果增加一个实例方法会引入对其他类型的依赖关系,而该关系会破坏依赖息的管理规则,那么应该使用扩展方法

System.Uri -> String

System.UriString.ToUrl() 方法会违反依赖方向。因此需要:

System.Uri Uri.ToUri(thisstring str)

2.6.2.3. 避免为System.Object定义扩展方法

2.6.2.4. 不要把扩展方法和被扩展的类型放在同一个名字空间中——除非为了把方法增加到接口中,或者为了对依赖关系进行管理

2.6.2.5. 避免在定义两个扩展方法时使用相同的签名,即时他们位于不同的名字空间中

2.6.2.6. 如果被扩展的类型是接口,而且该扩展方法的设计目的就是要用于多数的情况甚至是所有的情况,考虑把扩展方法和被扩展的类型放在同一个空间中

2.6.2.7. 不要把实现某个特性的扩展方法放在一个通常与其他特性相关联的名字空间中。

2.6.2.8. 避免使用太宽泛的名字(如Extensions)来给扩展方法专用的名字空间命名,要使用更具描述性的名字(如Routing)

2.7. 操作符重载

2.7.1. 除非类型像个基本(内置)类型,否则别用操作符重载

2.7.2. 考虑在让人感觉应该像基本类型的类型中定义操作符重载

比如System.String的operator==和 operator !=

2.7.3. 要为表示数值的结构定义操作符重载

比如System.Decimal

2.7.4. 不要在定义操作符重载时耍小聪明

2.7.5. 操作符应该对定义它的类型进行操作

C#编译器强制

2.7.6. 要以对称的方式来重载操作符

== vs !=

< vs >

2.7.7. 考虑为每个重载过的操作符提供对应的方法,并用容易理解的名字命名

有一个对应操作符的方法名官方列表

operator-()

Subtract()

2.7.8. 重载==

相当复杂,见Object.Equals()规范

2.7.9. 类型转换操作符

2.7.9.1. 除非有明确的用户需求,不要提供

2.7.9.2. 不要在定义此操作符时超越类型所在的领域

2.7.9.3. 不要提供隐式类型转换操作符,如果会丢失精度

2.7.9.4. 不要从隐式的强制类型转换操作符中抛出异常

2.7.9.5. 如果对强制类型转换操作符的调用会丢失精度,而该操作符承诺不丢失精度,要抛出System.InvalidCastException

2.8. 参数的设计

2.8.1. 要用类层次结构中,最接近基类的类型作为参数的类型

2.8.2. 不要使用保留参数

2.8.3. 不要把指针、指针数组以及多位数组作为公有方法的参数

这些类型作为参数难以被正确使用

2.8.4. 要把所有输出参数放在以值方式和引用方式传递的参数后面

2.8.5. 要在覆盖成员或者实现接口成员时保持参数命名的一致

2.8.6. 枚举和布尔值的选择

2.8.6.1. 要用枚举,如果不这样做会导致参数中有两个或以上的布尔类型

布尔类型难以被正确理解其含义

2.8.6.2. 除非百分百肯定绝对不需要两个以上的值,否则不要使用布尔参数

2.8.6.3. 考虑在构造函数中,对确实只有两种状态的参数,以及用来初始化布尔属性的参数,使用布尔类型

2.8.7. 参数的验证

2.8.7.1. 要对传给公有的、受保护的或显式实现的成员的参数进行验证。如果验证失败,那么应该抛出System.ArgumenException或其子类。

2.8.7.2. 如果传入的是null而该成员不支持null,要抛出ArgumentNullException

2.8.7.3. 要验证枚举参数

不要以为用户传入的枚举参数值一定会在定义范围内。

public void PickColor(Color color){

if(color < color.Black || color < Color.White) {

thrownew ArgumentOutOfRangeException(...);

}

...

}

2.8.7.4. 不要用Enum.IsDefined来检查枚举的范围

负载很高,容易误用

2.8.7.5. 要清楚的知道传入的可变参数可能会在验证后发生改变

最好先制作一个副本,再验证和处理

2.8.8. 参数的传递

1)按值传递——不带限定符

2)引用传递——ref

3)输出参数——out

2.8.8.1. 避免使用输出参数或引用参数

值类型和引用类型的区别不容易被理解

2.8.8.2. 不要以引用方式传递引用类型

2.8.9. 参数数量可变的成员(方法)

2.8.9.1. 如果预计用户不会传入太多的参数,使用params关键字

2.8.9.2. 如果调用方的参数已经在一个数组里面了,就要避免使用params

比如byte[]参数

2.8.9.3. 如果要在方法中对数组进行修改,不要用params

2.8.9.4. 考虑在简单的重载中使用params

2.8.9.5. 要对参数进行合理的排序

2.8.9.6. 如果性能要求高,就不要专门设计多个不同参数个数的方法,而不仅仅是params方法

2.8.9.7. 要注意params参数可能是null

2.8.9.8. 不要使用varargs(省略号)方法

C++支持,但不符合CLS规范

2.8.10. 指针参数

大多数情况下不应该出现指针参数

2.8.10.1. 要为任何以指针为参数的成员提供一个替补成员,因为指针不符合CLS规范

2.8.10.2. 避免对指针参数进行高开销的检查

2.8.10.3. 要遵循与指针相关的常用约定

感谢大家的阅读,如觉得此文对你有那么一丁点的作用,麻烦动动手指转发或分享至朋友圈。如有不同意见,欢迎后台留言探讨。

原文发布于微信公众号 - 韩大(handa1740168)

原文发表时间:2015-12-22

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏JAVA高级架构

Java面试2018常考题目汇总(一)

一、JAVA基础篇-概念 1.简述你所知道的Linux: Linux起源于1991年,1995年流行起来的免费操作系统,目前, Linux是主流的服务器操作系统...

34110
来自专栏java一日一条

(转)Java中的System类

System类代表系统,系统级的很多属性和控制方法都放置在该类的内部。该类位于java.lang包。

912
来自专栏好好学java的技术栈

Java面试2018常考题目汇总

Linux起源于1991年,1995年流行起来的免费操作系统,目前, Linux是主流的服务器操作系统, 广泛应用于互联网、云计算、智能手机(Android)等...

1123
来自专栏CRPER折腾记

ES6折腾记- 模板字符串

总体来说,模板字符串的出现了,让我们的字符串拼接写的更加优美了;相当简易实用;但是这货并不是万能的,有部分unicode编码字符会造成编译报错

963
来自专栏章鱼的慢慢技术路

《算法图解》第二章笔记与课后练习

17510
来自专栏wym

18年暑假多校赛第一场 1002

http://acm.hdu.edu.cn/showproblem.php?pid=6299

831
来自专栏章鱼的慢慢技术路

《算法图解》第二章笔记与课后练习_选择排序算法

1583
来自专栏ShaoYL

OC内存管理

3719
来自专栏企鹅号快讯

如何写好python代码

写代码好比画画,好的代码就像一件艺术品,美观、可读性高,让人看着舒服。代码是写给人看的,不是写给机器看的,遵守一定的代码规范很重要,就像写作文需要总分总结构,这...

3947
来自专栏帅小子的日常

Javac的实现过程

1252

扫码关注云+社区