• 换个角度说工作单元(Unit Of Work):创建、持有与API调用


    看到一些工作单元的介绍,有两种感觉,第一种是很学院,说了等于没说,我估计很多都是没有自己引入到实际的项目中去,第二种是告诉我一种结果,说这就是工作单元,但是没说为什么要这么使用。所以,本篇想要探讨的是:为什么工作单元要这么用。首先,要想将工作单元引入到自己的项目中去,急需要解决的一个问题是:工作单元的生命周期,即:

    1:工作单元被谁创建,何时消亡?

    2:工作单元被谁们持有?

    3:工作单元的 4 个 API( MakeNew、MakeDirty、MakeRemoved、Commit )被谁调用?

    一:工作单元(Unit Of Work)

    在《架构模式数据源模式之:数据映射器(Data Mapper)中的代码中,最大的问题的是,如果对象有变化,我们直接进行数据库更新,这样以来,会产生很多的数据库操作。工作单元模式就是避免这种情况发生的。它记录对象的变化,然后在需要操作数据库的时候,才去 Commit。

    一般而言,有三种方式可以实现让工作单元感知对象发生变化了:

    1:调用者注册(call registration),即:用户改变了某个对象就必须把它注册到工作单元;

    2:对象注册(object registration),即:领域对象或者服务脚本改变了对象,就将本身或者服务中的对象注册到工作单元;

    3:工作单元控制器(unit of work controller),即:工作单元在读操作时候产生对象拷贝,在更新时候比较拷贝。

    在这里我们使用的是 对象注册 这种方式。接下来,我们就要考虑:

    怎么样让 对象注册 变得简单,即:将本身或者服务中的对象注册到工作单元变得简单。

    但是,在考虑这个问题之前,我们显然还漏掉了一个细节,细究此话“对象注册到工作单元”,此话意味着首先已经存在了 工作单元 了,那么,工作单元 这个对象本身又是什么时候产生的,又存储在哪里呢?

    二:工作单元的创建

    好的,我们还要弄明白的一个概念就是:

    工作单元的生命周期?即:工作单元对应应用程序来说,是全局的,还是属于“会话”的,还是属于领域对象的?所谓属于,指与后者同消亡。

    现在,我们来看看工作单元的实质,这在本文一开头的时候描述过:它记录对象的变化,然后在需要操作数据库的时候,才去 Commit。

    那么,谁会需要记录对象的变化呢?显而易见,是事务本身(事务太抽象了,那就理解为执行的那段业务代码吧)。业务代码属于谁,属于领域对象(或者贫血模式下,属于领域服务,这里,我们只说充血),但工作单元的生命周期往往要比具体的某个领域对象宽泛,它绝不仅仅随着某个领域对象产生并消亡。它应该是属于当前 事务 的(一个事务可能会牵扯很多的领域对象)。在这个毫无疑问的基础下,我们可以假定其生命周期属于:

    1:事务本身

    事务,即业务代码,业务代码写在方法内部,所以这里的工作单元的生命周期即方法内部。

    事务本身创建和拥有工作单元,还有一种变体。事务为谁所拥有?为客户端端代码,如 UI 层,如 MVC 中的控制器等,MSDN 官方有两篇博文各自提到这种方式,可参考:

    http://www.asp.net/mvc/tutorials/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application

    http://blogs.msdn.com/b/adonet/archive/2009/06/16/using-repository-and-unit-of-work-patterns-with-entity-framework-4-0.aspx

    把工作单元交给事务(即:显式创建工作单元),优点是明确且灵活,缺点是系统当中就会到处存在工作单元对象的创建与消亡;

    2:当前工作线程

    将生命周期扩大化,Martin Fowler 在 《企业应用架构模式》 中的示例代码中提到:工作单元属于当前工作线程(使用 ThreadLocal 实现)。优点是业务代码自身不用创建工作单元。缺点是什么呢?如网站系统,多个客户端用户往往会共用一个工作线程,你和我之间的那些变化,如果你不及时 Commit,就可能会被我 Commit。要避免这点,可以使用锁定,但锁定可不利于高并发情景。当然,如果我们是在开发一个桌面应用程序,则不会如此。

    3:全局

    如果第二点可以被接受,那么,实际上工作单元还可以是全局的,即同一个应用程序域的,即意味着,你 Commit 了,就会把系统中所有会话(如果有会话的话)未及时 Commit 的会话都 Commit 了。

    4:“会话”

    Martin 在阐述 “属于当前工作线程” 的时候,谈到:工作单元逻辑上属于会话对象。会话在从技术上表达来说,有不同的含义。比如,在网站系统中,通常我们指那个 Session 对象。但由于 ASP.NET 引擎默认 Session对象 在高并发时候的易失性,通常我们不会将 工作单元 放在它那里。于是,我们可以维护自己的 会话对象

    这在逻辑上是优选。这种方式的优点是:最大化隔离工作单元,“你”和“我”之间不会相互干扰,并且如同 2 和 3 一样, 工作单元不需要传递,你只要获取就行。缺点是:维护自己的会话对象,很麻烦哦,如果放在内存中的话,在高并发的时候,跟 ASP.NET 的 SESSION 一样,莫名丢失了就会产生莫名的 BUG。

    5:包含事务代码的领域模型

    还有一种做法。再次看上文“它应该是属于当前 事务 的(一个事务可能会牵扯很多的领域对象)”。在领域模型设计中,所有的事务,即所有的行为,都是属于领域模型的,这句话就意味着:工作单元还可以属于领域模型本身,领域模型中领域根在构造器中负责创造工作单元并传递给非根们。

    在《.NET Domain-Driven Design with C#: Problem - Design - Solution》(该书有个坑爹的中文名:《领域驱动设计C# 2008实现》)这本书中就是这样实现的,它使用的是贫血模式,故对应在模型所在的 Service 中可以看到创建了 工作单元(同时工作单元又被传递到 Repository 中,题外话,该书有个最大的问题是,它将 SQL 查询拼接的到处都是)。

    这最后一种方式的优点是:编码很简单,缺点是和 1:事务脚本 是一样的,系统中到处存在工作单元的创建和传递,即:

    领域根负责将自己的 工作单元 传递给非根们;

    该做如何选择?

    如果你有一个高效且稳定的 会话对象,你选择 4 来创建和使用 工作单元;

    如果没有足够的信心自己写一个高效并稳定的 会话对象,那么,我们应该选择 1 或者 5。毕竟频繁的创建和消亡 工作单元,也没什么大不了,总比出错好,MS的官方博客都这样推荐哦,虽然没告诉你干嘛我们要这么用;

    如果我们在写桌面应用,则可以选择最简单的 2 和 3;

    三:工作单元的持有

    在上面一节中,我们已经看到了 工作单元 的创建,从中也窥探到了谁持有 工作单元。最后一个问题是:仓储库有无必要持有 工作单元。这其实依赖于 MakeNew MakeDirty MakeRemove 这些功能由谁负责。如果我们由领域模型负责干这件事情,那么工作单元就没有必要传递给仓储库。我个人倾向于由领域模型干这件事情,如果领域模型的某个属性被 Modify 之后,自然而然需要 MakeDirty,当然,这里的坏处是,你得为每个属性的 SET 方法 MakeDirty。另外一种做法是,不公开属性的 set 方法,使用方法接受模型的属性值的变更,然后在方法中 MakeDirty。我个人倾向于后者,往往领域模型属性有很多,但是修改属性的做法不会对所有属性进行修改,这大大节约了我的代码行数,并且用方法来接受属性的变更也可以从方法命名上来看到更明确的业务功能。举例来说: aUser.ReName(“zhangsan”) 比 aUser.Name=”zhangsan” 语义要明确多了。

    扩展阅读:

    REP 及 context 的注入,在Control中;http://www.asp.net/mvc/tutorials/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application

    这里面最重要的一句话是:The unit of work class serves one purpose: to make sure that when you use multiple repositories, they share a single database context.

    三:简版会话对象

    由于领域根可能是易变的,并且最重要,领域层本身没有标识哪些模型是根,哪些不是,所以最终的结果是:工作单元与领域模型(领域根)是一起。即:

    public abstract class DomainObj
    {
        public string Id {get; set;}

        public string Name {get; set;}
        protected UnitOfWork uow = new UnitOfWork();
        protected void MakeNew()
        {
            uow.RegisterNew(this);
        }
        protected void MakeDirty()
        {
            uow.RegisterDirty(this);
        }
        protected void MakeRemoved()
        {
            uow.RegisterRemoved(this);
        }
    }

    完整代码示例

    使用对象注册的方式的示例代码如下:

    void Main()
    {
        SqlHelper.ConnectionString = new MyConnections().Conn;
        var user1 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
        var user2 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
        (user1 == user2).Dump();
        SomeRootDomain root = new SomeRootDomain();
        root.DoSomething();
        "END".Dump();
    }

        public class SomeRootDomain : DomainObj
        {
            public void DoSomething()
            {
                // 创建一个用户并改名,然后查看是否 newObjects 有它,dirtyObjects 无它,removedObjects 无它
                User luminji = User.CreateNew("luminjiAdded");
                luminji.Id.Dump();
                luminji.Dump();
                luminji.ReName("luminjiAddedModified");
                luminji.Dump();
                uow.CheckObj(luminji);
                // 创建一个用户并删除,然后查看是否在 UT 的 newObjects,dirtyObjects,removedObjects 都没有它
                User luminji2 = User.CreateNew("luminjiAdded2");
                luminji2.Id.Dump();
                luminji2.Dump();
                uow.CheckObj(luminji2);
                luminji2.Delete();
                luminji2.Dump();
                uow.CheckObj(luminji2);
                // 创建一个用户并改名,继而删除,然后查看是否在 UT 的 newObjects,dirtyObjects,removedObjects 都没有它
                User luminji3 = User.CreateNew("luminjiAdded3");
                luminji3.Id.Dump();
                luminji3.Dump();
                luminji3.ReName("luminjiAdded3Renamed");
                uow.CheckObj(luminji3);
                luminji3.Delete();
                luminji3.Dump();
                uow.CheckObj(luminji3);
                // 获取一个用户,然后查看是否在 UT 的 newObjects,dirtyObjects,removedObjects 都没有它
                User luminji4 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
                uow.CheckObj(luminji4);
                // 获取一个用户并改名,然后查看是否在 UT 的 newObjects 无它,dirtyObjects 有它,removedObjects 无它
                User luminji5 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
                uow.CheckObj(luminji5);
                luminji5.Dump();
                luminji5.ReName("luminjiAdded3Renamed");
                uow.CheckObj(luminji5);
                // 获取一个用户并并删除,然后查看是否在 UT 的 newObjects 无它,dirtyObjects 无它,removedObjects 有它
                User luminji6 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
                uow.CheckObj(luminji6);
                luminji6.Delete();
                uow.CheckObj(luminji6);
                uow.Commit();
                // 综合例子
                // 1: 创建用户
                // 2: 获取用户修改用户
                // 3:删除用户
                User luminjiTestAdd = User.CreateNew("luminjiTestAdd");
                User luminji7 = User.FindUser("6f7ff44435f3412cada61898bcf0df6c");
                luminji7.ReName("luminji unit");
                User gaoyuan = User.FindUser("91f610bf01e540dbade4825d6f05f1ee");
                gaoyuan.Delete();
                uow.CheckAll();
                uow.Commit();
            }
        }
        public abstract class DomainObj
        {
            public string Id {get; set;}

            public string Name {get; set;}
            protected UnitOfWork uow = new UnitOfWork();
            protected void MakeNew()
            {
                uow.RegisterNew(this);
            }
            protected void MakeDirty()
            {
                uow.RegisterDirty(this);
            }
            protected void MakeRemoved()
            {
                uow.RegisterRemoved(this);
            }
        }

        public class Organization : DomainObj
        {
            public static Organization Get(string id)
            {
                var org = OrganizationMap.GetInstance()
                .Find(id);
                return org;
            }
        }
        public class User : DomainObj
        {
            public string OrganizitionId;
            public static User CreateNew(string name)
            {
                User user = new User(){ Id = Guid.NewGuid().ToString(), Name = name};
                user.MakeNew();
                return user;
            }
            private Organization organization;
            public Organization Organization
            {
                set
                {
                    organization = value;
                }
                get
                {
                    if(this.organization == null)
                    {
                        this.organization = Organization.Get(this.OrganizitionId);
                    }
                    return this.organization;
                }
            }
            static UserMap map = UserMap.GetInstance();
            private List<Course> courses;
            public List<Course> Courses
            {
                get
                {
                    if(this.courses == null)
                    {
                        this.courses = Course.GetListByUserId(this.Id);
                    }
                    return this.courses;
                }
            }
            public static User FindUser(string id)
            {
                var user = map.Find(id);
                return user;
            }
            public void Delete()
            {
                this.MakeRemoved();
            }
            public void ReName(string name)
            {
                this.Name = name;
                this.MakeDirty();
            }

        }

        public class Course : DomainObj
        {
            public int Duration;   
            static CourseMap map = CourseMap.GetInstance();
            public static List<Course> GetListByUserId(string userId)
            {
                var courses = map.GetListByUserId(userId);
                return courses;
            }
        }

        public class OrganizationMap : AbstractMapper<Organization>
        {
            private OrganizationMap(){}
            private static OrganizationMap map;
            public static OrganizationMap GetInstance()
            {
                if(map == null)
                {
                    map = new OrganizationMap();
                }
                return map;
            }
            public Organization Find(string id)
            {
                return (Organization)AbstractFind(id);
            }
            public override Organization AbstractFind(string id)
            {
                var organization = base.AbstractFind(id);
                Load(organization);
                return organization;
            }
            public override void Update(Organization organization)
            {
                "UPDATE Organization SET ....".Dump();
            }
            public override void Insert(Organization organization)
            {
                "INSERT INTO Organization  ....".Dump();
            }
            public override void Delete(Organization organization)
            {
                "DELETE Organization ....".Dump();
            }
            public override void Update(DomainObj organization)
            {
                Update(organization as Organization);
            }
            public override void Insert(DomainObj organization)
            {
                Insert(organization as Organization);
            }
            public override void Delete(DomainObj organization)
            {
                Delete(organization as Organization);
            }
        }

        public class UserMap : AbstractMapper<User>
        {
            private UserMap(){}
            private static UserMap map;
            public static UserMap GetInstance()
            {
                if(map == null)
                {
                    map = new UserMap();
                }
                return map;
            }
            public User Find(string id)
            {
                return (User)AbstractFind(id);
            }
            public override User AbstractFind(string id)
            {
                var user = base.AbstractFind(id);
                if( user == null )
                {
                    //
                    string sql = @"
                    DECLARE @ORGID VARCHAR(32)='';
                    SELECT @ORGID=OrganizationId FROM [EL_Organization].[USER] WHERE ID=@Id
                    SELECT * FROM [EL_Organization].[USER] WHERE ID=@Id
                    SELECT * FROM [EL_Organization].[ORGANIZATION] WHERE ID=@ORGID";
                    var pms = new SqlParameter[]
                    {
                        new SqlParameter("@Id", id)
                    };
                    var ds = SqlHelper.ExecuteDataset(CommandType.Text, sql, pms);
                    user = DataTableHelper.ToList<User>(ds.Tables[0]).FirstOrDefault();
                    user.Organization =  DataTableHelper.ToList<Organization>(ds.Tables[1]).FirstOrDefault();
                    if(user == null)
                    {
                        return null;
                    }

                    user = Load(user);
                    // 注意,除了 Load User 还需要 Load Organization
                    user.Organization = Load(user.Organization) as Organization;
                    return user;
                }
                return user;
            }
            public List<User> FindList(string name)
            {
                // SELECT * FROM USER WHERE NAME LIKE NAME
                List<User> users = null;
                return LoadAll(users);
            }
            public override void Update(User user)
            {
                "UPDATE USER SET ....".Dump();
            }
            public override void Insert(User user)
            {
                "INSERT INTO USER  ....".Dump();
            }
            public override void Delete(User user)
            {
                "DELETE USER ....".Dump();
            }
            public override void Update(DomainObj user)
            {
                Update(user as User);
            }
            public override void Insert(DomainObj user)
            {
                Insert(user as User);
            }
            public override void Delete(DomainObj user)
            {
                Delete(user as User);
            }
        }
        public class CourseMap : AbstractMapper<Course>
        {
            private CourseMap(){}
            private static CourseMap map;
            public static CourseMap GetInstance()
            {
                if(map == null)
                {
                    map = new CourseMap();
                }
                return map;
            }
            public Course Find(string id)
            {
                return (Course)AbstractFind(id);
            }
            public override Course AbstractFind(string id)
            {
                var Course = base.AbstractFind(id);
                Load(Course);
                return Course;
            }
            public override void Update(Course course)
            {
                // UPDATE USER SET ....
            }
            public override void Insert(Course course)
            {
                // INSERT INTO USER  ....
            }
            public override void Delete(Course course)
            {
                // DELETE USER ....
            }
            public override void Update(DomainObj course)
            {
                Update(course as Course);
            }
            public override void Insert(DomainObj course)
            {
                Insert(course as Course);
            }
            public override void Delete(DomainObj course)
            {
                Delete(course as Course);
            }
            public List<Course> GetListByUserId(string userId)
            {
                List<Course> courses = null;
                // SELECT * FROM ...
                LoadAll(courses);
                return courses;
            }
        }
        public abstract class AbstractMapper
        {
            protected static Dictionary<string, DomainObj> loadedMap = new Dictionary<string, DomainObj>();

            public abstract void Insert(DomainObj t);
            public abstract void Update(DomainObj t);
            public abstract void Delete(DomainObj t);
        }
        public abstract class AbstractMapper<T> : AbstractMapper where T : DomainObj
        {
            protected AbstractMapper(){}
            public void CheckLoaedDomains()
            {
                foreach(var m in loadedMap)
                {
                    m.Value.Dump();
                }
            }
            public DomainObj Load(DomainObj t)   
            {
                if(loadedMap.ContainsKey(t.Id) )
                {
                    return loadedMap[t.Id];
                }
                else
                {
                    loadedMap.Add(t.Id, t);
                    return t;
                }
            }
            protected T Load(T t)
            {
                if(loadedMap.ContainsKey(t.Id) )
                {
                    return loadedMap[t.Id] as T;
                }
                else
                {
                    loadedMap.Add(t.Id, t);
                    return t;
                }
            }
            protected List<T> LoadAll(List<T> ts)
            {
                for(int i=0; i < ts.Count; i++)
                {
                    ts[i] = Load(ts[i]);
                }
                return ts;
            }
            public virtual T AbstractFind(string id)
            {
                if(loadedMap.ContainsKey(id))
                {
                    return loadedMap[id] as T;
                }
                else
                {
                    return null;
                }
            }
            public virtual void Insert(T t){}
            public virtual void Update(T t){}
            public virtual void Delete(T t){}
        }
        public class UnitOfWork
        {
            private List<DomainObj> newObjects = new List<DomainObj>();
            private List<DomainObj> dirtyObjects = new List<DomainObj>();
            private List<DomainObj> removedObjects = new List<DomainObj>();
            public void Clear()
            {
                newObjects.Clear();
                dirtyObjects.Clear();
                removedObjects.Clear();
            }
            public void CheckAll()
            {
                foreach(var m in newObjects)
                {
                    ("newObjects:" + m.Name).Dump();
                }
                foreach(var m in dirtyObjects)
                {
                    ("dirtyObjects:" + m.Name).Dump();
                }
                foreach(var m in removedObjects)
                {
                    ("removedObjects:" + m.Name).Dump();
                }
            }
            public void CheckObj(DomainObj obj)
            {
                ("newObjects中有:" + newObjects.Count()).Dump();
                ("dirtyObjects中有:" + dirtyObjects.Count()).Dump();
                ("removedObjects中有:" + removedObjects.Count()).Dump();
                if(newObjects.Contains(obj))
                {
                    ("newObjects中有" + obj.Id).Dump();
                }
                else
                {
                    ("newObjects中没有" + obj.Id).Dump();
                }
                if(dirtyObjects.Contains(obj))
                {
                    ("dirtyObjects中有" + obj.Id).Dump();
                }
                else
                {
                    ("dirtyObjects中没有" + obj.Id).Dump();
                }
                if(removedObjects.Contains(obj))
                {
                    ("removedObjects中有" + obj.Id).Dump();
                }
                else
                {
                    ("removedObjects中没有" + obj.Id).Dump();
                }
            }
            public void RegisterNew(DomainObj obj)
            {
                if(string.IsNullOrEmpty(obj.Id))
                {
                    throw new Exception("Id不能为空");
                }
                if(dirtyObjects.Contains(obj))
                {
                    throw new Exception("新对象不能为一个脏对象");
                }
                if(removedObjects.Contains(obj))
                {
                    throw new Exception("新对象不能为一个要删除的对象");
                }
                if(newObjects.Contains(obj))
                {
                    throw new Exception("新对象已经注册过了");
                }
                newObjects.Add(obj);
                "newObjects添加成功".Dump();
            }
            public void RegisterDirty(DomainObj obj)
            {
                if(string.IsNullOrEmpty(obj.Id))
                {
                    throw new Exception("Id不能为空");
                }
                if(removedObjects.Contains(obj))
                {
                    throw new Exception("脏对象已被列入到删除对象中");
                }
                if(!newObjects.Contains(obj) && !dirtyObjects.Contains(obj))
                {
                    dirtyObjects.Add(obj);
                    "dirtyObjects添加成功".Dump();
                }
            }
            public void RegisterRemoved(DomainObj obj)
            {
                if(string.IsNullOrEmpty(obj.Id))
                {
                    throw new Exception("Id不能为空");
                }
                if(newObjects.Remove(obj))
                {
                    return;
                }
                if(!removedObjects.Contains(obj))
                {
                    removedObjects.Add(obj);
                    "removedObjects添加成功".Dump();
                }
            }
            public void Commit()
            {
                InsertNew();
                UpdateDirty();
                DeleteRemoved();
            }
            private void InsertNew()
            {
                foreach(var obj in newObjects)
                {
                    MapperRegistry.GetMapper(obj).Insert(obj);
                }
            }
            private void UpdateDirty()
            {
                foreach(var obj in dirtyObjects)
                {
                    MapperRegistry.GetMapper(obj).Update(obj);
                }
            }
            private void DeleteRemoved()
            {
                foreach(var obj in removedObjects)
                {
                    MapperRegistry.GetMapper(obj).Delete(obj);
                }
            }
        }
        public class MapperRegistry
        {
            private List<AbstractMapper> mappers = new List<AbstractMapper>();
            public static AbstractMapper GetMapper<T>(T t) where T : DomainObj
            {
                if(t is User)
                {
    //                if(!mappers.Contains(UserMap.GetInstance()))
    //                {
    //                    mappers.Add(UserMap.GetInstance());
    //                }
                    return UserMap.GetInstance();
                }
                throw new Exception("t type is valid");
            }
        }

    注意:领域模型自身应该负责通知 UnitOfWork 变化了;

    四:标识映射(Identity Map)

    在这段代码中,我们看到了标识映射的概念。

    标识映射是指:记录从数据库中读出的所有对象,当要用到一个对象的时候,先检查标识映射,看需要的对象是否已经存在其中。基于这种特点,标识映射成为了数据库读取的高速缓存。并且,我们把它放在了 数据映射器 中,且标识为静态的,即:

    public abstract class AbstractMapper
    {
        protected Dictionary<string, DomainObj> loadedMap = new Dictionary<string, DomainObj>();

        public abstract void Insert(DomainObj t);
        public abstract void Update(DomainObj t);
        public abstract void Delete(DomainObj t);
    }

    其它扩展阅读:

    http://www.joel.net/repository-and-unit-of-work-for-entity-framework-ef4-for-unit-testing

    The job of the Unit of Work class is to manage the lifecycle and context of your Repositories. The class is pretty simple to create, implement IDisposable, add all your Repositories and give it a Save method.

  • 相关阅读:
    深度神经网络的优化算法
    Python 正则表达式
    《java面试十八式》第一式 --冈本零点零一
    《java面试十八式》--引子
    Redis第二讲【Redis基本命令和五大数据结构】
    redis第一讲【redis的描述,linux和docker下的安装使用】
    springboot中的pom文件是如何管理依赖的
    详谈springboot启动类的@SpringBootApplication注解
    工具类中注入service和dao
    windows下安装ssdb
  • 原文地址:https://www.cnblogs.com/luminji/p/3734885.html
Copyright © 2020-2023  润新知