[C#]浅谈.Net版(C#)的CMP模式 - 一个上线商城的系统架构

商城上线快2、3个月了,一直都懒得写点东西,在加上杂七杂八的事情也比较忙,所以都没有把这个系统当时做的整个架构思绪整理清,昨天才从深圳完了两天回来,怎感觉是要做的事来着.刚开始接触CMP模式的时候也是看了它几天,到谷歌百度里面一搜,我们博客园里面就有蛙蛙池塘提高软件开发效率三板斧之二利用CMP模式一文里有它的详细介绍,在这里我自己也对这个用CMP模式拿来真正上项目时候的问题来做个总结.

  • 项目名称:惠海IT商城
  • 网        址:http://http://www.huihaimall.com/
  • 开发环境:WinXP SP3、IIS5.0、Dreamweaver、VS 2005、SQL-Server 2000
  • 项目描述:项目实现了商品的浏览筛选(主要是公司做的IT产品)、会员商品收藏、订购(订单)、发邮件推荐给朋友、会员积分、收货地址薄、DIY自主装机等,业务逻辑全部在本项目中用.NET(CMP)实现,而展示就不一定都是用.net的aspx页面来做,如DIY装机就是用Flex生成flash来实现的,但是它们都是同步的(同登陆同注销,包括会员产品收藏等).还有一个最重要的就是后台管理也是用Flex调用.net来实现的,由于要提供Flex调用的接口,所有还提供了几个WebService的页面(关与身份验证请见:在WebService中使用Session或Cookie—实现WebService身份验证(客户端是Flex) ),另外在用JQuery发送Ajax请求的时候页面传输数据时候还有用到Json数据(Flex好象有几个地方也用到了).
  • 项目解决方案截图如下:

    下面,我对上图所示以我的了解进行简要的介绍:
  1. CMPServices 它里面主要是一些CMP配置和服务的基类,他们对应的名称和功能内如下所:
    名称 功能描述  
    CMPConfigurationHandler 继承自IConfigurationSectionHandler,用来读取在Web.Config文件内的自定义CMP配置.
    CommandMapping 命令映射类,用于某一个业务的容器,一般为对应Insert、Delete、Update、Select里某一个存储过程名为CommandName,里面可能包含多个CommandParameter.  
    CommandParameter 存 储过程参数类,里面有ParameterName、Size、DbTypeHint等属性,还有一个ClassMember的属性,表示对应实体模型的属 性,一般ParameterName为@Name而ClassMember值就为Name(预先配置好的),因为一般存储过程的参数名就对应数据库实体模 型的列字段.  
    ContainerMapping 容 器映射类,一般为一个业务实体,比如用户,它里面就有Insert、Delete、Update、Select这4个CommandMapping,而且 它有个key在CMP里面的映射ID叫ContainerMappingId和ContainedClass对应为实体对象模型名.  
    ContainerMappingSet 多个ContainerMapping容器映射集合类,里面的Hashtable可根据ContainerMappingId映射ID的key来匹配ContainerMapping.  
    PersistableObject 持久对象基类,实体类继承它能实现对数据的保存(一般为Insert、Delete、Update命令操作).  
    PersistableObjectSet 继承自PersistableObject,实现数据持久化保存结果(一般为Select命令操作).  
    SQLPersistenceContainer 业务的容器基类,构造函数需ContainerMapping,包含Insert、Delete、Update、Select四个虚方法.  
    StdPersistenceContainer 业务的容器,构造函数ContainerMapping调用父类构造函数,根据ContainerMapping对Insert、Delete、Update、Select四个方法进行具体的实现.  

  2. ConfigurationServices 里面就一个类SiteProfile,它里面主要是一些静态成员,如有DefaultDataSource是数据源连接字符串,还有一个 Hashtable的DbTypeHints,作用是用来配置一些存储过程的参数对应的ADO.Net里面的SQL数据类型.
  3. http://localhost/WebSite/ 这层就不用说了,就是我们的网页表示层.
  4. MallMemberDAL 这个是商城里面的一个模块,为商城会员业务逻辑层分开为一个项目,里面主要为会员的一些操作,是为CMP的具体应用了,类的命名如下图所示:

    上图是对会员收货地址和优惠卷的实例,其中Item结尾的继承自PersistableObject的实体类,Manager为所有操作方法的集合类(以 静态方式提供),Set继承自PersistableObjectSet为数据集合的容器类,其实最初CMP里实体的命名不是这样的,好象是加 Entity后缀,这个就看你怎么决定了,但是整个项目一定要统一.
  5. MonitorServices 为CMP的监控服务项目,主要是跟踪当前执行的方法、异常信息等,这里就不祥说了.
  6. MonitorServicesLogging 监控服务日志项目,用来处理异常错误信息,可以保存到数据库内,如果保存到数据库失败则写到本地日志文件里,不过我在项目里面并没有用到它的,一刚开始因 为还没有整个摸透它,好象还要在Web.Config文件内设置GWTrace的跟踪等级,刚开始做的时候老是报错,但是没有把错误写到日志里面,而是 CMP老是抛出那同一个错误,感觉很是麻烦搞的满头包就没去理它了,直到后来发布网站上线的时候也把这个错误日志的功能忘记加了.
  7. Newtonsoft.Json 它是一个完全开源可以免费使用的数据格式,应用领域.Net、JavaScript、Flex至少我知道有这么多,它能一个.Net实体对象通过它的格式 化字符串传输到另一端又能转换成原对象,比如我能在使用了Json的JavaScript里面使用实体名.属性设置或获取值再在.Net里能轻易得到,同 理Flex里面也有Json的对象格式,它使得我们能使用字符串轻易的传输实体对象,在本项目里面就有使用Flex通过Json数据与.Net通 讯,JavaScript使用Ajax来Post传递Json数据.虽然它还存在一些的Bug,不过基本上所有的软件都会存在一些Bug的,在本项目应用 中,好象Json就有一个数据类型转换的Bug,不过还好,Json是开源的,直接在它的源代码上修改解决了这个Bug. 
  8. 最后还有2个模块:PopedomDAL和ProductDAL分别是权限和产品模块,实质是和MallMemberDAL差不多,也就不多说了.
  • 下面来说一下Web.Config文件的相关配置
  • 在configuration下的configSections的第一个子节点,配置CMP读取的自定义节点
    <section name="GWConfig" type="Huihai.Mall.CMPServices.CMPConfigurationHandler, Huihai.Mall.CMPServices" />
  • 再在configSections下增加CMP跟踪监视等级及数据库连接字符串,默认本地日志文件路径等配置(以下为我项目里面的一些CMP配置,最下面的为商城的一些配置)
    <system.diagnostics>
    <switches>
    <add name="GWTrace" value="4" />
    </switches>
    </system.diagnostics>
    <appSettings>
    <add key="ErrorViewUrlPrefix" value="/Wawacrm/ErrorLog/" />
    <add key="ErrorLogBaseDir" value="E:\me\bak\oa\WAWACRM\Errors\" />
    <add key="DefaultDataSource" value="server=127.0.0.1;database=HH_System;uid=sa;pwd=123" />
    <add key="picurl" value="Upload/product/" />
    <add key="score" value="1" />
    <add key="isShowRunTime" value="true" />
    <!– 产品评论 –>
    <add key="CommentIsAudit" value="Y" />
    <!– 产品评论回复 –>
    <add key="ReCommentIsAudit" value="Y" />
    <!– 产品咨询 –>
    <add key="ConsultIsAudit" value="N" />
    <!– 商城咨询反馈 –>
    <add key="FeedbackIsAudit" value="Y" />
    <!– 后台管理员的使用的邮箱名后缀 –>
    <add key="EmailPostfix" value="@coreoa.cn" />
    </appSettings>
  • 接 下来最重要的也是最复杂的就是GWConfig自定义CMP元数据配置的节点了, 它里面主要是配置每一个存储过程,对应容易实体类,及该业务实体的Insert、Update、Delete、Select四个方法的参数的详细描述,已 上面MallMemberDAL项目的Address和Coupon为例,它的配置为如下:
    <GWConfig>
    <ContainerMappingSet>
    <ContainerMapping>
    <ContainerMappingId>Address</ContainerMappingId>
    <ContainedClass>AddressItem</ContainedClass>
    <Insert>
    <CommandName>MO_Address_Insert</CommandName>
    <Parameter>
    <ClassMember>Member_inner_code</ClassMember>
    <ParameterName>@member_inner_code</ParameterName>
    <DbTypeHint>Int</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>4</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Province</ClassMember>
    <ParameterName>@province</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>20</Size>
    </Parameter>
    <Parameter>
    <ClassMember>City</ClassMember>
    <ParameterName>@city</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>20</Size>
    </Parameter>
    <Parameter>
    <ClassMember>County</ClassMember>
    <ParameterName>@county</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>20</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Zip</ClassMember>
    <ParameterName>@zip</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>6</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Address</ClassMember>
    <ParameterName>@address</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>100</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Name</ClassMember>
    <ParameterName>@name</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>50</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Mobile</ClassMember>
    <ParameterName>@mobile</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>15</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Tel</ClassMember>
    <ParameterName>@tel</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>50</Size>
    </Parameter>
    <Parameter>
    <ClassMember>IsDefault</ClassMember>
    <ParameterName>@isDefault</ParameterName>
    <DbTypeHint>Char</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>1</Size>
    </Parameter>
    </Insert>
    </ContainerMapping>
    <ContainerMapping>
    <ContainerMappingId>Coupon</ContainerMappingId>
    <ContainedClass>CouponItem</ContainedClass>
    <Select>
    <CommandName>MO_Coupon_Select</CommandName>
    <Parameter>
    <ClassMember>Sn</ClassMember>
    <ParameterName>@sn</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>10</Size>
    </Parameter>
    <Parameter>
    <ClassMember>Password</ClassMember>
    <ParameterName>@password</ParameterName>
    <DbTypeHint>Varchar</DbTypeHint>
    <ParamDirection>Input</ParamDirection>
    <Size>10</Size>
    </Parameter>
    </Select>
    </ContainerMapping>
    </ContainerMappingSet>
    </GWConfig>
  • 以 上配置中,为简便示例Address只有配置Insert的方法,它调用名为MO_Address_Insert的存储过程,Insert节点下第一个子 节点CommandName即为存储过程名,而同级别的还有Parameter节点,每一个都代表存储过程的一个参数,下面有ClassMember、 ParamDirection等子节点即是对它的描述,其他Update、Delete、Select依此类推也可以按这种格式来配置;而Coupon只 有配置一个Select的节点(其他的也能按照上述增加配置,没有配置的方法将不能调用),Coupon是为ContainerMappingId容器业 务实体唯一Key,它对应名为CouponItem的业务实体模型类,而参数下面的ClassMember为实体下的成员名,一般就是实体类的属性,因为 它将根据这个名字反射获得实体的属性值.以上这么多配置但是却只实现了2个业务的方法,是不是有点太麻烦了?没得办法,最开始的CMP模式就是这样一个个 把存储过程配置好的!但是你的项目里面要用到的存储过程肯定不只与2个,姑且就不说CMP模式这样配置也只能用存储过程的,但是存储过程一多了就会很麻烦 了,每一个存储过程配置都要手写添家进去!要是存储过程上十来个参数又有增删改查的方法岂不要配置很多东西?的确要配置的东西不少,但是我们可以自己写的 小程序对CMP的配置执行操作,而不需要我们去打开Web.Config文件一个个去手改,下图为我自己写的一个对CMP的配置进行操作的截图:
  • 上 面主要是通过XPath表达试对Web.Config进行筛选读取CMP的配置节点并在GridView里面显示出来,并能直接对其进行修改等操作.如果 假如我们要增加一个的时候,我们可以点浏览用sysobjects把数据库里面所有的存储过程名字等信息读出来,选择要配置的那个存储过程后再用 sp_sproc_columns把它所有的参数信息读出来,如ParameterName、Size、ParamDirection等,注意 ClassMember(实体模型的属性名)读出来默认是ParameterName,因为在我的项目里面ParameterName名就跟实体的属性名 一样,你也可以改成其他的,但是你要根据你实体命名的约定好,因为它要用反射实体里找这个属性,如果找不到就不好了.
  • 是 不是用上面那个小工具爽多了?你不需要打开Web.Config手写一个配置,在数据库里面写一个存储过程就到这里配置一下直接在增加一个CMP的配置节 点到Web.Config文件内.,我这个商城的项目下来把所有业务的存储过程配置完Web.Config文件总大小有140KB,把 Web.Config文件撑得这么大对性能会不会有影响呢?我想应该不会有多大影响的,从CMP框架的运行来看,它只是在 Application_Start的时候读取一次配置文件到内存,以后就像读AppSettings一样直接在内存里面取,很方便 的.Application_Start运行的代码如下:
  • // 在应用程序启动时运行
    System.Configuration.ConfigurationManager.GetSection("GWConfig");
    SiteProfile.DefaultDataSource
    = System.Configuration.ConfigurationManager.AppSettings["DefaultDataSource"];
    SiteProfile.DbTypeHints[
    "Varchar"] = System.Data.SqlDbType.VarChar;
    SiteProfile.DbTypeHints[
    "Nvarchar"] = System.Data.SqlDbType.NVarChar;
    SiteProfile.DbTypeHints[
    "Int"] = System.Data.SqlDbType.Int;
    SiteProfile.DbTypeHints[
    "Date"] = System.Data.SqlDbType.DateTime;
    SiteProfile.DbTypeHints[
    "Text"] = System.Data.SqlDbType.Text;
    SiteProfile.DbTypeHints[
    "Bit"] = System.Data.SqlDbType.Bit;
    SiteProfile.DbTypeHints[
    "Money"] = System.Data.SqlDbType.Money;
    SiteProfile.DbTypeHints[
    "Datetime"] = System.Data.SqlDbType.DateTime;
    //后新添加2种对应类型
    SiteProfile.DbTypeHints["Char"] = System.Data.SqlDbType.Char;
    SiteProfile.DbTypeHints[
    "Numeric"] = System.Data.SqlDbType.Decimal;
    SiteProfile.DbTypeHints[
    "Smalldatetime"] = System.Data.SqlDbType.SmallDateTime;
    //监听配置文件的改变
    System.IO.FileSystemWatcher fsw = new System.IO.FileSystemWatcher(Server.MapPath("~/Upload/special"));
    fsw.Filter
    = "XMLFile.xml";
    fsw.NotifyFilter
    = System.IO.NotifyFilters.LastWrite;
    fsw.Changed
    += new System.IO.FileSystemEventHandler(ReadAdConfig);
    fsw.EnableRaisingEvents
    = true;
    ReadAdConfig(
    null, null);
  • 上 面代码主要是在程序启动的时候Web.Config的CMP配置的元数据读到CMP的一个静态类里面,初始化数据连接,设置存储过程参数对应的SQL数据 类型,后面监听和ReadAdConfig方法是我项目另外的东西,这里就不叉开话题说了. 配置搞清楚了,下面我们就来研究一下CMP这个架构到底是如何运行的呢? 还是以上面的那个例子为准,比如我要在Address里增加一个会员收货地址.我就在AddressManager里面提供一个 AddressInsert的静态方法供调用.
  • public static void AddressInsert(AddressItem item)
    {
    SqlPersistenceContainer spc
    = new SqlPersistenceContainer(CMPConfigurationHandler.ContainerMaps["Address"]);
    spc.Insert(item);
    }
  • 上面所示为CMP一个调用业务的过程,它实现的全过程大概为:
  • ①第一步: 在Application_Start把CMP的所有配置读取到CMPConfigurationHandler下面的ContainerMaps集合里面,它是一个Hashtable对象
  • ②第二步: 根据Address这个ContainerMappingId的key在CMPConfigurationHandler.ContainerMaps匹配到Address这个业务对象的ContainerMapping映射容器对象
  • ③第三步: 实例化SqlPersistenceContainer托管容器对象,ContainerMapping作为构造函数穿入,并使用: base(initCurrentMap)调用StdPersistenceContainer父类构造函数
  • ④第四步: 执行具体方法(这里为Insert操作),而参数为继承了PersistableObject类型的实体模型对象,为了更好的说明我也把Insert方法的代码给帖出来
  • /// <summary>
    ///
    /// </summary>
    /// <param name="insertObject"></param>
    public override void Insert(PersistableObject insertObject)
    {
    GWTrace.EnteringMethod(MethodBase.GetCurrentMethod());
    try
    {
    CommandMapping cmdMap
    = currentMap.InsertCommand;
    SqlCommand insertCommand
    = BuildCommandFromMapping(cmdMap);
    AssignValuesToParameters(cmdMap,
    ref insertCommand, insertObject);
    insertCommand.Connection.Open();
    insertCommand.ExecuteNonQuery();
    insertCommand.Connection.Close();
    AssignOutputValuesToInstance(cmdMap, insertCommand,
    ref insertObject);
    insertCommand.Dispose();
    }
    catch (Exception dbException)
    {
    string s = insertObject.ToXmlString();
    s
    +=currentMap.InsertCommand.CommandName;
    throw new Exception("Persistance (Insert) Failed for PersistableObject", dbException);
    }
    }
  • 由 于受空间限制没能把全部代码贴出来比较难看一点,大概原理是根据PersistableObject 对象(实际为保存在XML内的CMP的配置映射到的容器)和要执行的方法创建SqlCommand对象,设置它要执行的存储过程名称.再循环 Parameter创建并添加参数,再在PersistableObject 根据反射ClassMember获得实体里面的属性值设置Parameter参数值.然后在执行存储过程,并跟踪记录当前错误方法,处理异常信息,这样便 完成了CMP一个业务处理的全过程.
  • 是不是感觉这样调用有点'妙' 呢?你要修改那个业务对象你只需要指定对应的实体模型的属性值它就能作为参数Insert(Update或Delete)数据库表里面对应的列,如果存储 过程参数对应的实体属性值没有指定的话将传递的为默认值,如String类型的将为NULL.这里在反射的时候还要注意一个问题,就是列为时间类型的时 候,C#里面的DataTime为空的时候为初始值为0001-1-1 0:00:00,而当你使用反射的时候把这个时间更新到数据库会报错,提示什么数据溢出,因为SQL里时间类型是有一个时间段,所以在CMP调用 AssignValuesToParameters方法使用发射赋值的时候要加上一个判断,这样修改时间类型字段指定为空就没有问题了.
  • object o = PropertyInfo.GetValue(persistObject, null);
    if (o == null)
    o
    = DBNull.Value;
    if (o.GetType().Equals(typeof(System.DateTime)))
    {
    //时间默认值(即未给时间赋值),则为空
    if (o.ToString() == "0001-1-1 0:00:00")
    o
    = DBNull.Value;
    }
  • 而 Select查询操作略有一点不同, 因为它要返回结果解,这个时候就应该要用AddressSet类了,所有要实现查询操作的类都必须继承自PersistableObjectSet,因为 它能有返回DataSet数据集的实现,可以把它看成一个特殊的PersistableObject对象,因为它除开有Insert、Delete、 Update还有Select.AddressSet类其实也是一个实体类,但它跟AddressItem类不同的是它只管查询,而查询的存储过程往往没 有增、删、改的那么多的参数,因此它的里面只需要几个查询条件字段的属性.调用起来跟增、删、改的操作都差不多,也是根据反射它里面的属性值在赋个对应的 参数在执行,CMP默认返回的一个DataSet,我的觉得既然用到的实体,为什么不用它来代替DataSet呢?所以我在原来的基础上新加一个用反射把 DataSet转换成实体的数组,方便再次操作,代码如下:
  • /// <summary>
    /// 根据引用传来的Object实体对象使用反射给它的属性赋值
    /// </summary>
    /// <param name="obj">实体对象</param>
    /// <returns>是否给表里面的记录值填充实体里属性成功,如何找不到实体属性或记录集为空返回false</returns>
    public bool ResultSingleObject(ref Object obj)
    {
    //internalData是本类里返回的DataSet集合
    if (internalData.Tables.Count == 0)
    return false;
    DataTable tab
    = internalData.Tables[0];
    if (tab.Rows.Count == 0)
    return false;
    Type type
    = obj.GetType();
    foreach (DataColumn column in tab.Columns)
    {
    string columnName = column.ColumnName;
    object t = tab.Rows[0][columnName];
    PropertyInfo property
    = type.GetProperty(columnName);
    if (property == null)
    property
    = type.GetProperty(columnName.Substring(0, 1).ToUpper() + columnName.Substring(1, columnName.Length 1));
    if (property == null)
    property
    = type.GetProperty(columnName.ToUpper());
    if (property != null)
    {
    if (t.GetType().FullName != "System.DBNull")//如果数据库返回的值不等于NULL的情况下才给找到了实体的字段属性赋值
    property.SetValue(obj, t, null);
    }
    else
    throw new Exception("表的列名为" + columnName + "不能与实体名为" + type.Name + "的属性名一致,请修改过程的返回的列名称或实体属性名");//便与调试
    }
    return true;
    }
  • 通过如上代码for循环一下就能把DataSet转换成AddressItem数组或是List<AddressItem>泛型.
  • CMP模式差不多就这么多些吧,总结其中一些美中不足的地方:
  • Insert、 Delete、Update、Select这个四个方法还不能满足需求,增删改查这个四个方法是CMP的核心,但一个业务实体的操作只有这个4个方法往往 是不够的.因为这四个方法只能分别对应一个储存过程,而查询的存储过程一般一个难得搞定,比如用户表我除开根据用户ID去查询,还要根据用户名和密码,还 有可能要根据用户类型返回用户列表等等,实际的需求是复杂的.用CMP的话那我还需要另外单独配置2个业务实体来分别放根据用户名和密码和根据用户类型的 操作的存储过程,而它们里面的Select都只有一个且对应的实体也是同一个,是不是感觉有点浪费?感觉Insert、Delete、Update、 Select这个四个方法不够用,后来想到增加一个List的方法,基本上每一个表都有一个根据ID去查询记录的时候,Select就对应这个操作,而新 加的List就对应根据其他条件可能会返回多条记录的操作,这样就不需要当一个业务有2个查询操作的时候而再去新建一个了,但是新增一个List查询的方 法Web.Config内CMP配置文件也要加List节点的配置,而且CMP的基类里面也要增加一个List方法并要解析对应的List配置节点,由于 当时项目做得快差不多了,这人一懒呢就没有去完成了.-_-!!!
  • 连 续写了几个晚上终于快要完了,之所以我要写这些,是感觉自己最近好象都没做什么东西一样,白天在慢悠悠的上一天班,晚上就什么也不想动了,一坐到电脑前就 是看玩传奇世界或是看电视连续剧斗牛要不要、篮球火等啊,呵呵,等来得急看时间的时候已经凌晨过后了…第二天起来到公司上班,晚上又继续,我心是想我 不要每天就这样过去了,但是这样持续了好久一段时间…我有时候也自责自己到最后还是一事无成!既然来到了这行,就一定要做好这个职业的本职工作,IT 行业这个技术每天都在不断更新演变的领域你每天不去学习怎么能行呢?所以我不要再那么在'堕落'下去了,呵呵^_^,便决定写点东西,至少别自己做过的项 目都不记得去了.
  • 源代码就不要问了,放在公司的SVN服务器里面了,这里提供一个我原来的参考的CMP架构的源代码下载, 好象还是蛙蛙池塘2005年写的.
  • CMP模式参考源代码下载
赞(0) 打赏
分享到: 更多 (0)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏