# Scree - 基于ORM的一整套框架思想与体系 **Repository Path**: jibamao/Scree ## Basic Information - **Project Name**: Scree - 基于ORM的一整套框架思想与体系 - **Description**: 这是一套使用C#开发的ORM框架,对象基于版本控制,集成事务、缓存、同步与锁。作者力图在简单、实用、可扩展和分布式中寻求合适的平衡,它并不仅仅是ORM,而是一整套基础架构的思想,在简洁直白中传达架构思维的艺术。 - **Primary Language**: C# - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2017-08-28 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ### 目录 一、Scree是为了解决什么问题? 二、Scree为了取代数据访问层是如何思考的?做了哪些事情? 三、现在开始使用scree 四、Scree的高阶能力 五、分布式环境下的scree 六、常见问题集锦 ### 前言 2008年初,来上海不久的我,有幸加入了一家在沪上的.net领域还算有一定名气的公司。第一次接触到ORM,那是技术老大自行研发的。其实,它并不仅仅是ORM,而是基于ORM的一整套框架体系。当时的我就被震撼到了,对一套框架来说,核心并不在于代码,而在于它曾经面临什么样的问题,在于它解决问题的思维方式。随着研究和使用的深入,越发觉得它的思想在当时很超前的(这里就不多提了),同时,也发现了它的一些不足和过度设计的问题。于是,我萌生了写一套自己框架的想法。说干就干,这一干,从08年开始断断续续就好几年。迭代了很多版本,逐步完善了很多功能,也删了更多的功能。其后,项目经过几年在磁盘中的封印,又经过几年在实际生产中的应用,又经过N年文档懒癌的治疗,终于下定决心开源出来。我喜欢写代码,特别喜欢逻辑思考的过程,但一点也不喜欢写项目文档,何况还是教别人怎么使用这一堆代码的文档。 项目名scree(小石子)来源于大学期间,远在04年的故事,暂不多提了,说了太多废话,开始进入正题。再家里奶奶般的重复一遍,问题与解决问题的思想才是最重要的。 ### 一、Scree是为了解决什么问题? 要回答这个问题,不得不提非ORM的开发模式,同时也不得不提三层架构。 ![三层架构](https://git.oschina.net/uploads/images/2017/0822/142826_dc863372_1132503.jpeg "cb8065380cd79123c72ab2f0aa345982b3b7806f.jpg") 三层与N层这里不展开,需要提及的是三层中的数据访问层不一定就是一层,也可以是两层、三层甚至N层。在非ORM开发模式下,通过在业务逻辑层编写SQL语句(很多时候也以视图或存储过程的形态存在),然后调用数据访问层而达到读写数据的目的。而基于ORM底层框架(这里需要加上底层这个定义,随着技术的发展,系统规模的膨胀三层框架任何一层在横向和纵向都有了大规模的层级扩展),主旨就是为了替换掉数据访问层同时变业务逻辑层的SQL操作方式为对象化操作方式,这也是scree的初衷。 对象化操作的好处就不用多说了,谁用谁知道。ORM的出现,将面向对象语言的系统构建过程全面的对象化了,不再留有SQL时代的缺憾。数据终于全部变成了一个个、一组组的对象,一线开发人员的精气神可以更专注于业务逻辑,更有效率的团队协作、更快速的数据迁移、更灵活的系统扩展成为可能。 ### 二、Scree为了取代数据访问层是如何思考的?做了哪些事情? **1、自动生成表** 既然祭出了ORM的旗帜,那么class与表、object与数据行的对应关系维护就是基本需求。先看如下一段代码: ``` public enum NewsType { Military = 0, World = 1, Society = 2, Culture = 3, Travel = 4, } public class News : SRO { [StringDataType(IsNullable = false, Length = 50)] public string Title { get; set; } [StringDataType(IsMaxLength = true)] public string Context { get; set; } public string Author { get; set; } public NewsType Type { get; set; } public int ReadingQuantity { get; set; } static News() { TimeStampService.RegisterIdFormat("xw{0:yyMMdd}{1}"); } } ``` - Scree会自动将继承自SRO的类生成为数据库中同名的表(不支持配置不同的表名,这不是一个技术问题,应该来说最初是有这个设计的,在再三考虑下,取消这个支持。少就是多,在至简的名义追求最大的可用才是最美的。对于很多不支持的功能,不是不能支持,而是经过慎重考虑不予支持,下同,不在赘述)。 - 仅支持SQL Server,如果确实有其他数据库的需求,请自行修改Scree.DataBase.SQLServer - 支持数据类型为int、bool、string、DateTime、枚举、decimal、long、byte[],分别对应数据库中的int、bit、nvarchar或text、datetime、int、decimal、bigint、image - 可用通过对属性设置Attribute来指定字段类型详细信息,详见Scree.Attributes - string默认为nvarchar(32),decimal默认为decimal(18,4) - 系统只会自动创建新class对应的表,如果是class的字段有修改,需要人工修改数据库 - SRO基类中提供了5个默认属性Id、CreatedDate、LastAlterDate、Version、IsDeleted,也就是所有scree的数据对象都会自带这5个字段,用途后面还会详解 **2、增删改查** 对数据的基本操作,莫过于增删改查,下面演示如何通过操作对象方便的读写对应的数据。 - 增加 ``` //如果直接new也是可以的,目前是等效的,建议统一使用CreateObject,未来可以利用CreateObject搞一些事情 //News news = new News(); News news = PersisterService.CreateObject(); news.Title = "新闻标题"; news.Context = "新闻内容"; PersisterService.SaveObject(news); ``` - 查询 ``` News obj = PersisterService.LoadObject("新闻Id"); ``` - 修改 ``` News obj = PersisterService.LoadObject("新闻Id"); news.Title = "新的标题"; news.Context = "新的内容"; PersisterService.SaveObject(news); ``` - 删除,仅支持逻辑删除,不会做物理删除,可以理解为删除本身也就是一种修改。逻辑删除的数据,系统查询时会自动屏蔽掉逻辑删除的数据。 ``` News obj = PersisterService.LoadObject("新闻Id"); news.IsDeleted = true; PersisterService.SaveObject(news); ``` 本质上,增删改查只有两个动作:读和写,在scree中,单个对象读使用LoadObject,写使用SaveObject。 **3、读取一组对象** 使用LoadObjects,可精确查找、可模糊查找、可排序。 ``` internal static News[] GetNewsByType(NewsType type, LoadType loadType) { List prams = new List(); prams.Add(DbParameterProxy.Create("Type", SqlDbType.Int, (int)type)); News[] objs = PersisterService.LoadObjects("[Type]=@Type", prams.ToArray(), loadType); return objs; } internal static News[] GetNewsByAuthor(string author, LoadType loadType) { List prams = new List(); prams.Add(DbParameterProxy.Create("Author", SqlDbType.NVarChar, "%" + author + "%")); News[] objs = PersisterService.LoadObjects("[Author] like @Author order by ReadingQuantity desc", prams.ToArray(), loadType); return objs; } ``` **4、保存一组对象** 默认为事务性保存。 ``` News news = PersisterService.CreateObject(); news.Title = "新闻标题"; news.Context = "新闻内容"; string remark = "增加新闻"; SystemLog systemLog = LogService.CreateSystemLog(SystemLogType.AddNews, typeof(News), news.Id, remark); PersisterService.SaveObject(new SRO[] { news, systemLog }); ``` **5、自定义对象Id** 新创建的对象,默认Id是Guid。 ``` private string _id = Guid.NewGuid().ToString().Replace("-", ""); ``` 也可以自定义具有业务意义的Id(建议Id全局唯一。注意,是全局唯一,而不是单类型唯一)。Scree提供时间戳服务,可以确保Id全局唯一。 - 自定义Id需要两步,首先注册Id的格式(这种注册格式大家应该很熟悉,就是格式化字符串的写法),引用时间戳服务提供的变量。 ``` public class News : SRO { static News() { TimeStampService.RegisterIdFormat("xw{0:yyMMdd}{1}"); } } ``` - 然后,给新创建的对象Id赋值(注意这里不能直接使用Id = ***)。 ``` News news = PersisterService.CreateObject(); news.SetId(TimeStampService.GetOneId()); ``` RegisterIdFormat以及GetOneId高级使用可以详见代码注释。 **6、对象与数据的映射原理** - 先说读,前面提到读使用LoadObject 在框架内部,通过条件自动拼接出select的sql语句,从DB拉取到数据后对对象属性进行反射,逐一赋值。 - 再说写,写统一使用SaveObject Scree通过SRO基类的IsNew属性维护对象的新老状态。新创建的对象IsNew=true,而通过LoadObject拉取到的对象IsNew=false,框架通过判断IsNew,分别拼接出insert或update的sql。如果是一组对象同时save,则是循环前述过程。 **7、对象版本** Scree通过SRO基类的Version属性提供对象版本维护的能力。这项能力非常重要,建议无论是否使用ORM,无论使用什么样的框架与开发模式,都应该对对象(或者说行数据)增加版本号。 - 每一个新创建的对象其Version默认为0(第一次insert进入数据库中,Version维持为0) - 对象每一次update操作后,其Version自动+1 - 对象update sql会自动加上当前Version的where条件,以保证不会产生数据污染 **8、CreatedDate与LastAlterDate** CreatedDate是对象第一次创建的时间,永不再变化。LastAlterDate是对象最后一次被修改的时间,每一次update都会改变。 **9、充血模型与贫血模型** 推荐使用贫血模型。这方面的争论太多了,个人认为理论和工程是两码事,让理论的归理论,工程的归工程吧。简单、层次结构清楚,工程师易于理解和使用在工程实际中太重要了。 ### 三、现在开始使用scree 启动scree框架,只需要一行代码,在Global.asax中 ``` protected void Application_Start(object sender, EventArgs e) { ServiceRoot.Init(); } ``` 在Test文件夹中,提供了两个示例项目: - SimpleExample,简单示例,一般小型应用(单服务器或少量服务器或单表数据量在百万内),使用基本用法即可满足需求了 - AdvancedExample,高级示例 启动scree框架之前,需要有两个步骤,下面以SimpleExample为例。 **1、引用程序集** - Scree.Attributes - Scree.Cache - Scree.Common - Scree.Core.IoC - Scree.DataBase - Scree.Lock - Scree.Log - Scree.Persister - Scree.Syn **2、添加配置文件** 在应用程序的根目录新建名为config的文件夹,并添加文件 - log4net.config - mapping.config - root.config - storage.config 配置文件的内容可以详见示例项目。 ### 四、Scree的高阶能力 **1、视图支持** 虽然,我一般不建议使用视图,但某些情境下,视图确实还有积极的意义。在这里,可以通过一个窍门的方式来读取视图的数据,把视图当成一张表即可。前面说到,scree会自动将继承自SRO的类映射成DB中同名的表。例如对于视图vwNewsForUser,可以定义类型: ``` public class vwNewsForUser : SRO { } ``` 读取视图的数据同样可以使用到LoadObject或LoadObjects,也就是说在读上面等同于数据对象。 ``` vwNewsForUser obj = PersisterService.LoadObject("视图数据Id"); ``` 显然,视图中必须存在SRO基类默认的5个字段。不过,这并不是问题,视图总归是从数据对象表之间关联而来,必然会有一张表是关联关系中的核心表,视图5个字段就使用该对象的即可。 **2、缓存** 框架为对象或对象数组提供本地缓存功能,只需要配置即可。 - 第一步,root.config中需要配置启用缓存服务,如下: ``` ``` - 第二步,cache.config中配置指定类型的缓存参数,示例如下: ``` ``` LoadObject或LoadObjects均有LoadType的参数,默认优先读取缓存数据。框架未提供分布式缓存注入功能,如果需要使用MC或Redis等,需自行修改Scree.Cache.CacheService。 **3、BeforeSave和AfterSave** - 对于单个对象,在持久化之前和之后均可以搞一些事情。 ``` public class SystemLog : SRO { protected override void BeforeSave() { //可以在这里搞一些事情 } protected override void BeforeSave() { //可以在这里搞一些事情 } } ``` - 在调用PersisterService.SaveObject持久化单个(或一组对象)的之前和之后也可以搞一些事情。 ``` public delegate void BeforeSave(SRO[] objs); public delegate void AfterSave(SRO[] objs); void RegisterBeforeSaveMothed(BeforeSave beforeSave); void RegisterAfterSaveMothed(AfterSave afterSave); ``` PersisterService提供了注入接口。 **4、分库** - storage.config用于配置每一个数据库的连接信息,例如: ``` 127.0.0.1 AdvancedExample dbname dbpassword true 60 1 100 127.0.0.1 AdvancedExample-User dbname dbpassword true 60 1 100 ``` - mapping.config用于配置指定对象会对应到哪一个数据库,例如: ``` userdb userdb ``` 上述配置表示对象User的默认库(default)是名为current的数据库;其存储别名(alias)为UserSubById或UserSubByHour对应的库是名为userdb的数据库。 备注:mapping.config未指定的类型其默认库是storage.config中名为current的数据库,尚若没有名为current的配置,则默认库就是storage.config中的第一个数据库。 **5、分表** 随着单表数据量增大,分表是通常的解法。前面说过BeforeSave可以搞一些事情,下面就是一个例子。 ``` public class User : SRO { protected override void BeforeSave() { this.RegisterStorageBehavior(null); this.RegisterStorageBehavior("UserSubById", "UserById" + Id.Substring(Id.Length - 1)); this.RegisterStorageBehavior("UserSubByHour", "UserByHour" + CreatedDate.Hour.ToString()); } } ``` 框架提供了便捷的存储行为注册接口。 ``` this.RegisterStorageBehavior("UserSubById", "UserById" + Id.Substring(Id.Length - 1)); ``` RegisterStorageBehavior方法的第一个参数是存储别名(alias)指定,对应的第4条分库中指定的数据库;第二个参数是在该库中分表后的表名。上述例子是通过Id最后一位做散射,从前面的Id定义可知,Id的最后一位是0-9的数字,这就意味着User对象的Id散射分表有10个,从UserById0至UserById9。 ``` this.RegisterStorageBehavior("UserSubByHour", "UserByHour" + CreatedDate.Hour.ToString()); ``` 同理,User根据对象生成时间的小时数做散射,可以得到24个表,从UserByHour0至UserByHour23,使用alias为UserSubByHour,从分库配置上来看,同样存储在名为AdvancedExample-User的数据库中。 ``` this.RegisterStorageBehavior(null); ``` alias为null且未指定表名,这样,User对象将会有一份总表数据,存储在名为AdvancedExample的数据库中。 该示例中,User对象将产生三份完全一样的数据。以上仅为示例,大家可以根据自己的业务需要定制对象存储行为。 备注:分表是不会自动生成的,需要人工创建。 **6、SRO对象更多技能** - CurrentAlias 因为对象存在分库,故某一个对象可能会从不同的库中读取而来,对象的CurrentAlias属性就是代表对象来源的存储别名。 - CurrentTableName 同理,因为对象存在分表,CurrentTableName代表对象来源的表名。 - SaveMode ``` public enum SROSaveMode { //新创建或者新Load Init = 0, //新创建的对象已经第一次持久化 Insert = 1, //已有对象的再次持久化 Update = 2, //已有对象通过SaveObject试图持久化,但是对象在业务处理过程中并没有被修改过 NoChange = 3, } ``` 表示对象最近一次持久化的模式,借由这个属性以及前述的AfterSave,也可以视业务的需要搞一些事情。 - GetOriginalValue 对象在被Load出来后,经过业务逻辑处理,一部分属性会被修改,通过GetOriginalValue可以获得属性被修改前的原始值。其实,在框架内部也是通过对象的当前值与原始值的比对,生成出update的sql语句的。前述的SROSaveMode.NoChange也是基于此。 ### 五、分布式环境下的scree **1、同步** 同步主要是为本地缓存而生的。 本地缓存与数据库中的数据不免的存在差异。如果全部使用的分布式缓存且同一对象只有一份缓存副本,那么是不需要同步的。一般的,既然使用了缓存,数据差异的问题就是必然的、可预见的而且在业务处理过程中也应该有所考虑和应对的。缓存某种意义上也是一把双刃剑,从性能角度,缓存自然是越多越好,越久越好。随之而来的就是数据差异会越来越大,越来越广,以至于在业务处理中不免要分神去考虑应对策略。 以电商常见的商品库存信息为例。一般商品详情的PV会在整个系统中位列前茅,商品信息自然会做缓存,而且要尽量长。但商品的库存却一直在变化中,买家看到商品库存明明还有很多,但是一旦去下单就发现库存没了,这样体验就很不好。当然,实际中也可以单独对库存进行处理,但是价格、标题等等也同样可能会变化。自营B2C的可能还好控制一些,C2C的变化就更复杂,这就是前述的分神问题,各种对象都可能需要缓存,要考虑的就多了。 - 同步机制 ### 六、常见问题集锦 1、为什么没有自动创建表 - 确保storage.config中数据配置是正确的 - 检查mapping.config中autocreatetable属性值是否为true - 检查TableCreated.config是否已经存在该类型,此文件内容会自动生成,用于记录数据库中对应表是否已经存在。如果想重新生成表,请删除表的同时删除对应的配置项,然后重启应用即可 - 检查对应类的程序集名称是否已经配置在mapping.config中的Assembly节点 - 如果存在分库的,检查表是否生成到了其他库,如果是,检查表的mapping配置