zoukankan      html  css  js  c++  java
  • 剖析 .Net 下的数据访问层技术

    zhangxuefeng(原作) CSDN

    自从 .NET 真正走入开发人员那天起,“效率”两个字就一直成为众多程序员津津乐道的话题。无论是从开发模式(Cross Language)、系统框架(.NET Framework),还是各种使用方便的工具(VS.NET),无一不体现出了它的胜人一筹。

    同时,在另一方面,.NET 是否可以真正胜任企业级应用(Enterprise Application)开发的重任,却依然争论不断,褒贬不一。

    通常来说,对于一个企业级应用,需要考虑的方面很多,如安全、性能、伸缩性、易用性等。在本文中,作者更愿意与大家一起探讨 .NET 下数据访问层的相关技术,这可能是在多层架构(n-Tier Architecture)诞生之日起就受到广泛关注的敏感话题,而对于大部分开发人员来说,这也可能是项目中最让人沮丧的部分,甚或引起争议最多的部分。

    在以下论述中,为统一起见,作者暂时将数据访问层简称为DALData Access Layer)。

    l       分析问题

    简单统计分析后,就不难发现,DAL之所以让人畏惧,并非出于技术本身的问题,甚至恰恰相反,很多开发人员认为这是最没有技术含量的部分之一(就作者经历的大小项目来看,该层所占的开发时间一般较短,也是很多开发人员不愿意承担的“苦差”),只是架构需要或者某些思想作怪(如:为DALDAL)才加入了这所谓的第四层(传统三层架构并没有提出DAL思想)。

    DAL的提出,确实对传统的架构模式提出了巨大挑战,加入的目的肯定也是希望借其进一步提高生产效率,在这种模式下,理想情况是:大部分开发人员从此摆脱DBA之苦,甚或彻底断绝与数据库的直接关系,SQL之痛将离我们而去,整个OO世界从此清静。

    不过,理想归理想,能否成为现实则需通过项目检验。

     

    接下来,作者试图分析比较流行且较有代表性的几种解决方案,看看能否从中得出一些有价值的结论,并为我们今后在设计与实现DAL时提供一些借鉴。

     

    u     ADO.NET

    首先,提到.NET下的DAL,立刻映入眼帘的就是ADO.NET

    没错,几乎所有的DAL解决方案(请允许作者使用Solution而非

    Framework)都必须从它发展而来,没得选择,这也是具有.NET特色的

    实现方式(相比较J2EE)。

    排除商业因素及CLR本身的需要,ADO.NET真正带给我们的东西

    不多,值得一提的也就DataSet(就作者经历的项目来说,使用更多的是

    DataTableDataView)。从微软早期的内存数据库(Memory Database

    鲜有人问津到今天的DataSet大行其道,这其中的曲折实非片言只语所

    能道尽,总之,有一点可以肯定,正是有了DataSet这种选择,.NET

    DAL才能象今天这般百花齐放,大家的思路才能更趋开阔。

     

    Duwamish

    这方面有很多好的Sample,最经典的莫过于微软大力推荐的企业级开发套餐:Duwamish

    对于希望学习.NETDAL设计的朋友,这是一个不错的起点,这

    方面的完整剖析,大家可以参考CSDN开发高手,2003.11,本文不

    再赘述。

    作者自己参与的一个项目中就使用了Duwamish方案,当时限于工

    期,感觉这是一个很好的参考,没做深入分析就开始设计了。现在回想

    起来,发现还是有很多不足之处。

    举个简单的例子,Duwamish方案中并没有考虑Cache Management

    而这对于企业级应用来说,某些时候就是一个不得不考虑的问题;另一

    方面,虽然Duwamish中告别了SQL语句(全部采用存储过程实现),

    但数据库痕迹依旧十分明显,比如:某些字段名称的定义,关联表名称

    的定义等等。

    还有一个十分头疼的问题是在开发过程中体现出来的。一开始,那

    些比较简单的数据表还比较容易实现,到了一些包含相互关系的数据表

    时,我们的DAL工程师就感到了压力,到后来,几乎又做了一遍DBA

    在数据库建模时早已做过的工作,只不过,这次将数据库脚本换作了

    C# 实现(或者说:将数据库结构换成了表面上具有OO特色DataSet

    而已。

    可能,Duwamish的实现比较经典,但在实际应用中,有时并不意

    味着Best Practice。就拿我们的项目来说,虽然成功交付,但无论从模

    型复用角度,还是开发效率来说,都不能算很成功。套用一句流行语:

    其实我们可以做得更好!

     

    PetShop

    ADO.NET上另一个值得参考的DAL实现就是鼎鼎大名的PetShop

    当然了,与Duwamish相似,名气大未必真的实用。PetShop虽然

    弥补了Duwamish在某些方面的不足,例如:通过Factory支持多种数

    据库存储,引入了Cache机制,提供了更为便利的SQL Helper,但也同

    时带来了另一些问题。其中,最麻烦的就是SQL语句的引入,而且还

    是针对不同数据库存储的不同SQL语句(主要是SQL ServerOracle

    的参数表示方式不同)。

    另一方面,PetShop虽然没有使用DataSet而代之以更为简洁的普通

    实体对象(Model),但它还是将DataReader的结果转换到了包含实体对

    象的列表集合中供离线使用,从这个意义上说,可谓换汤不换药。甚至,

    在某些场合,例如:需要进行数据过滤,或者在主从数据间导航,反而

    更为不便(此时,简单的Collection或者List是无法满足需求的,DBA

    DAL开发人员只能再提供其它的方法来达到目的)。

     

    从上述两个例子中,我们可以看出,即使在微软的开发团队中,也

    没有能够在DAL这个问题上达成一致。这方面的更详细信息,有兴趣

    的朋友可以参考如下文章:

    http://www.microsoft.com/china/community/Column/67.mspx

     

    实战

    上面剖析的两个解决方案,让我们看到了它们各自的优势与不足,而企业级应用的复杂环境也不太可能要求一个放之四海而皆准的框架就能解决所有难题,因此,只能根据具体情况具体分析。

    作者曾经参与一个(.NET)大型外包项目的开发工作,有幸一睹其DAL的设计思想,深感震撼,在此与各位朋友一起共同探讨。

    SQL Server所带Northwind数据库为例,如下就是一段基于该DAL的调用代码(作者做了一些名称上的调整):

     

    // 根据EmployeeID返回其Title

    boEmp = new EmployeeDAL();

    boEmp.Keys[“Emp_ID”] = 1; // 注意:实际字段名为:EmployeeID

    boEmp.Select();

    string strTitle = boEmp[“Emp_Title”]; // 注意:实际字段名为:Title

    ……

    // 根据 City 返回所有符合条件的 Employee

    boEmp = new EmployeeDAL();

    boEmp.Keys[“Emp_City”] = “Seattle”;

    boEmp.Select(); // 注意:该方法与上面的调用完全相同

    DataTable dtEmp = boEmp.Table;

     

    如果不考虑对象创建(可以采用Object Pooling或者Cached Object)以及调用后的处理,实际的代码只有两行!

    更让人吃惊的是,上述EmployeeDAL类没有任何真正意义上的实现代码,仅仅是声明了类名,然后从一个通用基类继承而已!!

    最优雅的地方还不在于此,实际上,就算在那个基类中,也根本看不到SqlConnection或者OracleAdapter之类的帮派之争。

    相信大家也猜出来了,没错,它是借鉴了PetShop的实现,采用了Factory模式来保证DAL可以适用于不同的数据库存储。不过,这种实现与PetShop还是有很大的区别:至少,它没有产生不同的SQL语句,更没有出现不同的参数调用方式(SQL Server中一般使用“@”符号,Oracle中一般使用“:”符号),所有帮派一视同仁!

    这其中,当然得益于Factory的实现技巧,但更重要的因素还在于设计方式的精妙。其实,在.NET Framework中,已经提供了这种设计方式的基石,说白了,就是System.Data中的那些Interface(如:IDBConnectionIdataAdapter等)。

    在这样的设计基础上,我们针对每一个DAL类,就不再需要为不同的数据库存储提供不同的数据存取实现了。例如:在PetShop中,针对订单数据需要实现Order类,很自然的,系统为SQL ServerOracle分别实现了Order类并使用不同ProviderSqlClientOracleClient)提供的方法进行操作。而在实际调用时,PetShop通过Factory模式动态创建真正的Order类并激活相应的方法,一个面向不同数据库存储的方案就跃然纸上。

    其实,PetShop这种方案已经比较灵活了,如果更能省去“撰写不同Order类”之苦,那就真的送佛送到天了J。而所有这些功能,在作者所参与的这个项目中,已经完全搞定了!

    至于上面的“EmployeeDAL(当然,包括其它所有DAL类)没有任何真正实现代码”,只不过玩了一个小小的配置技巧而已:将不同的DAL类与相关的Stored Procedure(请注意:不是TableView)按照Namespace分别存储到XML文件中。

    可能大家已经看出来了,理论上,甚至只需要一个DAL类就可以完成上述所有的工作!但在实际操作中,不同的DAL类可能还是有一些数据处理上的细微差别(比如:数据校验,格式转换等)。

    总的来说,在这样一个大项目中,不可能要求所有开发人员(除了DBADAL Framework Developer)都去了解ADO.NET的方方面面,虽然作者对此颇有研究,但在这个项目中,却从头至尾只用到了两个类DataTableDataView(甚至连Transaction都无需了解)!

     

     

     

    其它

          结束ADO.NET剖析前,不得不提提DataReaderDataSet间的兄弟

    之争。

          就作者所看过的资料,几乎所有的都建议实际情况具体分析,剩下

    很少很少的则全凭个人习惯决定。

          在学习ADO.NET时,作者也是抱着这样的想法,并反复牢记资料

    上总结的那些条款(就像当年学习GOF 23条时那样,几乎可以倒背如

    流了J),想到终有一日也可在ADO.NET下大展神威了。

     

          可惜现实不随人愿,连续做了几个项目,无论规模大小,竟然全部

    采用了DataSet解决方案!

          此时,再回头看看学习ADO.NET时打开最为频繁的PetShop项目,

    两相一比较,这才看出些许端倪。

     

          简单的说,PetShop采用了如下这种“曲线救国”的方式来实现数据

    交换:

     

          DataReader获取数据 => 创建数据实体类 => 根据字段类型填充

    据实体类 => 将数据实体添加到列表类中(仅针对返回超过一条数据的

    场合)

          (补充:采用数据实体类或者集合类可以比较方便的实现Cache Manament

    而普通的DataReader由于其数据读取方式限制,无法满足这种需求)

     

          这个过程与DataAdapter.Fill() 所所产生的效果大同小异,只不过,

    Fill() DataAdpater自动创建DataReader去获取数据,之后创建

    DataTable(相当于数据实体类),并根据字段类型填充DataTable,当然

    ,如果可能返回多条记录,DataTable完全可以处理,就没必要去实现列

    表操作了。

          可能读者马上产生了疑问:既然如此,PetShop中为何还需要数据实

    体类呢?

          这其中还是有一些差别的。

          首先,数据实体类是轻量级的structure,一般仅包含数据字段,没有

    什么操作方法,这比DataTable或者DataRow还是有一些性能上的优势

    (在数据量不大时可以忽略不计);另一方面,数据实体类的操作相对

    简单,不需要开发人员具备任何ADO.NET知识(其实就DataTable

    说,这也不算什么问题),点点属性就可以了。

          不过,根据作者的实践来看,这两方面似乎还不足以使人转而使用

    DataReader方案,理由列举如下:

         

    (1)    对于数据量较大的场合,可以采用分批读取的方式,这有点类似DataGrid的数据分页效果;

     

    (2)    对于简单的数据,实体类还能应付,一旦涉及关联数据,就只能另外撰写方法了。而所有这些,在DataSet中是非常容易处理的(对于企业级应用,大部分情况都需要处理比较复杂的数据);

     

    (3)    DataTable“天生”就支持数据集合操作,这样的特性比“集合+实体”的混合模式(PetShop)更容易控制,也更自然;

     

    (4)    实体类在声明时需要确定所有数据类型,当进行数据填充时,就需要DataReader再次关注实体所对应的数据类型,不能有丝毫差错!在这方面,DataTable就显得非常方便,操作时只需要一次类型关注即可;

     

    (5)    DataSet解决方案可以非常方便的支持序列化操作(如:RemotingWebServices),同时,与XML的关系更是亲密无比,这对于和其它系统的交互来说也是至关重要的。

     

    分析过一些技术和方案,相信读者朋友已有一些体会。值此收官之际,如果非要在这里提供一个“综上所述”,那作者的建议就非常明确:

     

    在企业级应用开发中,尽可能的采用DataSetDataTable / DataView+ Cache Management解决方案!

     

    其它开发中,只在如下4种情况才考虑使用DataReader(就作者经验来说,大部分使用DataReader都属第种情况):

     

    (1)    对资源要求比较苛刻的场合,这里的资源主要指内存和数据库连接;

     

    (2)    希望在读取数据库返回结果集时作自定义处理,例如:在读取一条记录后立刻终止处理,或者在读取时作计算操作。

    (提示:这种情况类似于XML中的SAXSimple API for XML)技术,无需一次性读入所有XML数据即可进行操作;相反的,DOMDocument Object Model)则要求必须装载所有XML数据后才能开始操作(MSXML4.0已开始允许只读取XML文档部分数据即可开始操作,这是后话)!

     

    (3)       只希望得到返回记录数或者返回记录的部分字段,如:

    string GetNameByID(int nID) //根据员工ID返回员工姓名,这里只需要

    // 读取姓名字段;

    (提示:这种情况一般可以通过执行特定的查询或存储过程直接解决)

     

    (4)    出于某些方面的考虑(例如:n-Tier系统中严格区分各Layer间的职责),无法(或者禁止)通过数据库本身进行查询过滤,这时就只有使用DataReader在读取时进行过滤操作!

    (提示:虽然DataView也能达到这种目的,但它的过滤前提是必须读取到所有返回数据,所以性能上不如DataReader!)

     

    u     O/R Mapping

    O/R Mapping的全称是:Object Relational Mapping,主要目的是在传统RDBMSOO Language之间建映射关系,从而使开发人员彻底脱离数据持久这片剪不断理还乱的苦海。

    关于O/R Mapping或者近来比较热门的O/X Mapping(大家可以参考“程序员,2004.01P86”),可能需要专门的文章进行详细论述,本文的目的主要是对现有方案的优缺点进行简单剖析以及提供一些实践中的参考信息。

    相比较J2EE平台,.NET下的O/R Mapping可谓没什么历史,至今还尚未有经过考验的成熟的可用方案。但是,随着各大厂商的重视以及开源项目的如火如荼,.NET O/R Mapping的步伐也开始慢慢跟上,使这块本属于J2EE的领地加入了新的竞争对手(会不会使更多的开发人员投入.NET阵营?J),也让众多疲于在SQL ClauseADO.NET中来回奔命的DAL开发人员看到了“光明之路”。

     

    接下来,就让我们一起看看在这片比ADO.NET更广阔的土地上有些什么值得探讨的Solution

    Ø        Open Source

    开源方面一直与.NET保持一定距离,O/R Mapping更是寥寥无几,但就作者的下载试用和源码分析来看,个人以为如下的两个解决方案还是有一定参考价值的:OPFOJB

    有关这两个开源项目的简介,大家可以参考“程序员,2004.01P13

    OPF的全称是:Object Persistent Framework

    OJB的全称是:ObJect Relational Bridge

     

    在实现手法上,这两个方案的思路完全不同,具有各自的代表性。

    OPF走的路线有点类似于Typed DataSetBorland ECO(请参考下面的介绍),实现比较简单,提供更多的源码级控制;而OJB的实现则类似于Microsoft ObjectSpaces(请参考下面的介绍),采用了配置文件的方式,相对就比较复杂了。

     

    这两个方案的基本框架如下所示:

    OPF

    从图中不难看出:

    (1)    Persistent类扮演了DataSet的角色,除了常规的对象数据操作外,还可以设定不同对象间的关系(如主从关系,集合关系等,这一点在Borland ECO所生成的代码中也可略见一二),这也是上文所说“提供更多源码级控制”的原因所在;

     

    (2)    PersistentSqlDataManager则扮演了DataAdapter的角色,通过预先设置的Commands来执行真正的数据库操作;在实际撰写的employee data manager中,开发人员确实需要提供基本的SQL语句,就像在SqlCommond中设置的那样(Borland ECO则更进一步,以OCL代替了SQL);

     

    (3)    ObjectBroker的作用非常重要,它是对象与数据间的桥梁,RegisterPersistent方法建立了这种虚拟Object)与现实RDBMS)间的关系

     

    (4)    employee business object的声明中,对象属性与数据库字段的对应关系是通过.NET Attribute机制体现的,所以修改起来还是比较方便的,虽然相比配置文件的方式显得不够灵活(请参考OJB的介绍),比如:需要重新编译,开发人员不得不关注数据库字段等。

           

    OJB

     

     

    从图中不难看出:

    (1)    该方案的实现比较复杂,但用户需要实际撰写的代码变少了(只需要编写employee business object),这其中的关键就在于引入了配置文件;同时,由于配置文件的引入,我们在hello world application中也不需要调用类似OPF解决方案(请参考上文的OPF类图)中的RegisterObject方法,所有这一切(甚至包括数据库连接信息),系统都已了如指掌!

     

    (2)    该方案中,SQL命令通过Criteria类被彻底替代,而QueryFacade则充当了Adapter的功能,通过PersistenceBroker这一真正的Command与数据库进行通信;

     

    (3)    无论是repository.xml配置文件,还是CriteriaQueryFacade类,我们都可以在ObjectSpaces(请参考下面的介绍)中找到类似的实现(难道是巧合?),同时,作者个人以为,这种方式也更符合O/R Mapping的精神,减轻了开发人员的负担!

     

    (4)    OJB还有一个非常cool的工具“repositorygen.exe”,可以用来生成repository.xml配置文件(同样的,源码无偿奉上J),这一点,甚至连ObjectSpaces都没能做到(想想那么多字段、属性、关联、映射,简直可以让人发疯J)!

     

    Ø        Microsoft ObjectSpaces

    这是一个在几年前就让众多.NET guy伸长脖子激动不已的技术。就作者来说,那个时候,只要一提起这个话题,一般都是在J2EE guy的嘲笑声中悻悻而归,恨不能自己也搞个ENB(相对EJB)或者NCMP(相对CMP)什么的。

    终于,我们可以在.NET Framework 1.2(可在VS.NET 2004WhidbeyYukon中找到,目前都是Beta版本)中一睹其“芳容”了J

     

    首先,让我们看看用ObjectSpaces写出的代码是什么样子(依然使用上面的employee例子):

                                      

    // 初始化ObjectSpace

    SqlConnection conn = new SqlConnection("Data Source=localhost;

    Integrated Security=SSPI; Database=Northwind");

    ObjectSpace os = new ObjectSpace("map.xml", conn);

     

    // 根据EmployeeID返回其Title

    Employee oEmp = (Employee)os.GetObject (

    new ObjectQuery(typeof(Employee), "ID = 1"));

    // 注意:实际字段名为:EmployeeID

    string strTitle = oEmp.Title;

    ……

    // 根据 City 返回所有符合条件的 Employee

    ObjectSet oSet = os.GetObjectSet(

    new ObjectQuery(typeof(Employee), "City = '”Seattle'"));

    // 注意:返回的不是DataTable,而是对象集合

    foreach (Employee oEmp in oSet)

    {

    …… // 注意:在这里可以对oEmp做任何操作

     

    针对上面第二段代码,还有一种解决方案,就是以ObjectReader替代ObjectSet,这其中所包含的差异,类似于ADO.NET 1.0(包含ObjectSpacesdADO.NET又称为ADO.NET 2.0)中的DataSet / DataTableDataReader间的不同(不得不佩服Microsoft在前后一致性上表现出的老谋深算J)。

     

    仔细分析上面的代码,就可以发现它和前面讨论的OJB有惊人的相似点(OJB中作者只画了基本类图,但足可看出这种思想上的接近)!

    例如:ObjectSpace类基本上提供了OJB中的QueryFacade功能;ObjectQuery类基本上提供了OJB中的Criteria功能;同时,两种解决方案又不约而同的使用了配置文件来存储O/R Mapping信息;而应用程序一般也就通过这2个类进行数据操作,非常方便。稍微有些区别的可能是在数据返回格式上(这一点,ObjectSpaces考虑得更细致,可以参考上面的代码),但这已经对实际的代码实现影响不大了。

    如果将ObjectSpaces下的调用代码与前面给出的那段在ADO.NET下撰写的代码作个比较,不难看出,ObjectSpaces给出的代码更易阅读和理解,就算不熟悉ADO.NET整体架构的开发人员,也可轻松上手(唯一涉及RDBMS的代码只有建立数据库连接时需要)。对于已经熟悉ADO.NET或曾接触过O/R Mapping(如:J2EE下的Hibernate)的朋友来说,真可谓小菜一碟!

     

    .NET Framework 1.2文档中可以知道,ObjectSpaces总共提供了3个命名空间,整体结构非常清晰:

    System.Data.ObjectSpaces

    System.Data.ObjectSpaces.Query

    System.Data.ObjectSpaces.Schema

     

    ObjectSpacesQuery已在上面的代码中见识过,从名字中可以猜出,它们主要负责向外提供基本访问接口(如查询、增 / / 改等)和解析各种查询条件(如对象过滤等),Schema命名空间则主要用来操作O/R Mapping配置文件,并为其它两个命名空间中的类提供服务。

    ObjectSpaces中,O/R Mapping配置文件主要指map.xml,这个文件的名字是可以随意更换的,比较类似OJB中的repository.xml。另外两个分别描述数据库结构和对象结构的配置文件也非常重要:RSD.xmlRelational Schema Definition),OSD.xmlObject Schema Definition)。可以将它们理解为Typed DataSet中的XSD文件,没有它们,所有的数据 / 对象MappingValidation都将是“非法”的J

    本文中,作者不准备对ObjectSpaces来个深度探索,也不会提供什么Sample说明其优越性,这方面,.NET Framework SDK早已为大家提供了丰富套餐。

    作者只是希望,如果从DAL的角度来分析,ObjectSpaces技术能为我们带来什么,是否意味着从此告别DataReader / DataSet,抑或为开发人员带来了新的烦恼?

     

    好处不多说,仅举数例即可明了:

    (1)    ObjectSpaces全部采用对象方式访问数据,大大缓解了很多开发人员的SQL(或者说RDBMS)恐惧症;

     

    (2)    对于比较简单的数据库结构变化,只需要修改配置文件即可,无需重新编译代码(较之OPF中将映射关系以.NET Attribute方式封装于代码中,显得更加灵活、方便);

     

    (3)    对于比较复杂的数据库结构变化,由于只涉及对象操作,所以修改的工作也要比以前简单许多;

     

    (4)    采用了O/R Mapping配置文件后,数据库设计与DAL开发可以分别进行,相互的影响也降到了最低点;

     

    不足则是我们更须关注的话题:

    (1)    目前版本不支持中文(永远的话题J)查询,不爽!

     

    (2)    当前版本仅支持SQL Server 2000以上版本的数据库系统,弱(这是个很耐人寻味的限制,有兴趣的读者不妨想想到底是什么原因)!

    12引自.NET Framework SDK Document,就这两点已排除了很多跃跃欲试的朋友。而作者参与的.NET项目虽不受1影响,但由于经常使用Oracle,就不得不暂时忍痛割爱了J

     

    (3)    性能问题。虽然ObjectSpaces也提供了类似DataReader的功能(ObjectReader),但毕竟需要进行一次数据强类型填充,无论如何会有损失,如果返回数据量变大,将是一个不得不考虑的问题;

     

    (4)    还是性能问题map.xml是个好东东,但如何优化对它的访问以及进行正确的Validation(基于RSD.xmlOSD.xml)毕竟需要时间,甚至在某些时候(数据库结构比较复杂),这会造成比第3点更为严重的后果;

     

    说了些不足,其实也无须过于担心,毕竟,没有十全十美的解决方案,怎么取舍就看你自己的决定了。

    本章最后,作者给出了一个自己的总结,可供您参考一二。在所有的分析完毕之后,作者也试图结合自己的实践提供“我的方案(撰写中)”,希望能给各位读者带来帮助。

     

    作者简介:

    本文作者张雪峰 毕博全球开发中心 的高级开发工程师。他目前在中国上海 毕博全球开发中心 Core/EAI 部门工作,从事 .NET 技术的研究以及相关项目的开发。可以通过 xuefeng.zhang@bearingpoint.com 与他联系。

  • 相关阅读:
    2018年-2019年第二学期第七周C#学习个人总结
    2018年-2019年第二学期第六周C#学习个人总结
    2018年-2019年第二学期第五周C#学习个人总结
    2018年-2019年第二学期第四周C#学习个人总结
    2018年-2019年第二学期第三周C#学习个人总结
    2018年-2019年第二学期第二周C#学习个人总结
    本学期C#学习个人总结
    排球积分程序
    观后感
    最终总结
  • 原文地址:https://www.cnblogs.com/huqingyu/p/28242.html
Copyright © 2011-2022 走看看