前言

在各种orm框架或者sql映射框架(例如mybatis,sod框架之sql-map功能)中,都有将查询的结果映射为内存对象的需求,包括映射到实体类、简单类型(例如java的pojo,.net的poco)的对象。在.net中,这个过程可以通过ado.net的datareader对象来读取数据,然后将读取的数据映射到内存对象。本篇文章来讨论下不同方式的数据读取方式对性能的影响。

在写这篇文章之前,我在想现在都2020年全民奔小康了,除了微软官方的ef框架之外,各种orm框架层出不穷,连笔者的sod框架都诞生15年了,还有必要研究这么low的问题吗?后来想了想,自己写博客主要是总结经验,记录问题分析过程的,虽然笔者在2013年就做过一个测试,写了《用事实说话,成熟的orm性能不是瓶颈,灵活性不是问题:ef5.0、pdf.net5.0、dapper原理分析与测试手记》,但这篇文章已经过去6年多时间了,.net框架都发展到跨平台的.net core了,现在dapper更火了,基于emit和表达式树的orm轮子层出不穷,性能和易用性都不错,这些优秀的orm框架获得了很高的关注,而sod框架一直很低调,因为它一直没用采用emit和表达式树技术,也没有采用反射,而是最原始的datareader的非类型化数据读取方式,性能上可能比不上这些orm框架,但会有多大的差异呢?sod框架一直强调自己不仅仅是一个orm框架,orm仅仅是它的一个功能组件,不过大家既然都这么强调性能,于是决定重新测试一下datareader的非类型化数据读取与类型化数据读取的性能差异,演示下正确使用两者的方式。

映射对象

下面的测试方法都是将数据库同样的数据通过datareader读取出来映射到不同的对象中,本篇文章测试用来映射的对象一个是sod框架的实体类,一个是普通的dto对象,dto是poco的一种。下面是这两种对象的定义:

sod实体对象类user的定义:

 public class user : entitybase
    {
        public user()
        {
            tablename="tb_user1";
           
            identityname = "userid";
            primarykeys.add("userid");
        }

        /// <summary>
        /// 设置字段名数组,如果不实现该方法,框架会自动反射获取到字段名数组,因此从效率考虑,建议实现该方法
        /// </summary>
        protected override void setfieldnames()
        {
            propertynames = new string[] { "userid", "name", "pwd", "registeddate" };
        }

        /// <summary>
        /// 获取实体类全局唯一标识;重写该方法,可以加快访问效率
        /// </summary>
        /// <returns></returns>
        public override string getgolbalentityid()
        {
            //使用工具-》创建guid 生成
            return "f1344072-ab1e-4bcf-a28c-769c7c4aa06b";
        }

        public int id
        {
            get { return getproperty<int>("userid"); }
            set { setproperty("userid", value); }
        }

        public string name
        {
            get { return getproperty<string>("name"); }
            set { setproperty("name", value, 50); }
        }

        public string pwd
        {
            get { return getproperty<string>("pwd"); }
            set { setproperty("pwd", value, 50); }
        }

        public datetime registeddate
        {
            get { return getproperty<datetime>("registeddate"); }
            set { setproperty("registeddate", value); }
        }

    }

dto类 userdto的定义,跟实体类user完全一样的属性名称和属性类型:

 public class userdto
    {
        public userdto()
        {
        }

        public int userid
        { get; set; }

        public string name
        { get; set; }

        public string pwd
        { get; set; }

        public datetime registeddate
        { get; set; }
    }

下面开始不同的查询方式测试。

1,手写查询映射

测试方案为将datareader读取出来的数据手工逐一映射到一个poco对象的属性上,例如下面映射到userdto对象上。根据查询时候的sql语句中指定的数据列的顺序和类型来使用datareader是效率最高的方式,也就是datareader类型化数据读取方法,使用字段索引而不是字段名称来读取数据的方式,如下面示例代码中的reader.getint32(0) :

//手写datareader查询
private static long handquery(adohelper db, system.diagnostics.stopwatch watch)
{
    watch.restart();
    string sql = "select  userid, name, pwd, registeddate from tb_user1";
    ilist<userdto> list = db.executemapper(sql).maptolist<userdto>(reader => new userdto
    {
        userid = reader.isdbnull(0)? default(int): reader.getint32(0),
        name = reader.isdbnull(1) ? default(string) : reader.getstring(1),
        pwd = reader.isdbnull(2) ? default(string) : reader.getstring(2),
        registeddate = reader.isdbnull(3) ? default(datetime) : reader.getdatetime(3)
    });
    watch.stop();
    console.writeline("handquery list (100000 item) 耗时:(ms)" + watch.elapsedmilliseconds);
    return watch.elapsedmilliseconds;
}

代码说明:

方法的第一个参数db是sod框架的adohelper对象,它是对各种数据库进行访问的一个提供程序类,封装了ado.net各种对象的访问,包括自动管理连接、执行查询、管理事务和记录日志等功能。在当前测试程序中这里它的实例对象是sql server访问提供程序。adohelper对象的executemapper方法将数据查询结果封装成一个datareadermapper对象,然后可以使用该对象的maptolist方法使用datareader对象的类型化数据读取方法,将读取的值赋值给要映射的对象的属性,例如这里的userdto对象。需要注意的是,在调用datareader的类型化数据读取方法的时候,必须先判断当前位置的数据是否空数据(dbnull),否则会出错。例如上面的示例代码中,如果索引位置0的数据为空数据,则给userdto对象的userid属性赋值int类型的默认值0。maptolist方法会读取结果集的所有数据,读取完后自动关闭连接。

adohelper对象的封装比较简单,并且上面的查询会查询tb_user1表的全部10万条数据,所以在讨论查询性能的时候,可以认为绝大部分时间都是在处理datareader读取数据的问题,并且还采用了比字段名定位数据读取位置更高效的字段索引读取的方式,因此可以认为handquery方法的查询等同于最高效的手写查询方式。

2,映射数据到poco对象

上面的手写测试代码看起来简单,但是必须清楚当前读取字段的索引位置和当前字段的数据类型,当sql比较复杂或者sql语句不在当前方法内设置的,那么要写这种代码就很困难了并且还容易出错,所以手写代码使用类型化数据读取和对象属性映射就是一个费力不讨好的“体力活”,除非对性能有极高要求否则一般人都不会这样直接处理查询映射。要解决这个问题我们可以使用反射、emit或者表达式树来动态生成这种跟手写查询一样的代码。

sod框架并没有使用上面的几种方式来模拟手写查询代码,而是使用datareader的非类型化数据读取方式,再结合委托和缓存的方式来高效访问要映射的对象,例如当前要映射的poco对象。这个过程可以通过adohelper对象的querylist方法来完成,请看下面的示例代码:

 private static long querypoco(adohelper db, system.diagnostics.stopwatch watch)
 {
     watch.restart();
     string sql = "select  userid, name, pwd, registeddate from tb_user1";
     ilist<userdto> list = db.querylist<userdto>(sql);
     watch.stop();
     console.writeline("querypoco list (100000 item) 耗时:(ms)" + watch.elapsedmilliseconds);
     return watch.elapsedmilliseconds;
 }

代码说明:

使用adohelper对象的querylist方法要求要映射的对象的属性名字和查询结果集的字段名必须严格一致,如果名字不一致,可以在sql语句中使用字段别名。querylist方法可以接受多个参数,除了第一个参数是要执行的sql语句之外,其它参数可以是sql语句中的“参数”。所以这个查询方式非常简单,只需要一行代码就可完成查询,类似dapper的功能,所以这个功能算是sod框架中的“微型orm”。

下面是querylist方法的定义和使用示例:

  /// <summary>
  /// 根据sql格式化串和可选的参数,直接查询结果并映射到poco 对象
  /// <example>
  /// <code>
  /// <![cdata[
  /// //假设userpoco 对象跟 table_user 表是映射的相同结构
  /// adohelper dblocal = new sqlserver();
  /// dblocal.connectionstring = "data source=.;initial catalog=localdb;integrated security=true";
  /// var list=dbloal.querylist<userpoco>("select uid,name from table_user where sex={0} and height>={1:5.2}",1, 1.60m);
  /// ]]>
  /// </code>
  /// </example>
  /// </summary>
  /// <typeparam name="t">poco 对象类型</typeparam>
  /// <param name="sqlformat">sql格式化串</param>
  /// <param name="parameters">可选的参数</param>
  /// <returns>poco 对象列表</returns>
  public  list<t> querylist<t>(string sqlformat, params object[] parameters) where t : class, new()
  {
      idatareader reader = formatexecutedatareader(sqlformat, parameters);
      return querylist<t>(reader);
  }

如上代码所示,方法第一个参数是一个sql格式化字符串,在这个格式化字符串中可以有多个参数,就像string.format方法的使用一样。例如上面方法的注释中查询条件sex字段的参数和height字段的参数,其中height字段的参数的格式是精度为5,小数位数为2的浮点数。

上面的方法调用了querylist泛型方法来处理datareader对象读取的数据,下面看看它的实现:

/// <summary>
/// 采用快速的方法,将数据阅读器的结果映射到一个poco类的列表上
/// </summary>
/// <typeparam name="t">poco类类型</typeparam>
/// <param name="reader">抽象数据阅读器</param>
/// <returns>poco类的列表</returns>
public static list<t> querylist<t>(idatareader reader) where t : class, new()
{
    list<t> list = new list<t>();
    using (reader)
    {
        if (reader.read())
        {
            int fcount = reader.fieldcount;
            //使用类型化委托读取正确的数据,解决mysql等数据库可能的问题,感谢网友 @卖女孩的小肥羊 发现此问题
            dictionary<type, myfunc<idatareader, int, object>> readerdelegates = datareaderdelegate();
            myfunc<idatareader, int, object>[] getdatamethods = new myfunc<idatareader, int, object>[fcount];

            inamedmemberaccessor[] accessors = new inamedmemberaccessor[fcount];
            delegatedreflectionmemberaccessor accessormethod = new delegatedreflectionmemberaccessor();
            for (int i = 0; i < fcount; i++)
            {
                accessors[i] = accessormethod.findaccessor<t>(reader.getname(i));
                //修改成从poco实体类的属性上来获取datareader类型化数据访问的方法,而不是之前的datareader 的字段的类型
                if (!readerdelegates.trygetvalue(accessors[i].membertype, out getdatamethods[i]))
                {
                    getdatamethods[i] = (rd, ii) => rd.getvalue(ii);
                }
            }
            
            do
            {
                t t = new t();
                for (int i = 0; i < fcount; i++)
                {
                    if (!reader.isdbnull(i))
                    {
                        myfunc<idatareader, int, object> read = getdatamethods[i];
                        object value=read(reader,i);
                        accessors[i].setvalue(t, value);
                    }
                        
                }
                list.add(t);
            } while (reader.read());
        }
    }
    return list;
}

在上面的代码中的do循环之前,为要映射的poco对象的每个属性访问器构建了一个myfunc<idatareader, int, object> 委托,该委托实际上来自于sod框架预定义的一个处理datareader类型化数据读取的委托,为了通用,上面这个委托方法返回值定义成了object类型,这样在实际调用的时候会进行“装箱”操作,也就是上面方法的代码:

 object value=read(reader,i);
 accessors[i].setvalue(t, value);

之所以要进行装箱,是因为属性访问器方法setvalue需要一个object类型参数。

返回datareader类型化数据读取方法委托的datareaderdelegate方法定义如下:

 private static dictionary<type, myfunc<idatareader, int, object>> dictreaderdelegate = null;
 private static dictionary<type, myfunc<idatareader, int, object>> datareaderdelegate()
 {
     if (dictreaderdelegate == null)
     {
        dictionary<type, myfunc<idatareader, int, object>> dictreader = new dictionary<type, myfunc<idatareader, int, object>>();
        dictreader.add(typeof(int), (reader, i) => reader.getint32(i));
        dictreader.add(typeof(bool), (reader, i) => reader.getboolean(i));
        dictreader.add(typeof(byte), (reader, i) => reader.getbyte(i));
        dictreader.add(typeof(char), (reader, i) => reader.getchar(i));
        dictreader.add(typeof(datetime), (reader, i) => reader.getdatetime(i));
        dictreader.add(typeof(decimal), (reader, i) => reader.getdecimal(i));
        dictreader.add(typeof(double), (reader, i) => reader.getdouble(i));
        dictreader.add(typeof(float), (reader, i) => reader.getfloat(i));
        dictreader.add(typeof(guid), (reader, i) => reader.getguid(i));
        dictreader.add(typeof(system.int16), (reader, i) => reader.getint16(i));
        dictreader.add(typeof(system.int64), (reader, i) => reader.getint64(i));
        dictreader.add(typeof(string), (reader, i) => reader.getstring(i));
        dictreader.add(typeof(object), (reader, i) => reader.getvalue(i));

        dictreaderdelegate = dictreader;
    }
   return dictreaderdelegate;
}

3,sod框架的datareader非类型化数据读取

sod框架的实体类查询方法直接使用了datareader非类型化数据读取方式,一次性将一行数据读取到一个object[]对象数组中,sod实体类将直接使用这个object[]对象数组,这使得数据映射过程可以大大简化代码,并且取得不错的效率。下面是测试实体类查询方法的示例代码:

private static long entityquery(adohelper db, system.diagnostics.stopwatch watch)
 {
     watch.restart();
     user user = new user();
     oql q = oql.from(user).select(user.id, user.name, user.pwd, user.registeddate).end;
     //q.limit(5000);
     var list = entityquery<user>.querylist(q, db);
     watch.stop();
     console.writeline("sod querylist list (100000 item) 耗时:(ms)" + watch.elapsedmilliseconds);
     return watch.elapsedmilliseconds;
 }

下面是querylist方法有关数据读取和映射的具体实现部分:

  /// <summary>
  /// 根据数据阅读器对象,查询实体对象集合(注意查询完毕将自动释放该阅读器对象)
  /// </summary>
  /// <param name="reader">数据阅读器对象</param>
  /// <param name="tablename">指定实体类要映射的表名字,默认不指定</param>
  /// <returns>实体类集合</returns>
  public static list<t> querylist(system.data.idatareader reader,string tablename)
  {
      list<t> list = new list<t>();
      if (reader == null)
          return list;
      using (reader)
      {
          if (reader.read())
          {
              int fcount = reader.fieldcount;
              string[] names = new string[fcount];

              for (int i = 0; i < fcount; i++)
                  names[i] = reader.getname(i);
              t t0 = new t();
              if (!string.isnullorempty(tablename))
                  t0.mapnewtablename(tablename);
              t0.propertynames = names;
              do
              {
                  object[] values = new object[fcount];
                  reader.getvalues(values);

                  t t = (t)t0.clone(false );

                  //t.propertynames = names;
                  t.propertyvalues = values;

                  list.add(t);

              } while (reader.read());

          }
      }
      return list;
  }

上面的方法直接使用了datareader对象的非类型化数据读取方法getvalues,将数据读取到values数组对象中。在当前querylist方法中没用对datareader对象读取的数据进行装箱,但是这种方式相比测试方式1的手写映射方式性能还是要低,猜测方法内部进行了复杂的处理,否则无法解释测试方式2测试代码中类型化数据读取后数据进行装箱后供数据访问器使用,测试2的测试性能仍然高于当前测试方式3,但不会有太大的性能差距。

4,类型化读取到数组元素中

如果datareader对象类型化读取速度一定比非类型化数据读取方法getvalues快,那么可以尝试将类型化数据读取的值装箱到数组元素中,这样有可能提高sod框架现有的querylist方法的性能。下面模拟对querylist方法进行修改,使得datareader对象类型化读取到数组元素中。请看下面的示例代码:

 

 private static long entityquery2(adohelper db, system.diagnostics.stopwatch watch)
 {
     watch.restart();
     string sql = "select  userid, name, pwd, registeddate from tb_user1";

     string tablename = "";
     user entity = new user();
     idatareader reader = db.executedatareader(sql);
     list<user> list = new list<user>();
     using (reader)
     {
         if (reader.read())
         {
             int fcount = reader.fieldcount;
             string[] names = new string[fcount];

             for (int i = 0; i < fcount; i++)
                 names[i] = reader.getname(i);
             user t0 = new user();
             if (!string.isnullorempty(tablename))
                 t0.mapnewtablename(tablename);
             //正式,下面放开
             // t0.propertynames = names;
             //
             action< int, object[]> readint = ( i, o) => { if (reader.isdbnull(i)) o[i] = dbnull.value; else o[i] = reader.getint32(i); };
             action< int, object[]> readstring = ( i, o) => { if (reader.isdbnull(i)) o[i] = dbnull.value; else o[i] = reader.getstring(i); };
             action< int, object[]> readdatetime = ( i, o) => { if (reader.isdbnull(i)) o[i] = dbnull.value; else o[i] = reader.getdatetime(i); };
             action< int, object[]>[] readeractions = {
                      readint,readstring,readstring,readdatetime
               };
             //
             do
             {
                 user item = (user)t0.clone(false);
                 for (int i = 0; i < readeractions.length; i++)
                 {
                     readeractions[i]( i, item.propertyvalues);
                 }

                 list.add(item);
             } while (reader.read());

         }
     }

     //return list;
     watch.stop();
     console.writeline("entityquery2 list (10000 item) 耗时:(ms)" + watch.elapsedmilliseconds);
     return watch.elapsedmilliseconds;
 }

 

 测试过程

以上4种测试方法准备完毕,下面准备测试数据,使用sql server express localdb 创建一个数据库文件,在此文件数据库中创建一个user实体类对应的数据表,然后插入10万条数据,这个功能可以通过sod框架下面的代码实现:

 private static void initdata(adohelper db, system.diagnostics.stopwatch watch)
 {
     //自动创建数据库和表
     localdbcontext context = new localdbcontext();
     console.writeline("需要初始化数据吗?(y/n) ");
     string input= console.readline();
     if (input.tolower() != "y") return;
     console.writeline("正在初始化数据,请稍后。。。。");
     context.truncatetable<user>();
     console.writeline("...");
     watch.restart();
     list<user> batchlist = new list<user>();
     for (int i = 0; i < 100000; i++)
     {
         user zhang_yeye = new user() { id = 1000 + i, name = "zhang yeye" + i, pwd = "pwd" + i ,registeddate =datetime.now };
         //count += entityquery<user>.instance.insert(zhang_yeye);//采用泛型 entityquery 方式插入数据
         batchlist.add(zhang_yeye);
     }
     watch.stop();
     console.writeline("准备数据 耗时:(ms)" + watch.elapsedmilliseconds);

     watch.restart();
     int count = entityquery<user>.instance.quickinsert(batchlist);
     watch.stop();
     console.writeline("quickinsert list (100000 item) 耗时:(ms)" + watch.elapsedmilliseconds);
     system.threading.thread.sleep(1000);
 }

代码说明:

上面的方法中首先初始化数据库,通过dbcontext对象自动创建数据表,并且通过truncatetable 方法快速清除原来的测试数据。接着在内存中添加10万条数据,然后将它使用quickinsert方法快速插入到数据库。

下面就可以给出完整的测试过程了,直接看代码:

static void main(string[] args)
{
    system.diagnostics.stopwatch watch = new system.diagnostics.stopwatch();
    watch.start();
    adohelper db = mydb.getdbhelperbyconnectionname("local");
    initdata(db, watch);

    long[] usetime1 = new long[10];
    long[] usetime2 = new long[10];
    long[] usetime3 = new long[10];
    long[] usetime4 = new long[10];

    for (int i = 0; i < 10; i++)
    {
        usetime1[i]= handquery(db, watch);
        system.threading.thread.sleep(1000); //便于观察cpu、内存等资源变化

        usetime2[i] = querypoco(db, watch);
        system.threading.thread.sleep(1000);

        usetime3[i] = entityquery(db, watch);
        system.threading.thread.sleep(1000);

        usetime4[i] = entityquery2(db, watch);
        system.threading.thread.sleep(1000);

        console.writeline("run test no.{0},sleep 1000 ms", i + 1);
        console.writeline();
    }
    //去掉热身的第一次
    usetime1[0] = 0;
    usetime2[0] = 0;
    usetime3[0] = 0;
    usetime4[0] = 0;
    console.writeline("avg handquery={0} ms, \r\n avg querypoco={1} ms, \r\n avg sod entityquery={2} ms,\r\n avg entityquery2={3} ms"
        , usetime1.average(),usetime2.average(),usetime3.average(), usetime4.average());
    
    console.readline();
}

测试过程去掉第一次循环测试的“热身”过程,计算剩余9次不同方式的平均执行时间,下面是在笔者笔记本电脑(intel i7-4720hq cpu 2.6ghz,12g ram,普通硬盘)的测试结果:

需要初始化数据吗?(y/n)
y
正在初始化数据,请稍后。。。。
...
准备数据 耗时:(ms)225
quickinsert list (100000 item) 耗时:(ms)5363
handquery list (100000 item) 耗时:(ms)158
querypoco list (100000 item) 耗时:(ms)188
sod querylist list (100000 item) 耗时:(ms)251
entityquery2 list (10000 item) 耗时:(ms)281
run test no.1,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)139
querypoco list (100000 item) 耗时:(ms)192
sod querylist list (100000 item) 耗时:(ms)194
entityquery2 list (10000 item) 耗时:(ms)283
run test no.2,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)156
querypoco list (100000 item) 耗时:(ms)177
sod querylist list (100000 item) 耗时:(ms)224
entityquery2 list (10000 item) 耗时:(ms)289
run test no.3,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)183
querypoco list (100000 item) 耗时:(ms)179
sod querylist list (100000 item) 耗时:(ms)213
entityquery2 list (10000 item) 耗时:(ms)265
run test no.4,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)172
querypoco list (100000 item) 耗时:(ms)179
sod querylist list (100000 item) 耗时:(ms)226
entityquery2 list (10000 item) 耗时:(ms)273
run test no.5,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)172
querypoco list (100000 item) 耗时:(ms)211
sod querylist list (100000 item) 耗时:(ms)192
entityquery2 list (10000 item) 耗时:(ms)229
run test no.6,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)202
querypoco list (100000 item) 耗时:(ms)229
sod querylist list (100000 item) 耗时:(ms)191
entityquery2 list (10000 item) 耗时:(ms)240
run test no.7,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)190
querypoco list (100000 item) 耗时:(ms)177
sod querylist list (100000 item) 耗时:(ms)218
entityquery2 list (10000 item) 耗时:(ms)274
run test no.8,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)166
querypoco list (100000 item) 耗时:(ms)191
sod querylist list (100000 item) 耗时:(ms)197
entityquery2 list (10000 item) 耗时:(ms)229
run test no.9,sleep 1000 ms

handquery list (100000 item) 耗时:(ms)179
querypoco list (100000 item) 耗时:(ms)192
sod querylist list (100000 item) 耗时:(ms)213
entityquery2 list (10000 item) 耗时:(ms)253
run test no.10,sleep 1000 ms

avg handquery=155.9 ms,
 avg querypoco=172.7 ms,
 avg sod entityquery=186.8 ms,
 avg entityquery2=233.5 ms

测试结果说明,sod框架的querypoco“微型orm”功能性能不错,虽然有数据装箱过程,但仍然接近手写代码数据映射的方式。sod框架最常用的entityquery实体查询性能接近于querypoco方式,而本次的测试方法4尝试将类型化数据读取到object数组对象也有装箱过程,性能却远低于entityquery实体查询方式。那么测试方法4的entityquery2方法中如果不装箱,直接采用读取指定位置数据为object类型能否性能明显提升呢?比如将方法中下面的代码:

 action< int, object[]> readint = ( i, o) => { if (reader.isdbnull(i)) o[i] = dbnull.value; else o[i] = reader.getint32(i); };

修改为:

 action< int, object[]> readint = ( i, o) => { if (reader.isdbnull(i)) o[i] = dbnull.value; else o[i] = reader.getvalue(i); };

经测试,修改前后性能没用明显的改善,两者性能基本相同。看来datareader对象是否使用类型化数据读取对性能没用明显的影响,也就是读取的数据是否装箱对于orm的数据映射性能没有明显影响,orm查询过程中对性能影响最大的应该是数据库,而不是数据装箱。测试方法4还说明了,将datareader的数据一次性读取到object[]对象数组中,性能要明显高于逐字段读取,不管是类型化读取还是非类型化读取。

这次测试也说明,sod框架的orm性能与手写代码查询映射的性能接近,没有明显的差距,sod框架仍然是一个简单、高效、可靠的,值得使用的数据开发框架。本次测试的全部代码都在sod项目解决方案的“sodtest”程序集项目中,源码仓库地址:

——————————————————————————————

最后值此元旦之际,向奋斗在一线的广大程序员朋友致敬!

为感谢广大sod框架(原pdf.net框架)用户朋友和所有支持、关心的朋友,让大家把“增删改查”的项目做的更快、更好,笔者花了一年多时间写了一本有关数据开发与架构实战的书:《sod框架“企业级”应用数据架构实战》,目前出版社已经在校对阶段,预计年后将跟读者朋友见面,欢迎大家关注!

sod框架高级用户qq群:18215717