• 【转帖】设计模式学习总结


    引子

    刚开始学习设计模式的时候,感到这些模式真的非常抽象。今年下半年以来,随着我们组工作重点的转移,以及我在小组中角色的变化,我开始有条件提出自 己对新系统的设计想法。在设计过程中,我发现了很多设计模式的用处,也确实应用了很多设计模式,这让我越来越感到设计模式的重要性,因此我写了这十余篇专 门介绍设计模式的文章,作为我的学习笔记。

    《设计模式——可复用的面向对象软件的基础》(有趣的是,梅宏一再在组会上强调应该译成重用)中介绍了一共23种设计模式,我一共写了19个设计模 式(其中三个和在一篇文章中),余下四个,考虑到该模式的应用范围我就没有介绍。在写这些文章时,其中的很多例子都是我在实践中提炼出来的,当然也有很大 一部分是《设计模式》中的例子。不过,这四个人(四人团)生活的年代里现在已经很远了,所以它们的例子也很古老。

    让我们更加设计模式

    设计模式是个好东西,它给出了很多设计中的技巧与思路,对于很多优秀的设计,它加以总结与提炼。设计模式并非四人团拍脑瓜想出来的,而是他们搜集了其他人优秀的设计,加以整理出来的,他们不是这些模式的创造者,仅仅是整理者。

    应用设计模式会给我们带来很多好处:软件将变得更加灵活,模块之间的耦合度将会降低,效率会提升,开销会减少。更重要的,设计模式就好像美声唱法中的花腔,让你的设计更加漂亮。总的来说,设计模式似乎将软件设计提升到艺术的层次。

    设计模式已经被广泛的应用了,在现在很多的图形界面框架都使用了MVC模式,大量跌代器模式的应用,彻底改变了我们对集合的操作方式。不仅如此,应用了设计模式的设计,往往被看成为优秀的设计。这是因为,这些设计模式都是久经考验的。

    模式不是模型

    在学习和使用设计模式的时候,往往出现一个非常严重的误区,那就是设计模式必须严格地遵守,不能修改。但是设计模式不是设计模型,并非一成不变。正 相反,设计模式中最核心的要素并非设计的结构,而是设计的思想。只有掌握住设计模式的核心思想,才能正确、灵活的应用设计模式,否则再怎么使用设计模式, 也不过是生搬硬套。

    当然,掌握设计模式的思想,关键是要仔细研究模式的意图和结构。一个模式的意图,就是使用这个设计模式的目的,体现了为什么要使用这个模式,也就是 需求问题。这个模式的结构,就是如何去解决这个问题,是一种手段、一种经典的解决方法,这种解决方法只是一种建议。两个方面结合起来,明白为什么需要设计 模式,同时明白了如何实现这个模式,就容易抓住模式的本质思想。

    在抓住意图和结构的基础上,实践也是掌握设计模式的必要方法。当然,设计模式必须在某个场景下得到应用才有意义,这也是为什么《设计模式》中提供了 大量的例子用来说明模式的应用场景,这实际上为读者提供了一种上下文环境。学外语不是要强调“语言环境”么,学习设计模式也是这样。

    不要设计模式

    看到网上很多人在讨论设计模式,他们确实很有***,满嘴都是模式的名字,恨不得写个Hello World都要应用到设计模式。设计模式确实是好东西,但是,中国有句古话叫作物极必反,即便是按照辩证法,事物总要一分为二的看。

    我们说设计模式的目的是为了让软件更加灵活,重用度更高。但是,某种意义上,设计模式增加了软件维护的难度,特别是它增加了对象之间关联的复杂度。

    我们总说,重用可以提高软件开发的效率。如果你是大牛,你自然希望你的设计可以被反复使用10000年,那就是:当世界毁灭的时候,你的设计依然存 在。然而,现实是一个系统的设计往往在5年之内就会被抛弃,这是因为:1,软件技术产生了新的变化,使用新的技术进行的设计,无论如何都比你的设计 好;2,硬件环境发生了很大变化,你的设计里对开销或者效率的追求已经没有意义了;3,新的大牛出现了,并且取代了你的位置。

    应用设计模式会导致设计周期的加长(因为更复杂了),但是很多项目还在设计阶段就已经胎死腹中,再好的设计也没有发挥的余地。当我们向设计模式顶礼膜拜的时候,我们还必须清醒地看到软件生产中非技术层面上的东西往往具有决定性作用。

    理想固然崇高,但现实总是残酷的。如何看清理想与现实的界限,恐怕是需要我们在实践中不断磨砺而体会出来的。在看完设计模式后,不妨反问以下自己,这些模式究竟能给你带来什么?

    Interpreter、Iterator、State模式

        Interpreter模式:这个模式主要试图去解释一种语言。如果你学过形式语言,那么这个模式对你来说是多余的。

        Iterator模式:这个模式试图隐藏集合的内部表示,又同时可以使用户依次访问集合中的元素。现在STL和Java的跌代器就是应用这个模式的结果。

        State模式:这个模式的意图是允许对象在其状态改变时修改其行为,好像对象改变了。这个模式的应用场景是当对象的行为依赖于对象的状态时。为了实现这 个模式,我们可以为每个状态下的行为实现一个类,当对象的状态发生改变,它调用不同状态对象的实例方法。注意,以前可能需要使用switch或者if语句 进行分支转换,现在则利用多态机制完成。

     Flyweight模式

        这个模式利用共享有效的支持大量的细粒度的对象。比如,编辑软件中,一篇文章有很多个字符,我们可以对每个字符对象生成一个对象,如果这篇文章有几M个文 字,那么对象的数量肯定是不能容忍的。使用Flyweight模式,我们将所有的文字对象共享起来,文章中的字符仅仅是指向共享池中的某个对象的索引。

        在这里要搞清楚一件事情,利用Flyweight模式不会有效地减少信息的数量(也就是软件的空间开销),因为无论是否共享,表达这么多信息所需要的编码数量是一定的,所以开销不会大幅减小。只是,这个模式会减少系统中对象的数量,因为大量的对象会被共享。

        在编辑软件中,字符对象被共享,那么一篇文章中的文字,可以按照段落、格式等等进行结组,一组文字构成一个对象,这样对象从单个文字变成一组文字,数量大幅减少。

        在使用Flyweight模式需要注意的一点,由于对象被共享了,因此这些对象没有各自的属性,那么根据上下文环境,我们在使用这些对象的时候,必须向它传递一些参数。在编辑软件中,这些参数可能就是字体、字号、颜色等等信息。

        使用Flyweight模式还有一个好处,那就是我们可以在不修改系统的情况下增加享元。

    Command模式

        Command模式,将一个请求封装为一个对象。这样,你可以向客户端发送不同请求的参数,排队或记录请求,同时可以支持不能执行的请求。

        在软件中,不同的模块、对象之间经常会各种调用,或者我们称之为请求。传统的方法,我们将请求实现为函数调用。这样做是最简单的方法,但却在无形之中增加 了模块之间的耦合度。当请求发生很大变化的时候,系统将变得很难维护。与此同时,当服务端(接受请求的一端)增加或者删除一个请求的时候,按照传统的方 法,客户端(发送请求的一端)也必须重新编译(这一点在删除请求的时候最明显),这样系统才能正确运行。

        使用Command模式的一个核心思想就是,服务端提供一个统一的请求处理接口,客户端则通过调用接口向服务端发送请求,这些请求被封装成对象的形式(或 者其等价形式)。在《设计模式》中,“四人团”并没有强调统一接口的事情,它强调了另一个方面,那就是封装请求。事实上,封装一个请求总是要求有一个地方 来接受和处理这个请求的,这个地方实际上就是统一请求接口。

        在《设计模式》中,请求被封装成一个Command对象,这个对象保存着请求类型、参数等信息,服务端收到这个命令后就会执行Command对象中的Execute()函数,这个函数具体实现了真正的操作。这种实现方法可以保证增加新的请求而不必重新编译服务端。

        我个人认为,Command模式的另一个形式就是在服务端实现各种操作,Command对象只是负责指明请求的类型,这样,当服务器端发现请求不正确时, 可以忽略该请求。和上一种形式相比,这种形式更加简洁(因为可以不真正实现Command对象,在C++中可以使用不定参数实现),但是缺少灵活性。

        Command模式使得记录请求成为了可能,我们可以捕获系统中的请求对象,记录他们。

    Composite模式

        Composite模式的意图是“将对象组合成树形结构表示‘整体-部分’的层次结构。Composite使得用户对单个对象和组合对象的使用更具有一致性”。

        在Word中我们经常会将一些图元进行“组合”,组合以后的图形还可以向简单图元那样进行移动、变形等等操作;除此以外,在Word中,我们对于一个字 符、一个词组、一句话、一个段落,甚至是整篇文章的操作是相同的,我们都可以进行剪切、复制,进行字体与大小的调整,进行颜色的变换。这些例子都是 Composite模式的实例,我们将简单的元素组合成复杂的元素,然后还可以像操作简单元素那样操作组合元素。

        Composite模式将子元素组织成树型,实际上,组织成图型也没有问题。用户总是喜欢组合简单元素,一方面,用户可以通过这样的组合来进行抽象,另一方面,用户可以通过组合化简繁琐的操作。Composite模式在各种可视化编辑软件中应用得最为广泛。

        另一使用Composite的经典例子是Java的Swing系统。所有的Swing组件都是继承自一个叫做JComponent的接口,因此,我们对一 个JFrame的操作和对一个JButton的操作是一样的。这同时也使得,JFrame在管理自己的子元素时,它不需要知道他们是一个JButton还 是一个JPanel,对它来说,这只是一个JComponent。

        实现Composite模式的关键是良好设计的接口,人们应该对可能的元素(简单的、组合的)进行分析,并设计出通用的操作。尽可能的保证接口操作对所有元素都是有意义的,否则就应该将那些只对部分元素有意义的操作下放到子类中。

    Proxy模式

        按照“四人团”的说法,Proxy模式可以为控制另一个对象而提供一个代理或者占位符。
        这个模式可以使我们在真正需要的时候创建对象,如果我们不需要这个对象,Proxy模式会为我们提供一个占位符。如果我们有大量这样消耗很大的对象的时 候,我们就可以使用Proxy模式,初始情况下,Proxy模式只会提供占位符而不会真正创建对象,但是对于使用者来说,他看到是真正的对象而不是一个代 理。一旦使用者需要获得或者更改对象属性的时候,Proxy模式就会创建该对象,在此之后,我们就可以通过代理访问真正的对象了。
        在Word里面应该是使用了Proxy模式。打开一篇含图的很长的文档时,大部分的图片都不会被载入,而仅仅是提供占位符,只有当用户准备察看这一页的时候,代理才会真正载入图片。
        和Singleton模式一样,Proxy模式都是保证我们可以按需分配对象,不同的是,Singleton模式还会保证在全局范围内使用同一个对象实例,而Proxy则没有这个功能。

    Visitor模式

        按照“四人团”的说法,Visitor模式的意图为:将元素的操作表示成一种结构。这样Visitor模式可以使你在不修改元素结构的前提下增加新的操作。

        考虑一个链表,我们需要一个求得最大元素的操作,这个操作可能是遍历每个节点,然后求的最大值。这个时候我们又需要一个为每个元素加1的操作,这个操作还 需要遍历每个节点,同时将每个元素加1。与之类似,还会有很多其他的针对元素操作,他们的特点都是要遍历链表。这个时候可以使用Visitor模式,结点 类负责依次遍历,并调用Visitor类中的函数,而Visitor类的具体实现则负责完成功能。

        这里需要注意的是,Visitor类只能是一个接口,针对不同的操作需要有不同的具体实现,针对不同的具体元素,需要设计不同的操作。每个元素负责选择自己应该调用的操作,Visitor子类负责实现具体功能。

        一个已知的应用是SmallTalk-80的编译器,在编译时,编译器需要建立一棵语法树。在这个时候,它使用了Visitor模式,针对不同的操作,比 如:类型检查、代码生成等操作实现不同的Visitor具体类,Visitor类中针对不同的节点类型提供不同的操作接口,具体的节点负责选择调用哪种接 口,这像是一种回调操作。

    Observer模式

       按照“四人团”的说法,Observer模式的意图是“定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新”。

        实际应用的例子是,比如建模工具中,若干条线形元素附着在一个块状元素上,当块状元素的大小、位置发生变化,那些线形元素也需要进行改变,这个时候我们就可以应用Observer模式,在块状元素和线形元素之间建立一对多的关系,并利用这一模式进行维护。

        Observer模式首先构造一个Observer类,在这个类中具有一个update函数。被依赖的对象拥有它,依赖的对象被注册到Observer 中,当被依赖的对象发生变化的时候,就调用update函数更新所有依赖它的对象。更新的方式由update函数具体实现。

        还有一个现实中的例子,各个部门之间进行通讯,当一方发出新的信息时,按照传统的方法它必须告诉所有其他部门。如果使用Observer模式,那么产生新消息的一方只需要告知Observer,由Observer通知其他方面。

    Template Method模式

       Template Method模式的意图是:“定义一个操作中的骨架,而将一些步骤延迟到子类中。这使得子类可以不改变一个算法的结构即可以重定义该算法的某些特定步骤。

        这一模式和Strategy模式似乎和相似,但是他们的关注点不同。策略模式主要用于算法的替换,但是模板方法模式主要用于算法中特定步骤地替换。一个应 用模板方法模式的例子是数据库操作。对于数据库操作可以有很多中,比如查询、更新。查询又可以分为连接数据库、发送请求命令、解析结果等等步骤。对于不同 的数据库,比如Oracle和SQL2000,它们连接数据库、命令格式可能有所不同,但是就查询和更新着两个操作来说它们的步骤是相同的。这个时候,我 们可以应用模板方法模式,为查询、更新操作建立一个抽象的算法,具体的步骤交给子类来实现。如果对于策略模式,我们替换的将是查询和更新着两个操作。

        但是,将Template Method模式和Strategy模式进行类比是危险的,这两个模式有着很多重要的不同,但这些不同却又是十分的细微,只能意会不能言传。

    Factory Method模式

       这一模式的意图是:“定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method是一个类的实例化延迟到其子类。”

        这一模式的关键是掌握“何时应用这一模式”,事实上我觉得这也是所有设计模式的关键。一个已知的应用就是MFC中关于Document和Frame之间的 关系。通常在生成一个多文档程序时,VC会为你创建一个Frame类和Document类,你的Frame类可以用来相应OnFileNew消息,然后创 建一个Document对象。但是对于Windows的消息系统来说,它并不知道用户程序中创建的Document类有什么特性,对于它来说,它所看到是 CFrame对象和CDocument对象。Factory Method模式可以保证其他对象不需要知道具体对象的类型而管理这些对象,这一模式通常用于制定框架。

        这一模式和Abstract Factory模式很相像,事实上Abstract Factory模式可以由一系列Factory Method模式实现。

    Strategy模式

        Strategy模式的目的就是“定义一系列的算法,把他们一个个封装起来,并且使他们可以互相替换。”

        如何理解这一模式,首先看下面这个场景:一组数据进行排序,我们可以选择很多中排序算法,这个时候我们定义一个排序策略,然后每个排序算法实现一个具体策略,这样用户就可以在几个不同的排序算法中随意选择和替换了。

        当然,上面的例子中使用策略模式似乎多此一举,那么假设游戏中的敌人的AI,根据玩家的设定可以有不同的级别。在这种情况下,使用策略模式就是十分必要的了。

    Bridge模式

        按照“四人团”的说法,Bridge模式的意图是:将抽象部分与他的实现部分分离,使得他们可以独立的变化。你一定会感到一阵眩晕,不明白这是什么意思。

        首先应该说明的是“抽象”与“实现”的含义。在刚才的那句话中,“抽象”与“实现”并不是我们在描述类结构时所说的“接口”与它的“实现”,或者在 Java中抽象类与他的实现。在这里,“抽象”与“实现”只得是某种工作,“抽象”是说如何完成这项工作,“实现”是说“抽象”中所用的步骤的实现。

        一个例子可以很好的说明“抽象”与“实现”的关系。我们编写一个游戏,这个游戏有两个版本,DX版本和OpenGL版本。我们如何编写这两个版本呢?一种 方法是我们在这两个引擎上开发两套独立的游戏,但这显然不是最好的方法。另一个选择是我们将游戏的“抽象”部分与“实现”部分分离,开发一套“抽象”部 分,开发两套“实现”部分。那么什么是游戏的“抽象”部分?很显然就是游戏的绘图(也许用更专业词汇的应该是:渲染)过程,例如我们如何渲染游戏的人物, 这个人物可能是由很多个多边形组合而成的,我们按照一定的方法渲染之后,就可以画出一个人物来。这一部分就可以看作是“抽象”。那么另一方面就是“实现” 部分,在上面的例子中,“实现”部分就是如何绘制基础的线条、填充颜色,甚至是初始化屏幕等等。这些“实现”和具体的引擎密切相关。

        为什么说我们可以将“抽象”和“实现”分离,使得他们可以各自变化呢?假设现在要开发新的游戏,或者这个游戏升级了,在其中出现了新的人物,那么“抽象” 部分就发生了变化,但是具体“实现”没有变化,因此这个游戏还可以继续在你的计算机上运行。另一方面,如果游戏需要进行移植,目标平台的图形系统发生了变 化,你可能需要使用新的绘图引擎,这个时候,你只需要利用新的引擎实现基本的“实现”操作,原始的程序就可以在新的平台上运行(略去重新编译的问题)。

    Facade模式

        Facade模式的目的就是给子系统提供一个统一的接口。现在的软件都是按照模块进行划分,对不同的模块分别进行编程。但是在用户开来,不同的模块应该具有统一的接口,换句话说,我们应该可以通过统一的接口访问系统中的所有功能。

        有一个很典型的例子就是编译系统。通常我们将编译系统分解为:Compile和Link两个步骤。一个Compile又可以分解为词法分析、语法分析、语 义分析、中间代码生成等等步骤。对于用户来讲,我们不可能将这些模块分别提供给他们,让他们依次调用。相反的,我们应该提供一个统一的接口,使得用户可以 方便的使用各个功能,例如IDE 。

        Facade模式在强调模块化开发的同时也强调模块的统一,统一的接口也有利于子系统中模块内部的变化。对于开发大型系统来说,Facade模式是不可缺少的。

    Decorator模式

        按照“四人团”的说法,Decorator模式的意图是:动态的给一个对象添加一些额外的职责。值得注意的是,这个对象不知道他增加的是什么职责。

        这个模式的一个典型应用实例是:Java的流。一个文件流(Java.IO.File)用于读写文件,如果你想使用文件缓冲,你可在为File添加一个 BufferedInputStream或者BufferedOutputStream外观,这样这个文件流就具有了缓冲。再如一个Reader类,你可 以给他增加缓冲BufferedReader,然后你还可以给这个缓冲流增加一些格式化读取的能力。

        Decorator模式可以动态的增加对象的额外的职责,这也有利于将额外的功能分别实现,使得用户可以自由组合。

    Adapter模式

       有一天你在网上找到一个库,你打算把它应用到你的程序当中去,但是你发现这个库的函数不符合你的风格,你会怎么办?一个很简单的方法就是使用Adapter模式。

        Adapter模式的目的就是将一个类的接口转换为用户希望的接口,使得由于接口不兼容而不能一起工作的各个类可以一起工作。

        例如在一个软件里面可能使用了以前一个版本的类库。不幸的是这个类库的效率极高却和现在的接口不兼容,为了继续复用这个类库我们就可以使用Adapter模式,在原来的类库和现在的接口中间实现一个适配器,使得我们可以用现在的结构调用以前的类库。

        例如一个绘图程序(这种事情总是出现在这类程序中),以前的类库中提供绘制直线的方法DrawLine,但是新的接口要求绘图系统还要提供绘制矩形、折线 形的方法,为了复用这个类库,我们实现一个Adapter类,这个类中利用以前的绘图系统提供的方法实现了新的接口功能。

    Singleton模式 

       这可能是最简单的一个模式了,但是他的应用却是最多的。这个模式的目的就是保证一个对象只有一个实例,并且提供一个全局的访问点。

        那么这个模式的怎么实现呢?很简单,你首先必须为这个类设置一个指针(Java中是引用),然后提供一个方法用来获得这个类的实例。在这个方法中首先判断这个指针是否为空,如果是,则创建一个实例,否则直接返回这个指针。

        虽然我们可以提供一个全局访问点,但实际上这个模式也可以应用到局部。应用这个模式一个好处就是可以“按需分配”,同时也封装了对象的获取过程。不论如何,我觉得应该尽可能的应用这个模式,虽然这会让你感到很烦……

        这个模式在实现过程中可以进行变化,例如在Instance()方法上添加参数Boolean bAlloc,用于指定当实例不存在的时候是否进行创建。这样做是考虑到,有些时候我们获得实例的目的不是为了修改,而是为了读取。这个时候,返回一个空 实例和返回一个没有被修改过的实例在逻辑上是相同的。例如,这个对象是一个数组时,一个“空数组”和一个“空白的数组”是相同的。

    Builder模式

        按照“四人团”的说法,Builder模式的目的是:将一个复杂对象的构建与他的表示分离,使得同样的构建过程可以创建不同的表示。

        一个典型的例子是:文件的格式转换。假设一个RTF文件,我们可以将它转换成不同的格式,比如TXT、DOC、PDF等等。在这些目标格式的文件中,有些 文件格式中保留文本字体(比如DOC),有些可能不保留(比如TXT)。当我们开始转换过程时,按照RTF文件自己的格式进行分析转换。转换的过程是一样 的,但是不同的目标文件格式对于不同的转换请求的处理是不同的,比如TXT文件转换将会忽略所有的文本格式控制符,但是DOC文件将会把RTF的控制符转 换为自己的控制符。应用Builder模式,我们可以实现不同的具体生成器,对于相同的请求产生不同的结果。

        Director负责向Builder发送不同的生成请求,在刚才的例子中,RTF文档的分析器可以看作是Director,文档转换器可以看作是 Builder。另一个可以想到的例子是一个编成开发环境,我们可能对源程序进行语法分析,但是分析的目的可能不同,有的分析可能是用来生成代码,有的可 能是用来形成智能感知。不论如何,语法分析的过程是相同的,因此将语法分析看成一个Director,代码生成和形成智能感知看作是两个 ConcreteBuilder,对于相同的分析请求产生不同的动作。例如当分析器发现一个函数以后,就会向Builder发送请求,如果这个 Builder实例是代码生成器,那么它可能会记录这个函数的入口地址,如果这个Builder实例是智能感知器,那么它可能像数据库中插入这个函数的信 息。

    Abstract Factory模式

        这个模式的关键就是设计一个AbstractFactory接口,这个接口提供了一系列抽象函数用来创建各种对象。这个模式的意图就是使用一个统一的接口用以创建不同具体对象。

        假设这样一个场景,在不同配置的计算机上完成显示和打印任务,对于高配置的计算机我们使用高分辨率的驱动,在低配置上的机器使用低分辨率的驱动。这个时 候,我们可以将显示驱动看成ProductA,打印驱动看成ProductB;每种驱动具有两种分辨率,分别对应高低两种配置。这个时候在创建(配置)系 统驱动的时候,我们就可以使用AbstractFactory模式,为高低两种配置实现两个具体工厂类,分别用于创建对应的驱动。所有的用户不用关心当前 是什么配置的计算机,它只需要调用统一的抽象工厂接口就可以获得对应的驱动。

        使用这个模式还需要注意的是,产品对象也必须具有良好的设计,以至于用户不需要关心具体的产品是哪种类型就可以通过抽象产品的接口使用这个产品。使用这个 模式可以令用户无需关心具体环境,降低代码耦合度,使得程序结构更加清晰。缺陷是当每个产品的具体实现有很多种时,实现的具体工厂类的数量会迅速膨胀,而 且它不能对环境的改变进行立即响应。


    本文章里没说的4种模式是
    Prototype[原型模式]
    Chain of Responsibility[职责链模式]
    Mediator[中介者模式]
    Memento[备忘录模式]


    原型模式根据语言特性 多数使用clone方法底层实现
    职责链模式重点在于链的概念,操作者权限的移交
    中介者模式是把多个类之间的蜘蛛型网状结构重组成面向中介者的星型网状结构
    备忘录模式与原型模式类似,它主要是把自身的状态副本交由专门的第三方类来管理,以封装隐藏细节的实现
     
  • 相关阅读:
    SQLServer之创建AFETER DELETE触发器
    SQLServer之创建DML AFTER UPDATE触发器
    SQLServer之创建DML AFTER INSERT触发器
    css制作环形进度条
    curl教程2
    windows下更改鼠标滚轮方向
    curl tutorial with examples of usage
    为npm设置代理
    日期类型的input元素设置默认值为当天
    加盐密码哈希:如何正确使用
  • 原文地址:https://www.cnblogs.com/goed/p/2238655.html
Copyright © 2020-2023  润新知