• 从零开始使用CodeArt实践最佳领域驱动开发(五)


    本章内容还在整理上传中,你可以等全部更新完毕后再查阅也可以先预览已上传的内容。。。。。。

    7. 领域层的命令模式

      在上个章节里我们设计并编码了领域对象Permission,但是目前Permission并没有任何行为上的设计。这是因为我们不建议“凭空去制造行为”,而是在领域对象第一个版本的代码实现之后就立即使用它。在使用过程中观察外界(应用层或其他领域对象)对它的需求,这些需求往往暗藏了进一步揭露对象本质特征的提示。我们可以根据这些提示逐渐挖掘出该对象更多的行为特征,结合CA里相关的设计原则,最终我们可以确认领域对象该如何提供哪些行为方法。所以,大家不要在意领域对象在设计初期有点类似“贫血模型”,因为这不是它的最终形态,这只是它成长的起点。那如果经过一番尝试后,我们还是没有为对象添加任何方法呢?这种情况的确存在,有些对象存在的意义只是被别的对象引用,自身并没有提供任何行为方法,但是如果你的项目里大量充斥着没有行为的领域对象,这就值得你警惕了,肯定是设计上出现了重大的错误。不过只要大家依照教程里的设计原则实施项目,基本上不会出现这种情况,所以也不必太过担心。

      因此,我们暂且放下对领域模型的思考,将思路转移到应用层,考虑应用层对Permission有什么需求。涉及到应用层我们又不得不考虑表现层对应用层会有哪些需求,最终我们会发现要思考的问题其实是终端用户会有什么需求。所以,我们考虑第一个问题:用户怎么使用权限机制?这个话题在前文也讨论过,这里我们只用考虑用户使用权限的第一个必须操作是什么?

      当然是为项目创建权限描述了,我们需要定义整个项目提供了哪些权限,只有定义了权限信息后系统才能以此为参考识别登录者可以使用哪些功能。因此,创建权限信息是用户的第一个操作需要。但是我们要从使用者的角度对这个需求点进一步修正:“系统管理员可以定义整个项目提供了哪些功能”。系统管理员指的是项目搭建好后,设置项目初始参数的人,这类人比普通用户更加了解系统(往往是我们自己)。从UI界面操作体验上来讲,提示使用者“定义整个项目提供了哪些功能”比“定义整个项目提供了哪些权限”要更容易理解。

      分析到这里,我们就知道不论UI界面怎么描述,应用层都要提供“创建权限”的功能。所以我们以创建权限为示例讲述如何建立领域命令。

      在CA的4层架构里,应用层由多个服务组成, 这些服务不会直接操作领域对象,它们主要是通过调用子系统提供的命令来完成任务。也就是说,AccountSubsystem里除了定义领域模型的内容外还会提供一组命令给应用层使用,应用层不会直接使用领域模型,一切应用操作都是通过这些命令来下达给领域层去实现的。下面贴出创建权限的命令代码并作出说明:

    using System;
    using System.Linq;
    
    using CodeArt;
    using CodeArt.DomainDriven;
    
    namespace AccountSubsystem
    {
        public sealed class CreatePermission : Command<Permission>
        {
            private string _name;
    
            public string Description
            {
                get;
                set;
            }
    
            public string MarkedCode
            {
                get;
                set;
            }
    
            public CreatePermission(string name)
            {
                _name = name;
            }
    
            protected override Permission ExecuteProcedure()
            {
                Permission permission = new Permission(Guid.NewGuid())
                {
                    Name = _name,
                    MarkedCode = this.MarkedCode ?? string.Empty,
                    Description = this.Description ?? string.Empty
                };
                
                var repository = Repository.Create<IPermissionRepository>();
                repository.Add(permission);
    
                return permission;
            }
        }
    }

      1)public sealed class CreatePermission : Command<Permission>,类型CreatePermission继承自命令的泛型版本Command<T>,泛型参数表示该命令需要返回类型为T的结果,这里表示的是执行CreatePermission命令后,需要返回一个Permission权限对象。Command<T>来自CodeArt.DomainDriven命名空间,由程序集CodeArt.DomainDriven提供。CA规定除了查询操作外所有命令对象都需要继承自Command或Command<T>。

      2) private string _name;  私有成员 _name用于接收外界传递的权限名称的参数,请注意,因为name是必填项,所以以构造函数参数的形式强制要求外界必须传递name。名称为什么必填? 这跟传统开发里表单必填项没有任何关系,而是因为领域模型Permission的属性Name定义的固定规则包含了不能为空的限制。

      3)Description和MarekdCode被设计成为属性并且公开了GET和SET方法,表示外界可以为权限选择性填写描述和标识码。请注意这不是领域属性,这是命令为了接收参数而编写的.NET属性,事实上你可以为这两个属性任意命名(比如 desp或mc,不过考虑可阅读性我们没有这样命名)。这两个属性之所以不必填也是由于Permission对应的属性规则导致的,不再复述。

      4)protected override Permission ExecuteProcedure() 是派生类必须实现基类Command<Permission>的执行命令的方法。

      5)在执行方法中,第一个段落代码就是构造领域对象Permission。这里要注意的是属性赋值语句:MarkedCode = this.MarkedCode ?? string.Empty; 该代码的意思是,如果调用方没有传递MarkedCode参数(没有传递就为null),那么就使用string.Empty作为标识码构造对象。前文说明过,在CA里所有的领域对象及领域属性值都不能为null,这里就体现了这一点,至于原因前文有详细说明不再复述。

      6)var repository = Repository.Create<IPermissionRepository>(); 这句代码很容易理解:创建一个接口类型为IPermissionRepository的仓储实例,该接口提供了关于Permission对象的持久化操作。接口的定义和实现稍后有详细说明。

      7)repository.Add(permission); 该代码将permission对象加入到仓储里。此刻CreatePermission的工作全部完成,最后返回permission对象。

      下面我们来看看IPermissionRepository仓储是如何定义和实现的。以下是接口定义代码:

    namespace AccountSubsystem
    {
        public interface IPermissionRepository : IRepository<Permission>
        {
            /// <summary>
            /// 根据唯一标示精确查找权限对象
            /// </summary>
            /// <param name="markedCode"></param>
            /// <param name="level"></param>
            /// <returns></returns>
            Permission FindByMarkedCode(string markedCode, QueryLevel level);
    
            /// <summary>
            /// 根据名称模糊查找权限翻页集合
            /// </summary>
            /// <param name="name"></param>
            /// <param name="pageSize"></param>
            /// <param name="pageIndex"></param>
            /// <returns></returns>
            Page<Permission> FindPageBy(string name, int pageIndex, int pageSize);
    
            /// <summary>
            /// 根据编号得到多个权限对象
            /// </summary>
            /// <param name="ids"></param>
            /// <param name="level"></param>
            /// <returns></returns>
            IEnumerable<Permission> FindsBy(IEnumerable<Guid> ids);
        }
    }

       IPermissionRepository的定义代码比较简单,需要注意的有3点内容:

      1)在CA里领域对象仓储接口的定义必须继承自IRepository<TRoot>,该接口代码如下:

    namespace CodeArt.DomainDriven
    {
        public interface IRepository
        {
            /// <summary>
            /// 根据编号查找对象
            /// </summary>
            /// <param name="id"></param>
            /// <param name="level"></param>
            /// <returns></returns>
            IAggregateRoot Find(object id, QueryLevel level);
        }

    public interface IRepository<TRoot> : IRepository where TRoot : class, IAggregateRoot { /// <summary> /// 将对象添加到仓储 /// </summary> /// <param name="obj"></param> void Add(TRoot obj); /// <summary> /// 修改对象在仓储中的信息 /// </summary> /// <param name="obj"></param> void Update(TRoot obj); /// <summary> /// 从仓储中删除对象 /// </summary> /// <param name="obj"></param> void Delete(TRoot obj); /// <summary> /// 根据编号查找对象 /// </summary> /// <param name="id"></param> /// <param name="level"></param> /// <returns></returns> new TRoot Find(object id, QueryLevel level); } }

       从以上代码可以看出,由于IPermissionRepository继承了IRepository<Permission>接口,所以IPermissionRepository默认就定义了对权限对象的Add、Update、Delete、Find的操作。也就是说你实现IPermissionRepository接口就必须也实现这几个方法。不过在使用CA的时候这几个方法的实现已经由框架内置了,不需要你手动编码。后文在讲到如何编写仓储实现的时候会详述。
      2)我们为IPermissionRepository接口定义了几个查询方法,这些方法并非空穴来风,正如前文所言,对待仓储接口的定义我们要万分慎重,不要将其沦为类似存储过程般的存在。下面介绍下这几个方法以及相关的话题:
      Permission FindByMarkedCode(string markedCode, QueryLevel level); 根据标识码找到权限对象,标识码的概念前文有详细讲解这里不再复述,有此方法的定义很正常,仓储必须提供根据标识码找到权限对象的职责。

      注意第二个参数QueryLevel是查询级别,查询级别是一种加载对象的方式,提供这些方式是能够让程序的业务场景顺利的实施下去。到目前为止CA的最新版本中支持5种查询级别以适应不同场景下的需要,它们分别是:无锁、独占、hold独占、共享、镜像。在详细讨论查询级别这个话题之前,我们不得不介绍一下事务。

      所谓事务就是指你必须原子性的完成一件事,这件事要么成功完成要么完全失败。比如说,你向图书馆付费借阅一本书A,你付给图书馆5元钱,图书管理员收费后把书A从书架上取出来给你。这个借书的事务就完整的完成了。但是如果你支付了费用后,管理员去取书A的时候发现书已被借走了,这时候管理员就将收到的钱退还给你。那么这个借书的事务就完整的失败了,之所以说是完整的失败是因为钱归还给你了,你口袋里的钱在借书之前是50元,在借书失败之后还是50元,而不是45元。事务执行期间的状态在失败的时候都应该能保证回逆到执行事务之前的状态。我们写的程序实现某个业务场景也要满足这个要求,不能说我把钱支付给你了,书却因为借给别人了而没给我,钱也没退给我,这种情况下的事务就是不完整的,是错误的。

      要保证事务能够完整的执行有很多种办法,一点都不难,数据库这块也提供了成熟的解决方案。但是如果要和性能结合在一起考虑,追求高并发,高响应等指标那程序的复杂性就会提高很多。也正因如此,CA提供了查询级别的支持以便减轻大家在开发过程中因为事务的处理而耗费过多的精力。我们还是以借书为例子详细讲解下这几个查询级别的特点和使用方式。

      在你借书之前需要在琳琅满目的书架上找到感兴趣的书籍,这种情况下我们一般不会太讲究数据的及时性。就好比你现在在看一篇新闻的评论,同一时间也有人正在发布新的评论,这时候你并不知道,系统也不会提示你,但是当你刷新页面的时候评论信息就被刷新了,虽然你之前看的并不是最新的评论但是也不会给你带来多大的影响,对系统数据的完整性更不会造成任何破坏。因此,我们在只是为了查看内容而不会改变事物状态的操作下,一般使用无锁的形式加载对象,因为无锁的查询性能是最好的。你可以试想一下,如果你获得书籍信息是用带锁的查询方式,那么在你执行这项任务的时候,其余的人要看到图书必须得等到你查阅完毕后,而且他们也都需要一个个的排队查阅,这明显会大幅度降低系统的实用性。在CA中无锁的查询级别是QueryLevel.None,表示以无锁的方式加载领域对象,在该方式下其余的线程依然可以读取、锁定该领域对象。值得一提的是QueryLevel.None完全不会跟别的锁冲突的,就算你用另外的查询级别锁定了对象,但是使用无锁的方式一样可以读取,这也是为什么称它为“无”的原因。

      当你选中了图书A后需要以独占的方式将它临时声明为自己的书。之所以说临时是因为你还没有向管理员付费,管理员也没有确认把书借给你。但是如果你在向管理员办理手续的时候,其他的人也要向管理员借阅这本书,其他人就需要等待你办理完后才能知道自己能不能继续借阅图书A(因为有可能你没有带足够的钱导致支付失败进而无法借阅,这时候其他人还是有机会可以继续尝试借阅图书A的)。所以一旦你选中了图书A就要将其临时独占,如果别人在你之前已经开始办理图书A的借阅操作,那么你也要等待,直到别人释放了图书A的控制权后,你才能去找管理员办理借阅操作,虽然有可能管理员会告知你图书A已经被借走了。在这种场景下我们就需要用到QueryLevel.Single 独占锁,先以独占的方式加载图书A,然后进行借阅的办理流程,这时候如果有其他的线程也以独占的方式加载图书A,那么他们将持续等待到你办理完毕为止。所以独占锁的好处是可以让当前线程非常安全的操作某一个领域对象(注意,我说的是某一个而不是多个领域对象,后文会有解释),因为当前线程不会被其他线程干扰操作(比如说有两个甚至10多个人同时向管理员借阅图书那就乱套了)。独占锁的缺点就是如果当前借阅图书A的人很多,那么他们不得不排队等待,降低了系统的吞吐量。

      QueryLevel.Single是锁定仓储中已存在的对象,那如果我们想锁定一个或者多个不存在的对象呢?假设在图书馆理同样的书籍(书名相同)只能有一本库存,当图书管理员在为新书上架的时候就要判断书籍是否已存在书架上,如果不存在才会进行上架操作。图书馆很大,有多位管理员同时在为新书上架,这时候我们在上架图书A之前就要把“图书A的空缺”锁定,其他的管理员不能访问该空缺,直到我们完成上架操作释它的控制权为止。QueryLevel.HoldSingle就是用于这类场景下的查询级别,常用于新增操作验证对象的合法性。它可以把仓储中不存在的对象给锁住,只要该对象满足你指定的查询条件即可。

      QueryLevel.Share是共享的查询级别,它的定义比较简单:与QueryLevel.Single、QueryLevel.HoldSingle互斥,但是与QueryLevel.Share共享查询。也就是说,当我在使用QueryLevel.Share查询某个对象的时候,你如果用QueryLevel.Single或QueryLevel.HoldSingle查询该对象,那就得等待我查询完毕后你才能获得它的控制权。同样的,如果是你先用QueryLevel.Single或QueryLevel.HoldSingle查询对象,那么我也要等待你操作完毕后才能获取该对象的控制权。另外,如果你和我都是用的QueryLevel.Share查询级别,那么我们可以同一时间共同访问该对象。QueryLevel.Share常用于可以共同读取但是不能在读取的时候修改或删除对象的场景。

      在继续讨论最后一种查询级别“镜像”之前,我们先对QueryLevel.None、QueryLevel.Single、QueryLevel.HoldSingle、QueryLevel.Share这四种级别进行总结:

        (1)除了镜像查询外,使用其余四种查询级所得到的对象一定会被存放在领域缓冲区中,这意味着同一个对象就算有上万个用户同时访问它,该对象始终也只会有一个实例在内存中存在。这样节约了内存开销、减少了装载领域对象的时间(直接从领域缓冲区中取对象根本不需要装载,对象只有在第一次从数据库中获取才会执行装载操作),极大的提高了系统性能。

        (2)QueryLevel.None可以与任何其他查询级别共存,就算你使用了QueryLevel.Single或者QueryLevel.HoldSingle,我只要使用QueryLevel.None依然可以访问你锁定的对象。该查询级别性能最优,但是不能保证对象的状态是安全的,被读取的目标有可能正在被别的线程修改。因此,QueryLevel.None可以满足只是为了查看数据而执行的查询工作,它可以提供最优的查询效率。程序员在使用该查询级别时,一定不能更改目标对象状态(只能读属性和运行不会破坏对象状态的方法)。

        (3)QueryLevel.Single和QueryLevel.HoldSingle用于追加或更改对象状态的场景,QueryLevel.Single的性能高于QueryLevel.HoldSingle。该查询可以提供良好的使用环境让你更改一个聚合根极其成员的状态,其他的线程无法干扰你对该聚合根的任何操作(虽然QueryLevel.None还是可以查询,但是我们已作约定该查询下是绝对不能更改对象的目标状态的)。当你把某个聚合根锁定了,那么该聚合根的成员也被间接锁定了(因为访问成员要通过聚合根,你以带锁的形式加载了聚合根,别的线程就无法再锁定该聚合根,不能访问聚合根就无法访问其成员,所以锁聚合根等同于其成员也被锁定了)。在一个事务里,一定不要使用QueryLevel.Single或QueryLevel.HoldSingle查询两个或两个以上的聚合根,这样有可能会造成死锁。简单的说,你如果要操作两个聚合根并将其更新提交到仓储,你不要使用QueryLevel.Single和QueryLevel.HoldSingle,因为它们有可能造成对象死锁。之所有说“有可能”是因为造成死锁的原因不是这两个查询级别导致的,而是你写的业务代码不恰当的使用它们会引起死锁。这两个查询级别不能对你保证100%避免死锁,面对多个聚合根同时被更改的场景请使用镜像技术。

        (4)QueryLevel.Share共享查询的性能介于QueryLevel.None之后、QueryLevel.Single之前。如果你既想以高效率读取对象但是又不想该对象的状态会在查询期间被更改,那么你就可以使用该查询级别。事实上我们在实际使用中很少用到共享查询,单纯的读取操作几乎都是用QueryLevel.None。

      好了,现在我们正式讨论镜像查询(QueryLevel.Mirroring)。首先,该查询级别会以无锁的形式绕过领域缓冲区,从仓储中加载全新的领域对象。因此,使用它你不会跟其他查询有任何冲突,因为所有线程都是各自操作各自的领域对象。其次,由于它是无锁查询,所以在对象查询和后续被使用的期间,事务也不会被真正的开启(QueryLevel.None也一样,不会开启事务),不开启事务数据库的压力就会减少很多。然后,你也可以修改被查询的对象的状态(这点和QueryLevel.None不一样,我们约定使用None是不能修改对象状态的),最后,当你提交多个领域对象的更改时事务才会被真正开启,而且CA会帮你以一种安全序列的方式锁定对象,不会造成死锁现象。这样不仅事务的提交时间被压缩到最低而且帮助程序员避免了死锁,这是镜像查询最大的优点。当你需要在一个事务期间修改2个或2个以上的领域对象时请使用镜像查询的方式。

       到目前为止CA3.0中提供了以上五种查询级以满足大多数企业级应用。除此之外,我们还提供了领域事件机制将大事务划分为若干个小事务进行提交,该机制实现的效果是介于乐观锁和悲观锁之间。QueryLevel.Single和QueryLevel.HoldSingle属于悲观锁,这种锁在事务执行期间会将相关资源都锁定,虽然很安全但是系统吞吐量就比较低。QueryLevel.Mirroring属于乐观锁,当镜像对象在提交的时候CA会对比对象的版本号是否与仓储中记录的版本号相同,如果不相同表示镜像对象已经过期了,过期了的对象是不能提交到仓储的。因此,乐观锁虽然不会锁定资源,但是当并发执行事务的时候会有一方甚至多方提交失败,这是乐观锁的缺点,不能保证所有人都提交成功。领域事件的效果就是介于悲观锁和乐观锁之间,把大事务划分为多个小事务,每个小事务被独立执行并且提交,多个小事务全部提交成功,那么大事务就提交成功了。如果小事务提交失败了就会回逆事件,对事务进行人为的补偿,关于该机制更多的讨论留待后续示例中详述。

      Page<Permission> FindPageBy(string name, int pageIndex, int pageSize); 该方法比较简单,可以根据指定的页码和页面大小得到权限对象的集合。这里需要展开讨论的是“根据页码、页面大小”来得到一组领域对象的集合,这貌似是UI层界面呈现方式影响到了领域层的设计。领域层的设计不应该依赖表现层的呈现方式吧?是的,领域层内的元素(领域对象、仓储等)不应该只是为了提供某一种表现层的需要而做出设计。但是正如前文所述,分析表现层的UI体验可以辅助我们找到事物的本质特征,而仓储描述的是“如何找到事物”的特征。因此,我们会发现“由于一个大型系统里的权限对象会很多,如果由一个列表全部展现出来,用户的观看体验会很差,所以我们需要以翻页的形式呈现权限信息”(当然,你也可以认为权限对象数量不多,我们不需要翻页,那么仓储只用提供查询列表集合的方式即可,这个根据实际需要来,这种需要本身就是事物的特点)。所以,我们认为仓储提供以翻页的形式找到多个权限对象的方法是符合事物本质特征的,并不是局限于某一种UI考虑而做出的设计。实际上该方法可以用于其他的UI表现,比如手机端拖拉界面,每拖拉一次就相当于多加载一页内容。

      IEnumerable<Permission> FindsBy(IEnumerable<Guid> ids) 是仓储定义的最后一个方法,实际上最初设计权限仓储接口时我们并没有考虑到它。不过在后续的实际使用中我们发现需要提供这样一个查询接口:根据多个编号找到多个权限对象,这样有利于权限对象与其他领域对象协同工作。

      IPermissionRepository接口定义的相关话题就到此结束了,再次强调一点,一定不要为了使用的方便而肆意追加仓储接口的方法,这样会将仓储沦为类似存储过程般的存在,我们要用领域对象技术来分化业务的复杂性而使用过程化的方法是无法做到这一点的。关于如何设计仓储接口我们会在更复杂的例子里再讲解更深入的话题。

    8. 实现IPermissionRepository仓储

      终于到了实现仓储的时候了。请大家注意,从层次结构的角度来讲,“仓储接口”处于领域模型层,“仓储接口的实现”处于基础设施层。你在领域模型层里设计了聚合根,同时也根据需要设计了该聚合根的仓储接口,但是领域模型层并不关心仓储接口由谁来实现,它只知道“我公布了一个接口,基础设施层一定会帮我实现,所以我可以放心的使用仓储接口来工作”。

      由于仓储的实现处于基础设施层,这意味着你可以使用任意数据库或者其他存储技术来制作仓储。这一点与领域模型层不同,在领域模型层我们要求一定不要使用任何数据库技术,所有业务逻辑都需要依靠领域元素(领域对象、仓储接口、领域事件等)来驱动。那么,为什么仓储的实现我们却可以使用数据库技术呢?前文不是说过围绕数据库搞开发会有多种弊端吗?这是因为传统的开发中,程序员不仅仅用数据库存放各种数据,还会用大量的存储过程或者sql语句来处理业务逻辑,而数据库天生是用来存储数据的,无法分解业务上的复杂性。所以我们在仓储的实现中,用数据库存放领域对象的数据但是依然不会用它来处理任何业务逻辑,所有的业务处理都会放在领域模型层,这样就算用了数据库技术也不会带来混乱。

      下面我们先讨论下如何实现权限的仓储,然后演示如何将该仓储注入到程序中。

    using System;
    using System.Collections.Generic;
    using CodeArt.DomainDriven;
    
    using CodeArt.Concurrent;
    using CodeArt.DomainDriven.DataAccess;
    
    namespace AccountSubsystem
    {
        [SafeAccess]
        public class SqlPermissionRepository : SqlRepository<Permission>, IPermissionRepository
        {
            public Permission FindByName(string name, QueryLevel level)
            {
                return DataContext.Current.QuerySingle<Permission>("name=@name", (arg) =>
                 {
                     arg.Add("name", name);
                 }, level);
            }
    
            public Permission FindByMarkedCode(string markedCode, QueryLevel level)
            {
                return DataContext.Current.QuerySingle<Permission>("markedCode=@markedCode", (arg) =>
                {
                    arg.Add("markedCode", markedCode);
                }, level);
            }
    
            public Page<Permission> FindPageBy(string name, int pageIndex, int pageSize)
            {
                return DataContext.Current.Query<Permission>("name like %@name%", pageIndex, pageSize, (arg) =>
                {
                    arg.Add("name", name);
                });
            }
    
            public IEnumerable<Permission> FindsBy(IEnumerable<Guid> ids)
            {
                return DataContext.Current.Query<Permission>("id in @ids", (arg) =>
                {
                    arg.Add("ids", ids);
                }, QueryLevel.None);
            }
    
            public static readonly SqlPermissionRepository Instance = new SqlPermissionRepository();
    
        }
    }

       以上是IPermissionRepository的实现,大家可以看出真正完成存储工作的是数据上下文DataContext.Current,由于该对象比较复杂我们稍后开单独的章节讲解。目前我们只用知道可以通过调用DataContext.Current.QueryXXX方法就可以完成对象的查询工作。至于CUD操作SqlRepository<T>这个基类已经内部完成了相关工作,程序员不必再去实现。下面我们以DataContext.Current.Query为例讲解如何查询聚合根对象。

       IEnumerable<T> Query<T>(this IDataContext dataContext, string expression, Action<DynamicData> fillArg, QueryLevel level) where T : class, IAggregateRoot

      Query是泛型方法,泛型参数是需要查询的聚合根的类型,DataContext.Current.Query<Permission>表示的就是我们需要查询多个Permission对象。参数expression是对象表达式,我们可以用类似sql的语法编写对象表达式

    
    

    实在抱歉,最近工作有点忙,更新速度会放缓,请见谅。。。。。。

     

     

     

     

    欢迎各位加入CodeArt学习群共同进步,群号:558084219

    程序员不是任何人的工具,更不是碌碌无为的码农。我希望每一位使用CodeArt的程序员都能成为程序世界里的王者,用创造力去构建自己的领域。即使遭遇重重困难也不气馁、受到他人阻碍时亦有不屈服之心、遇到不公正时能毫不畏惧地纠正,不向官僚献媚。
  • 相关阅读:
    今日总结
    今日总结
    团队绩效1
    本周总结
    团队冲刺阶段10
    团队冲刺阶段9
    团队冲刺阶段8
    promise手写自定义封装异步任务回调的执行
    Vue中this.$options.data()和this.$data知多少?
    手写Promise自定义封装 then 函数
  • 原文地址:https://www.cnblogs.com/codeart/p/7125739.html
Copyright © 2020-2023  润新知