首页 > 开发 > 综合 > 正文

C#数据库事务原理及实践

2024-07-21 02:26:32
字体:
来源:转载
供稿:网友

什么是数据库事务

   数据库事务是指作为单个逻辑工作单元执行的一系列操作。

设想网上购物的一次交易,其付款过程至少包括以下几步数据库操作:

   · 更新客户所购商品的库存信息

   · 保存客户付款信息--可能包括与银行系统的交互

   · 生成订单并且保存到数据库中

   · 更新用户相关信息,例如购物数量等等

正常的情况下,这些操作将顺利进行,最终交易成功,与交易相关的所有数据库信息也成功地更新。但是,如果在这一系列过程中任何一个环节出了差错,例如在更新商品库存信息时发生异常、该顾客银行帐户存款不足等,都将导致交易失败。一旦交易失败,数据库中所有信息都必须保持交易前的状态不变,比如最后一步更新用户信息时失败而导致交易失败,那么必须保证这笔失败的交易不影响数据库的状态--库存信息没有被更新、用户也没有付款,订单也没有生成。否则,数据库的信息将会一片混乱而不可预测。

数据库事务正是用来保证这种情况下交易的平稳性和可预测性的技术。

数据库事务的acid属性

事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。一个逻辑工作单元要成为事务,必须满足所谓的acid(原子性、一致性、隔离性和持久性)属性:

   · 原子性

事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。通常,与某个事务关联的操作具有共同的目标,并且是相互依赖的。如果系统只执行这些操作的一个子集,则可能会破坏事务的总体目标。原子性消除了系统处理操作子集的可能性。

   · 一致性

事务在完成时,必须使所有的数据都保持一致状态。在相关数据库中,所有规则都必须应用于事务的修改,以保持所有数据的完整性。事务结束时,所有的内部数据结构(如 b 树索引或双向链表)都必须是正确的。某些维护一致性的责任由应用程序开发人员承担,他们必须确保应用程序已强制所有已知的完整性约束。例如,当开发用于转帐的应用程序时,应避免在转帐过程中任意移动小数点。

   · 隔离性

由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。这称为可串行性,因为它能够重新装载起始数据,并且重播一系列事务,以使数据结束时的状态与原始事务执行的状态相同。当事务可序列化时将获得最高的隔离级别。在此级别上,从一组可并行执行的事务获得的结果与通过连续运行每个事务所获得的结果相同。由于高度隔离会限制可并行执行的事务数,所以一些应用程序降低隔离级别以换取更大的吞吐量。

   · 持久性

 事务完成之后,它对于系统的影响是永久性的。该修改即使出现致命的系统故障也将一直保持。
dbms的责任和我们的任务

企业级的数据库管理系统(dbms)都有责任提供一种保证事务的物理完整性的机制。就常用的sql server2000系统而言,它具备锁定设备隔离事务、记录设备保证事务持久性等机制。因此,我们不必关心数据库事务的物理完整性,而应该关注在什么情况下使用数据库事务、事务对性能的影响,如何使用事务等等。

本文将涉及到在.net框架下使用c#语言操纵数据库事务的各个方面。
体验sql语言的事务机制

作为大型的企业级数据库,sql server2000对事务提供了很好的支持。我们可以使用sql语句来定义、提交以及回滚一个事务。

如下所示的sql代码定义了一个事务,并且命名为"mytransaction"(限于篇幅,本文并不讨论如何编写sql语言程序,请读者自行参考相关书籍):

declare @tranname varchar(20)

select @tranname = 'mytransaction'
begin transaction @trannamegouse pubs
go

update roysched
set royalty = royalty * 1.10
where title_id like 'pc%'
go

commit transaction mytransaction
go 

这里用到了sql server2000自带的示例数据库pubs,提交事务后,将为所有畅销计算机书籍支付的版税增加 10%。

打开sql server2000的查询分析器,选择pubs数据库,然后运行这段程序,结果显而易见。

可是如何在c#程序中运行呢?我们记得在普通的sql查询中,一般需要把查询语句赋值给salcommand.commandtext属性,这里也就像普通的sql查询语句一样,将这些语句赋给sqlcommand.commandtext属性即可。要注意的一点是,其中的"go"语句标志着sql批处理的结束,编写sql脚本是需要的,但是在这里是不必要的。我们可以编写如下的程序来验证这个想法:

//transql.csusing system;
using system.data;
using system.data.sqlclient;
namespace aspcn
{
  public class dbtransql
  {
   file://将事务放到sql server中执行
   public void dotran()
   {
    file://建立连接并打开
    sqlconnection myconn=getconn();myconn.open();
    sqlcommand mycomm=new sqlcommand();
    try
    {
     mycomm.connection=myconn;
     mycomm.commandtext="declare @tranname varchar(20) ";
     mycomm.commandtext+="select @tranname = 'mytransaction' ";
     mycomm.commandtext+="begin transaction @tranname ";
     mycomm.commandtext+="use pubs ";
     mycomm.commandtext+="update roysched set royalty = royalty * 1.10 where title_id like 'pc%' ";
     mycomm.commandtext+="commit transaction mytransaction ";
     mycomm.executenonquery();
    }
    catch(exception err)
    {
     throw new applicationexception("事务操作出错,系统信息:"+err.message);
    }
    finally
    {
     myconn.close();
    }
   }
   file://获取数据连接
   private sqlconnection getconn()
   {
    string strsql="data source=localhost;integrated security=sspi;user id=sa;password=";
    sqlconnection myconn=new sqlconnection(strsql);
    return myconn;
   }
  }

  public class test
  {
   public static void main()
   {
    dbtransql trantest=new dbtransql();
    trantest.dotran();
    console.writeline("事务处理已经成功完成。");
    console.readline();
   }
  }


   注意到其中的sqlcommand对象mycomm,它的commandtext属性仅仅是前面sql代码字符串连接起来即可,当然,其中的"go"语句已经全部去掉了。这个语句就像普通的查询一样,程序将sql文本事实上提交给dbms去处理了,然后接收返回的结果(如果有结果返回的话)。

很自然,我们最后看到了输出"事务处理已经成功完成",再用企业管理器查看pubs数据库的roysched表,所有title_id字段以"pc"开头的书籍的royalty字段的值都增加了0.1倍。

这里,我们并没有使用ado.net的事务处理机制,而是简单地将执行事务的sql语句当作普通的查询来执行,因此,事实上该事务完全没有用到.net的相关特性。
了解.net中的事务机制

如你所知,在.net框架中主要有两个命名空间(namespace)用于应用程序同数据库系统的交互:system.data.sqlclient和system.data.oledb。前者专门用于连接microsoft公司自己的sql server数据库,而后者可以适应多种不同的数据库。这两个命名空间中都包含有专门用于管理数据库事务的类,分别是system.data.sqlclient.sqltranscation类和system.data.oledb.oledbtranscation类。

就像它们的名字一样,这两个类大部分功能是一样的,二者之间的主要差别在于它们的连接机制,前者提供一组直接调用 sql server 的对象,而后者使用本机 ole db 启用数据访问。 事实上,ado.net 事务完全在数据库的内部处理,且不受 microsoft 分布式事务处理协调器 (dtc) 或任何其他事务性机制的支持。本文将主要介绍system.data.sqlclient.sqltranscation类,下面的段落中,除了特别注明,都将使用system.data.sqlclient.sqltranscation类。
事务的开启和提交

现在我们对事务的概念和原理都了然于心了,并且作为已经有一些基础的c#开发者,我们已经熟知编写数据库交互程序的一些要点,即使用sqlconnection类的对象的open()方法建立与数据库服务器的连接,然后将该连接赋给sqlcommand对象的connection属性,将欲执行的sql语句赋给它的commandtext属性,于是就可以通过sqlcommand对象进行数据库操作了。对于我们将要编写的事务处理程序,当然还需要定义一个sqltransaction类型的对象。并且看到sqlcommand对象的transcation属性,我们很容易想到新建的sqltransaction对象应该与它关联起来。

基于以上认识,下面我们就开始动手写我们的第一个事务处理程序。我们可以很熟练地写出下面这一段程序:

//dotran.csusing system;
using system.data;
using system.data.sqlclient;
namespace aspcn
{
  public class dbtran
  {
   file://执行事务处理
   public void dotran()
   {
    file://建立连接并打开
    sqlconnection myconn=getconn();
    myconn.open();
    sqlcommand mycomm=new sqlcommand();
    sqltransaction mytran=new sqltransaction();
    try
    {
    mycomm.connection=myconn;
    mycomm.transaction=mytran;

     file://定位到pubs数据库 
     mycomm.commandtext="use pubs";
     mycomm.executenonquery();

     file://更新数据
     file://将所有的计算机类图书
     mycomm.commandtext="update roysched set royalty = royalty * 1.10 where title_id like 'pc%'";
     mycomm.executenonquery();//提交事务
     mytran.commit();
    }
    catch(exception err)
    {
     throw new applicationexception("事务操作出错,系统信息:"+err.message);
    }
    finally
    {
     myconn.close();
    }
   }
   file://获取数据连接
   private sqlconnection getconn()
   {
    string strsql="data source=localhost;integrated security=sspi;user id=sa;password=";
    sqlconnection myconn=new sqlconnection(strsql);
    return myconn;
   }
  }

  public class test{public static void main()
  {
   dbtran trantest=new dbtran();
   trantest.dotran();
   console.writeline("事务处理已经成功完成。");
   console.readline();
  }
}

显然,这个程序非常简单,我们非常自信地编译它,但是,出乎意料的结果使我们的成就感顿时烟消云散:

 error cs1501: 重载"sqltransaction"方法未获取"0"参数

是什么原因呢?注意到我们初始化的代码:

sqltransaction mytran=new sqltransaction(); 

显然,问题出在这里,事实上,sqltransaction类并没有公共的构造函数,我们不能这样新建一个sqltrancaction类型的变量。在事务处理之前确实需要有一个sqltransaction类型的变量,将该变量关联到sqlcommand类的transcation属性也是必要的,但是初始化方法却比较特别一点。在初始化sqltransaction类时,你需要使用sqlconnection类的begintranscation()方法:

sqltransaction mytran; mytran=myconn.begintransaction(); 

该方法返回一个sqltransaction类型的变量。在调用begintransaction()方法以后,所有基于该数据连接对象的sql语句执行动作都将被认为是事务mytran的一部分。同时,你也可以在该方法的参数中指定事务隔离级别和事务名称,如:

sqltransaction mytran;
mytran=myconn.begintransaction(isolationlevel.readcommitted,"sampletransaction"); 

关于隔离级别的概念我们将在随后的内容中探讨,在这里我们只需牢记一个事务是如何被启动,并且关联到特定的数据链接的。

先不要急着去搞懂我们的事务都干了些什么,看到这一行:

mytran.commit(); 

是的,这就是事务的提交方式。该语句执行后,事务的所有数据库操作将生效,并且为数据库事务的持久性机制所保持--即使系统在这以后发生致命错误,该事务对数据库的影响也不会消失。

对上面的程序做了修改之后我们可以得到如下代码(为了节约篇幅,重复之处已省略,请参照前文):

//dotran.cs……}

file://执行事务处理
public void dotran()
{
  file://建立连接并打开
  sqlconnection myconn=getconn();
  myconn.open();
  sqlcommand mycomm=new sqlcommand();

  file://sqltransaction mytran=new sqltransaction();
  file://注意,sqltransaction类无公开的构造函数

  sqltransaction mytran;

  file://创建一个事务
  mytran=myconn.begintransaction();
  try
  {
   file://从此开始,基于该连接的数据操作都被认为是事务的一部分
   file://下面绑定连接和事务对象
   mycomm.connection=myconn;
   mycomm.transaction=mytran; file://定位到pubs数据库
   mycomm.commandtext="use pubs";
   mycomm.executenonquery();//更新数据
   file://将所有的计算机类图书
   mycomm.commandtext="update roysched set royalty = royalty * 1.10 where title_id like 'pc%'";
   mycomm.executenonquery();

   file://提交事务
   mytran.commit();
  }
  catch(exception err)
  {
   throw new applicationexception("事务操作出错,系统信息:"+err.message);
  }
  finally
  {
   myconn.close();
  }
}
…… 


到此为止,我们仅仅掌握了如何开始和提交事务。下一步我们必须考虑的是在事务中可以干什么和不可以干什么。

 

另一个走向极端的错误

满怀信心的新手们可能为自己所掌握的部分知识陶醉不已,刚接触数据库库事务处理的准开发者们也一样,踌躇满志地准备将事务机制应用到他的数据处理程序的每一个模块每一条语句中去。的确,事务机制看起来是如此的诱人——简洁、美妙而又实用,我当然想用它来避免一切可能出现的错误——我甚至想用事务把我的数据操作从头到尾包裹起来。

看着吧,下面我要从创建一个数据库开始:

 

using system;
using system.data;
using system.data.sqlclient;

namespace aspcn
{
  public class dbtran
  {
   file://执行事务处理
   public void dotran()
   {
    file://建立连接并打开
    sqlconnection myconn=getconn();
    myconn.open();

    sqlcommand mycomm=new sqlcommand();
    sqltransaction mytran;

    mytran=myconn.begintransaction();

    file://下面绑定连接和事务对象
    mycomm.connection=myconn;
    mycomm.transaction=mytran;

    file://试图创建数据库testdb
    mycomm.commandtext="create database testdb";
    mycomm.executenonquery();

    file://提交事务
    mytran.commit();
   }

   file://获取数据连接
   private sqlconnection getconn()
   {
    string strsql="data source=localhost;integrated security=sspi;user id=sa;password=";
    sqlconnection myconn=new sqlconnection(strsql);
    return myconn;
   }
  }

  public class test
  {
   public static void main()
   {
    dbtran trantest=new dbtran();
    trantest.dotran();
    console.writeline("事务处理已经成功完成。");
    console.readline();
   }
  }
}

//--------------- 


  未处理的异常: system.data.sqlclient.sqlexception: 在多语句事务内不允许使用 create database 语句。


at system.data.sqlclient.sqlcommand.executenonquery()
at aspcn.dbtran.dotran()
at aspcn.test.main() 


注意,如下的sql语句不允许出现在事务中:


alter database  修改数据库 
backup log  备份日志 
create database  创建数据库 
disk init  创建数据库或事务日志设备 
drop database  删除数据库 
dump transaction  转储事务日志 
load database  装载数据库备份复本 
load transaction  装载事务日志备份复本 
reconfigure  更新使用 sp_configure 系统存储过程更改的配置选项的当前配置(sp_configure 结果集中的 config_value 列)值。 
restore database  还原使用backup命令所作的数据库备份 
restore log  还原使用backup命令所作的日志备份 
update statistics  在指定的表或索引视图中,对一个或多个统计组(集合)有关键值分发的信息进行更新 


除了这些语句以外,你可以在你的数据库事务中使用任何合法的sql语句。
事务回滚

事务的四个特性之一是原子性,其含义是指对于特定操作序列组成的事务,要么全部完成,要么就一件也不做。如果在事务处理的过程中,发生未知的不可预料的错误,如何保证事务的原子性呢?当事务中止时,必须执行回滚操作,以便消除已经执行的操作对数据库的影响。

一般的情况下,在异常处理中使用回滚动作是比较好的想法。前面,我们已经得到了一个更新数据库的程序,并且验证了它的正确性,稍微修改一下,可以得到:


//rollback.cs
using system;
using system.data;
using system.data.sqlclient;

namespace aspcn
{
  public class dbtran
  {
   file://执行事务处理
   public void dotran()
   {
    file://建立连接并打开
    sqlconnection myconn=getconn();
    myconn.open();

    sqlcommand mycomm=new sqlcommand();
    sqltransaction mytran;

    file://创建一个事务
    mytran=myconn.begintransaction();
    file://从此开始,基于该连接的数据操作都被认为是事务的一部分
    file://下面绑定连接和事务对象
    mycomm.connection=myconn;
    mycomm.transaction=mytran;

    try
    {
     file://定位到pubs数据库
     mycomm.commandtext="use pubs";
     mycomm.executenonquery();

     mycomm.commandtext="update roysched set royalty = royalty * 1.10 where title_id like 'pc%'";
     mycomm.executenonquery();

     file://下面使用创建数据库的语句制造一个错误
     mycomm.commandtext="create database testdb";
     mycomm.executenonquery();

     mycomm.commandtext="update roysched set royalty = royalty * 1.20 where title_id like 'ps%'";
     mycomm.executenonquery();

     file://提交事务
     mytran.commit();
    }
    catch(exception err)
    {
     mytran.rollback();
     console.write("事务操作出错,已回滚。系统信息:"+err.message);
    }
   }

   file://获取数据连接
   private sqlconnection getconn()
   {
    string strsql="data source=localhost;integrated security=sspi;user id=sa;password=";
    sqlconnection myconn=new sqlconnection(strsql);
    return myconn;
   }
  }
  public class test
  {
   public static void main()
   {
    dbtran trantest=new dbtran();
    trantest.dotran();
    console.writeline("事务处理已经成功完成。");
    console.readline();
   }
  }


首先,我们在中间人为地制造了一个错误——使用前面讲过的create database语句。然后,在异常处理的catch块中有如下语句:

mytran.rollback();

当异常发生时,程序执行流跳转到catch块中,首先执行的就是这条语句,它将当前事务回滚。在这段程序可以看出,在create database之前,已经有了一个更新数据库的操作——将pubs数据库的roysched表中的所有title_id字段以“pc”开头的书籍的royalty字段的值都增加0.1倍。但是,由于异常发生而导致的回滚使得对于数据库来说什么都没有发生。由此可见,rollback()方法维护了数据库的一致性及事务的原子性。

使用存储点

事务只是一种最坏情况下的保障措施,事实上,平时系统的运行可靠性都是相当高的,错误很少发生,因此,在每次事务执行之前都检查其有效性显得代价太高——绝大多数的情况下这种耗时的检查是不必要的。我们不得不想另外一种办法来提高效率。

事务存储点提供了一种机制,用于回滚部分事务。因此,我们可以不必在更新之前检查更新的有效性,而是预设一个存储点,在更新之后,如果没有出现错误,就继续执行,否则回滚到更新之前的存储点。存储点的作用就在于此。要注意的是,更新和回滚代价很大,只有在遇到错误的可能性很小,而且预先检查更新的有效性的代价相对很高的情况下,使用存储点才会非常有效。

使用.net框架编程时,你可以非常简单地定义事务存储点和回滚到特定的存储点。下面的语句定义了一个存储点“noupdate”:

mytran.save("noupdate");

当你在程序中创建同名的存储点时,新创建的存储点将替代原有的存储点。

在回滚事务时,只需使用rollback()方法的一个重载函数即可:

   mytran.rollback("noupdate");

下面这段程序说明了回滚到存储点的方法和时机:
using system;
using system.data;
using system.data.sqlclient;

namespace aspcn
{
  public class dbtran
  { 
   file://执行事务处理
   public void dotran()
   {
    file://建立连接并打开
    sqlconnection myconn=getconn();
    myconn.open();

    sqlcommand mycomm=new sqlcommand();
    sqltransaction mytran;

    file://创建一个事务
    mytran=myconn.begintransaction();
    file://从此开始,基于该连接的数据操作都被认为是事务的一部分
    file://下面绑定连接和事务对象
    mycomm.connection=myconn;
    mycomm.transaction=mytran;

    try
    {
     mycomm.commandtext="use pubs";
     mycomm.executenonquery();

     mytran.save("noupdate");

     mycomm.commandtext="update roysched set royalty = royalty * 1.10 where title_id like 'pc%'";
     mycomm.executenonquery();

     file://提交事务
     mytran.commit();
    }
    catch(exception err)
    {
     file://更新错误,回滚到指定存储点
     mytran.rollback("noupdate");
     throw new applicationexception("事务操作出错,系统信息:"+err.message);
    }
   }

   file://获取数据连接
   private sqlconnection getconn()
   {
    string strsql="data source=localhost;integrated security=sspi;user id=sa;password=";
    sqlconnection myconn=new sqlconnection(strsql);
    return myconn;
   }
  }
  public class test
  {
   public static void main()
   {
    dbtran trantest=new dbtran();
    trantest.dotran();
    console.writeline("事务处理已经成功完成。");
    console.readline();
   }
  }
}
 


很明显,在这个程序中,更新无效的几率是非常小的,而且在更新前验证其有效性的代价相当高,因此我们无须在更新之前验证其有效性,而是结合事务的存储点机制,提供了数据完整性的保证。

隔离级别的概念

企业级的数据库每一秒钟都可能应付成千上万的并发访问,因而带来了并发控制的问题。由数据库理论可知,由于并发访问,在不可预料的时刻可能引发如下几个可以预料的问题:

脏读 :包含未提交数据的读取。例如,事务1 更改了某行。事务2 在事务1 提交更改之前读取已更改的行。如果事务1 回滚更改,则事务2 便读取了逻辑上从未存在过的行。

不可重复读取 :当某个事务不止一次读取同一行,并且一个单独的事务在两次(或多次)读取之间修改该行时,因为在同一个事务内的多次读取之间修改了该行,所以每次读取都生成不同值,从而引发不一致问题。

幻象 :通过一个任务,在以前由另一个尚未提交其事务的任务读取的行的范围中插入新行或删除现有行。带有未提交事务的任务由于该范围中行数的更改而无法重复其原始读取。

如你所想,这些情况发生的根本原因都是因为在并发访问的时候,没有一个机制避免交叉存取所造成的。而隔离级别的设置,正是为了避免这些情况的发生。事务准备接受不一致数据的级别称为隔离级别。隔离级别是一个事务必须与其它事务进行隔离的程度。较低的隔离级别可以增加并发,但代价是降低数据的正确性。相反,较高的隔离级别可以确保数据的正确性,但可能对并发产生负面影响。

根据隔离级别的不同,dbms为并行访问提供不同的互斥保证。在sql server数据库中,提供四种隔离级别:未提交读、提交读、可重复读、可串行读。这四种隔离级别可以不同程度地保证并发的数据完整性:
隔离级别  脏 读  不可重复读取  幻 像 
未提交读  是  是  是 
提交读  否  是  是 
可重复读  否  否  是 
可串行读  否  否  否 


可以看出,“可串行读”提供了最高级别的隔离,这时并发事务的执行结果将与串行执行的完全一致。如前所述,最高级别的隔离也就意味着最低程度的并发,因此,在此隔离级别下,数据库的服务效率事实上是比较低的。尽管可串行性对于事务确保数据库中的数据在所有时间内的正确性相当重要,然而许多事务并不总是要求完全的隔离。例如,多个作者工作于同一本书的不同章节。新章节可以在任意时候提交到项目中。但是,对于已经编辑过的章节,没有编辑人员的批准,作者不能对此章节进行任何更改。这样,尽管有未编辑的新章节,但编辑人员仍可以确保在任意时间该书籍项目的正确性。编辑人员可以查看以前编辑的章节以及最近提交的章节。这样,其它的几种隔离级别也有其存在的意义。

在.net框架中,事务的隔离级别是由枚举system.data.isolationlevel所定义的:


[flags]
[serializable]
public enum isolationlevel 


其成员及相应的含义如下:


成 员  含 义 
chaos  无法改写隔离级别更高的事务中的挂起的更改。 
readcommitted  在正在读取数据时保持共享锁,以避免脏读,但是在事务结束之前可以更改数据,从而导致不可重复的读取或幻像数据。 
readuncommitted  可以进行脏读,意思是说,不发布共享锁,也不接受独占锁。 
repeatableread  在查询中使用的所有数据上放置锁,以防止其他用户更新这些数据。防止不可重复的读取,但是仍可以有幻像行。 
serializable  在dataset上放置范围锁,以防止在事务完成之前由其他用户更新行或向数据集中插入行。 
unspecified  正在使用与指定隔离级别不同的隔离级别,但是无法确定该级别。
 


显而意见,数据库的四个隔离级别在这里都有映射。

默认的情况下,sql server使用readcommitted(提交读)隔离级别。

关于隔离级别的最后一点就是如果你在事务执行的过程中改变了隔离级别,那么后面的命名都在最新的隔离级别下执行——隔离级别的改变是立即生效的。有了这一点,你可以在你的事务中更灵活地使用隔离级别从而达到更高的效率和并发安全性。

最后的忠告

无疑,引入事务处理是应对可能出现的数据错误的好方法,但是也应该看到事务处理需要付出的巨大代价——用于存储点、回滚和并发控制所需要的cpu时间和存储空间。

  • 本文来源于网页设计爱好者web开发社区http://www.html.org.cn收集整理,欢迎访问。
  • 发表评论 共有条评论
    用户名: 密码:
    验证码: 匿名发表