zoukankan      html  css  js  c++  java
  • 用不到140行Csharp代码开发面向对象的数据库[转]

    本文的目标是用不到140行的C#代码编写一个简单的面向对象的数据库(OODB)。很显然这不会是一个最简单的项目;但是C#和.NET框架提供了很多内置的函数,我们可以利用它们来帮助我们实现这一目标。这个数据库的要求如下:

    • 必须能够保存对象的状态,而不需要向对象加入额外的信息,也不需要准确地告诉数据库如何保存对象。
    • 必须能够从数据存储区里获得对象,并向用户提供一个完整的业务对象,而不需要用户将数据库的字段映射给对象。
    • 必须能够通过一级C#表示来查询。例如,查询不一定要放在双引号里,比如SQL查询所要求的双引号,而且应该是类型安全的。
    • 必须要有一种特别的查询机制。这一机制要求查询必须放在双引号里,而且不用是类型安全的。
    • 必须能够从数据库里删除对象,提供一个“深度删除(deep delete)”的选项。
    • 必须能够更新数据库里的对象。

    很显然,企业级OODB的要求会严格得多,而且会有其他的特性,比如索引、触发器和并发等。本文所呈现的(核心思想)是充分利用.NET框架内置的功能就可以创建一个简单的OODB。我们在这里不是要颠覆性地创建一个OODB,而是要显示利用一点点创造力能够实现什么。

    如何保存数据

    将对象保存在数据库里我们有很多选择。其中最容易想到的一个是使用二进制序列化将其序列化。这让我们能够利用内置的二进制序列化(System.Runtime.Serialization命名空间)的优势,并使得保存/获取对象变得轻而易举。但是如果采取这种方式,我们就不得不需要编写搜索例程来搜索数据了。我们还必须面对对象以及对象类型相互隔离的状况。由于这些限制以及项目目标(的要求),二进制序列化对于我们来说不是最佳的选择。

    第二种选择是XML序列化。XML序列化提供了很多二进制序列化的优势,以及一些有趣的功能。与二进制序列化比较起来,XML序列化最大的一个优势 是对象保存在XML里。这意味着这些对象的搜索以及对象/对象类型的隔离已经在极大程度上被处理了。由于这些原因,XML序列化就是我们将要在这个项目里 使用的方式,它能够满足第1项和第2项要求,并能够让我们轻轻松松就实现其他的要求。

    关于XML序列化要知道的一件事情是,它一次只能够序列化/反序列化一个父对象。这意味着我们将需要在同一个文件里用一个XML布局保存多个父对象。这一问题可以通过将StringReaderStringWriter对象当作流而不是对象使用来解决。一旦我们把经过序列化的对象放到字符串里,我们就可以使用一个XmlDocument对象把对象的数据添加到数据库的XML文件里。要获得更多关于XML序列化的信息,请参阅我的XML序列化文章

    一个带有两个Customer(用户)对象的简单数据库看起来就像列表A一样。

    列表A

    <Database>
      <XmlDB.Order>
        <Order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>76a0558b-a8c7-42e3-8f1d-c56319365787</Identity>
          <CustomerIdentity>6f5e9a2b-b68f-4b6d-9298-fbe5f135dd25</CustomerIdentity>
          <DatePlaced>2006-11-21T07:12:16.3176493-05:00</DatePlaced>
        </Order>
        <Order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>16d8f0b8-46c6-47c3-ac6b-a0b0e0852970</Identity>
          <CustomerIdentity>61cf2db4-0071-4380-83df-65a102d82ff2</CustomerIdentity>
          <DatePlaced>2006-11-21T07:12:26.0533326-05:00</DatePlaced>
        </Order>
      </XmlDB.Order>
      <XmlDB.Customer>
        <Customer xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>6f5e9a2b-b68f-4b6d-9298-fbe5f135dd25</Identity>
          <LastName>Cunningham</LastName>
          <FirstName>Marty</FirstName>
        </Customer>
        <Customer xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>61cf2db4-0071-4380-83df-65a102d82ff2</Identity>
          <LastName>Smith</LastName>
          <FirstName>Zach</FirstName>
        </Customer>
      </XmlDB.Customer>
    </Database>

    正如你能看到的,这个格式允许我们快速地分析XML,并找到对象类型的不同分组。

    既然我们已经决定用XML序列化来保存对象,那么我们就需要找一种在内存中保存数据的方式,以便可以让用户与其交互。实现这一目标的一个显而易见的选择是XmlDocument类,因为它为XML文件提供了搜索和更新能力。

    体系结构

    在开始深入代码的细节之前,我们需要看看我们的解决方案背后的体系结构。其基本的体系结构由两个类和一个接口构成:

    类:

    • XmlDBState ——这是一个抽象类,包括数据库所需要的所有功能。其中有搜索、保存、删除,以及文件管理/创建功能。
    • XmlDBBase——这是一个公共类,专门用作要被保存到数据库里的对象的基类。虽然我们并不要求对象继承自这个类,但是继承自XmlDBBase会自动地实现IxmlSerializable接口并保存编码时间。

    接口

    • IXmlSerializable——任何要被保存到数据库里的对象都必须实现这个接口。正如上面所提到的,如果一个对象继承自XmlDBBase,那么它就已经实现了这个接口,因此把对象保存到数据库里不需要采取进一步的操作。

    既然已经搭好了基本的体系结构,我们就可以开始研究源代码,看看这个数据库是如何工作的了。

    加载数据库

    下面的XML(列表A)是在数据库被写入磁盘时的样子,仅供参考:

    列表A

    <Database>
      <XmlDB.Order>
        <Order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>76a0558b-a8c7-42e3-8f1d-c56319365787</Identity>
          <CustomerIdentity>6f5e9a2b-b68f-4b6d-9298-fbe5f135dd25</CustomerIdentity>
          <DatePlaced>2006-11-21T07:12:16.3176493-05:00</DatePlaced>
        </Order>
        <Order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>16d8f0b8-46c6-47c3-ac6b-a0b0e0852970</Identity>
          <CustomerIdentity>61cf2db4-0071-4380-83df-65a102d82ff2</CustomerIdentity>
          <DatePlaced>2006-11-21T07:12:26.0533326-05:00</DatePlaced>
        </Order>
      </XmlDB.Order>
      <XmlDB.Customer>
        <Customer xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>6f5e9a2b-b68f-4b6d-9298-fbe5f135dd25</Identity>
          <LastName>Cunningham</LastName>
          <FirstName>Marty</FirstName>
        </Customer>
        <Customer xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>61cf2db4-0071-4380-83df-65a102d82ff2</Identity>
          <LastName>Smith</LastName>
          <FirstName>Zach</FirstName>
        </Customer>
      </XmlDB.Customer>
    </Database>

    在本文的示例里,有两个客户和两个订单被保存在数据库里。保存在数据库的每个对象类型都被包含在一个节点里,这个节点专门用于特定类型的对象。例如,DatabaseXmlDB.Order (Database[namespace].[type])节点就包含有所有已经被保存的Order(订单)对象。

    在每个专用的类型节点里都有保存序列化对象的对象节点。列表B就是这样一个例子。


    列表B

        <Order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xmlns:xsd="http://www.w3.org/2001/XMLSchema">
          <Identity>16d8f0b8-46c6-47c3-ac6b-a0b0e0852970</Identity>
          <CustomerIdentity>61cf2db4-0071-4380-83df-65a102d82ff2</CustomerIdentity>
          <DatePlaced>2006-11-21T07:12:26.0533326-05:00</DatePlaced>
        </Order>

    这只是序列化形式的Order对象。

    要加载XML数据库,我们就要创建一个XmlDocument对象,并调用它的“Load(加载)”方法来加载XML。这个功能由XmlDBState类的“OpenDatabase”函数来实现。(列表C

    列表C

        public static void OpenDatabase(string path)
        {
            //Set the path to the database file.
            _path = path;

            //Douse the database file already exist?
            if (File.Exists(path))
                Database.Load(path); //If so, load it.

            //If a main node already exists in the database
            // use it. If not, create it.
            MainNode = (Database.ChildNodes.Count > 0) ?
                Database.SelectSingleNode(rootName) :
                Database.CreateElement(rootName);

            //If the main node doesn't exist, add it.
            if (Database.SelectSingleNode(rootName) == null)
                Database.AppendChild(MainNode);
        }

    这个函数的一个很有意思的特性是,如果数据库文件不存在,它会在内存中自动地创建一个数据库来使用。当对象被保存并被保持到磁盘之后,内存中的数据库就会被写到磁盘上。

    上面代码中参考的“MainNode(主节点)”指的是XML的“Database(数据库)”节点。所有的对象都被保存在这个节点下面,它被认为是XML文档的“根节点”。


    保存对象

    在数据库被加载到XmlDBState对象之后,我们就希望将对象保存在它里面。这也可以通过XmlDBState用一个叫做“SaveObject”的方法来处理。SaveObject的代码见列表D

    列表D

    public static void SaveObject(IXmlDBSerializable data, bool persistData)
    {
        Type type = data.GetType();
        string typeString = type.ToString();
        PropertyInfo[] properties = type.GetProperties();

        //Remove the object if it's currently in the database.
        XmlNode typeNode = RemoveCurrentObject(typeString, data);

        //Loop through each property in our object and see
        // if we need to save them in the database.
        foreach (PropertyInfo property in properties)
        {
            //Get the property's value.
            object propertyValue = property.GetValue(data, null);

            //Check to see if the property is IXmlDBSerializable,
            // and if it is save it to the database.
            if (propertyValue is IXmlDBSerializable)
                ((IXmlDBSerializable)propertyValue).Save(persistData);
            else if (propertyValue is System.Collections.ICollection)
            {
                //This property is a collection of objects.
                // We need to see if this collection contains
                // IXmlDBSerializable objects, and serialize
                // those objects if needed.
                IList propertyList = propertyValue as IList;

                //Does the collection contain IXmlDBSerializable
                // objects?
                if (propertyList != null &&
                    propertyList.Count > 0 &&
                    propertyList[0] is IXmlDBSerializable)
                {
                    //It does contain IXmlDBSerializable objects
                    // so save each of them.
                    foreach (object listObject in propertyList)
                        ((IXmlDBSerializable)listObject).Save(persistData);
                }
            }
        }

        //If the type which is being saved isn't currently
        // represented in the database, create a place to
        // hold that specific type.
        if (typeNode == null)
        {
            typeNode = XmlDBState.Database.CreateElement(typeString);
            XmlDBState.MainNode.AppendChild(typeNode);
        }

        //Prepare the objects we will need for serializing
        // the object.
        XmlSerializer serializer = new XmlSerializer(type);
        StringWriter writer = new StringWriter();
        XmlDocument objectDocument = new XmlDocument();

        //Serialize the object into our StringWriter object.
        serializer.Serialize(writer, data);

        //Create an XmlDocument from our serialized object.
        objectDocument.InnerXml = writer.ToString();

        //If the serialized object had data, import it into
        // the database.
        if (objectDocument.ChildNodes.Count > 0)
        {
            //Set the object's Node property to the serialized
            // data.
            data.Node =
                XmlDBState.Database.ImportNode(objectDocument.ChildNodes[1],
                                                true);
            //Append the serialized object to the type node.
            typeNode.AppendChild(data.Node);
        }

        //If requested, persist these changes to the XML file
        // held on disk. If this is not called, the change is
        // made in memory only.
        if (persistData)
            XmlDBState.Database.Save(XmlDBState.Path);
    }

    这个函数毫无疑问地担当着数据库最重要的功能。下面就是这个函数从开始到结束所经历的步骤。

    • 如果对象在数据库里存在的话,它就会把这个对象删除。我们这样做的理由是,在数据库里删除序列化对象并重新保存它要比(直接)更新序列化对象容易。被调用的RemoveCurrentObject函数还会返回XmlNode,负责存留我们正在保存的对象类型。如果看一下上面的示例数据库,你会看到XmlDB.OrderXmlDB.Customer这两个节点——它们都是类型节点,一个用于保存Order对象,另一个用于保存Custom对象。
    • 在删除了当前对象之后,我们需要使用反射来检查正在被保存的对象。只有这样做我们才能够把任何子对象/集合以及主对象保存到数据库里。如果我们发现对象有必须保存的子级,那么子级就要被明确地送到IXmlDBSerializable,用来调用Save()方法。
    • 下一步,我们检查typeNode是否存,如果不存在,我们就创建一个。
    • 然后我们必须创建序列化形式的对象。使用XmlSerializer对象把对象序列化成为StringWriter对象就行了。这让我们可以通过String(字符串)访问序列化对象,然后把它导入一个临时的XmlDocument。我们把它导入XmlDocument,这样它就能够被导入到主数据库XmlDocument里了。利用主XmlDocument对象的ImportNodeAppendChild方法就能够实现这一目标。
    • 最后,如果需要的话,我们要把数据库保持到硬盘上。调用XmlDocument.Save方法并把数据库的路径作为参数传递可以达到这一目标。这会使得XmlDocument覆盖磁盘上的当前内容。把数据库写回到磁盘上的过程很缓慢,这就是为什么我们可以选择不保持数据的原因。例如,如果我们要保存10,000个对象,将这10,000保存到内存里(Save(false))然后调用XmlDBBase.Database.Save(XmlDBBase.Path)要比仅仅对10,000个对象调用Save(true)快得多。

    删除对象

    对象的删除由XmlDBState的两个函数来处理——DeleteRemoveCurrentObjectDelete的代码见列表E

    列表E:

        public static void Delete(IXmlDBSerializable data, bool deep)
        {
            //If this is a "deep delete", we look through the object's
            // properties and delete all child objects from the database.
            if (deep)
            {
                PropertyInfo[] properties = data.GetType().GetProperties();

                foreach (PropertyInfo property in properties)
                {
                    object propertyValue = property.GetValue(data, null);

                    if (propertyValue is IXmlDBSerializable)
                        Delete((IXmlDBSerializable)propertyValue, true);
                    else if (propertyValue is System.Collections.ICollection)
                    {
                        IList propertyList = propertyValue as IList;

                        if (propertyList != null &&
                            propertyList.Count > 0 &&
                            propertyList[0] is IXmlDBSerializable)
                            foreach (object listObject in propertyList)
                                Delete((IXmlDBSerializable)listObject, true);
                    }
                }
            }

            //Remove the object from the database.
            XmlDBState.RemoveCurrentObject(data.GetType().ToString(), data);

            //Persist the database to disk.
            XmlDBState.Database.Save(XmlDBState.Path);
        }

    正如你看到的,Delete在内部使用RemoveCurrentObject,但是还提供了“深度删除(deep delete)”的选项。这意味着正在被删除的对象的每个子对象也要被从数据库里删除。RemoveCurrentObject的代码见列表F

    列表F:

        public static XmlNode RemoveCurrentObject(string typeString, IXmlDBSerializable data)
        {
            //Find the node that holds this type's data.
            XmlNode typeNode = XmlDBState.MainNode.SelectSingleNode(typeString);

            //If the object has a node associated with it, remove
            // the node from the database.
            if (data.Node != null)
                typeNode.RemoveChild(data.Node);

            //Return the node that is responsible for this type's
            // data.
            return typeNode;
        }

    RemoveCurrentObject的基本功能是发现数据库当前对象里的对象,并使用类型的XmlNodeRemoveChild方法从数据库里删除序列化对象。这是一个对内存进行的操作,这就是为什么Delete方法要用额外的步骤调用XmlDBState.Database.Save把更改的内容保持到磁盘上的原因。

    谓词查询

    通过向用户提供使用Predicate(谓词)方法搜索数据库的选项,我们能够实现一个类型安全的、集成C#的查询机制。这项功能通过Search(搜索)方法的重载在数据库里实现(列表G)。

    列表G

        public static List<DynamicType> Search<DynamicType>(
                                        Predicate<DynamicType> searchFunction)
                                        where DynamicType : IXmlDBSerializable
        {
            //Get the Type of the object we're searching for.
            Type type = typeof(DynamicType);

            //Get the nodes of those objects in our database.
            XmlNodeList nodeList =
                XmlDBState.Database.SelectNodes(String.Format(@"/Database//",
                                                type.FullName, type.Name));

            //Get a collection of DynamicType objects via the
            // ExtractObjectsFromNodes method.
            List<DynamicType> matches = ExtractObjectsFromNodes<DynamicType>(nodeList);

            //Use the List<T>.FindAll method to narrow our results
            // to only what was searched for.
            return matches.FindAll(searchFunction);
        }

    这个函数会选择数据库里给定类型的所有节点,使用ExtractObjectsFromNodes将节点反序列化,然后使用.NET框架提供的List<T>.FindAll方法过滤集合。ExtractObjectsFromNodes的代码见列表H

    列表H

        private static List<DynamicType> ExtractObjectsFromNodes<DynamicType>(
    XmlNodeList nodeList)
        {
            XmlSerializer serializer = new XmlSerializer(typeof(DynamicType));
            List<DynamicType> objects = new List<DynamicType>();

            foreach (XmlNode node in nodeList)
            {
                StringReader reader = new StringReader(node.OuterXml);
                DynamicType deserialized = (DynamicType)serializer.Deserialize(reader);
                ((IXmlDBSerializable)deserialized).Node = node;

                objects.Add(deserialized);
            }

            return objects;
        }

    这个方法只会在我们XmlNodeList的每个节点里循环,将每个对象反序列化成为活的业务对象。它然后把对象添加到List<T>集合里,在循环结束的时候返回集合。

    这种类型的查询要求我们将保存在数据库里的被请求类型的所有实例都反序列化,知道这一点是十分重要的。这意味着如果数据库里有10,000个 Customer对象,我们要使用Predicate查询过滤它们,那么这10,000对象都必须被反序列化,然后数据库才能够开始过滤结果。这显然是一 个十分耗时的过程,这就是为什么我们提供了一个基于XPath的替代查询机制的原因。

    XPath查询

    正如我们在本系列的上篇中提到的,使用XML保存对象的一个主要优势是我们可以将XPath用作是一种特别的查询机制。这一机制由XmlDBState类通过Search方法的重载来实现,见列表I

    列表I

        public static List<DynamicType> Search<DynamicType>(
                                        string query)
                                        where DynamicType : IXmlDBSerializable
        {
            //Get the Type of the object we're searching for.
            Type type = typeof(DynamicType);
            //Create a List<DynamicType> collection to hold the results.
            List<DynamicType> matches = new List<DynamicType>();

            //Change single quotes to double quotes in the query.
            query = query.Replace("'", """);

            //Build our XPath query.
            string xpath = "Database/" + type.FullName + "/" +
                            type.Name + "[" + query + "]";

            try
            {
                //Select all nodes which match out XPath query.
                XmlNodeList nodes = XmlDBState.Database.SelectNodes(xpath);

                //If we have results, extract objects from those nodes.
                if (nodes != null)
                    matches = ExtractObjectsFromNodes<DynamicType>(nodes);
            }
            catch (Exception exception)
            {
                throw new Exception("Could not search. Possible bad query syntax?",
    exception);
            }

            return matches;
        }

    要注意的是,在使用XPath查询的时候我们只将我们知道符合查询条件的对象反序列化。这会极大地提高查询执行的速度。在测试中,Predicate查询返回10,000个对象需要一秒钟,而XPath查询只需要百分之一秒。

    还要注意的是,在这个方法的开始,我们用双引号替换掉了单引号。这样做的目的是用户不用在需要在它们的查询里转义双引号。但是这种做法存在一些副作用,因为单引号是合法的XPath字符,某些实例可能会要求使用它们。使用它只是为了实现可用性而做出的一种让步。

    IXmlDBSerializable接口

    IXmlDBSerializable接口必须通过要被保存到数据库里的任何对象来实现。这使得数据库能够对所有对象都一视同仁,避免了人们去猜测某个对象是否能够被保存到数据库里。IXmlDBSerializable的代码见列表J

    列表J

    namespace XmlDBLibrary
    {
        public interface IXmlDBSerializable
        {
            System.Guid Identity { get; set; }
            System.Xml.XmlNode Node { get; set; }
            void Save(bool persistData);
        }
    }

    我们需要Identity(标识)属性是因为每个被保存到数据库的对象都必须有唯一能够被识别的标记。这一属性应该自动地被生成,用于实现IXmlDBSerializable的任何对象。

    Node属性被用来把XmlNode保存到与当前对象对应的数据库里。这让数据库能够快速地删除对象,而不需要搜索对象的标识,还没有被保存到数据库里的对象的这一属性为空。

    我们还需要Save(保存)方法,这样XmlDBState.SaveObject方法就能够保存正在被保存的对象的子对象。通过将把子对象传给IXmlDBSerializable并对子对象调用Save,我们就能够实现这一目标。

    XmlDBBase类

    当对象需要符合IXmlDBSerializable 要求的时候,XmlDBBase类被当为开发人员所使用的一种快捷方式。这并不是一个必需的类,因为IXmlDBSerializable可以手动实现。当被用作一个基类时,XmlDBBase还提供了保存、搜索和删除功能。XmlDBBase的代码见列表K

    列表K

        public class XmlDBBase : IXmlDBSerializable
        {
            private XmlNode _node = null;
            private System.Guid _identity = System.Guid.NewGuid();

            public XmlDBBase()
            {
            }

            public void Delete()
            {
                this.Delete(false);
            }

            public void Delete(bool deep)
            {
                XmlDBState.Delete(this, deep);
            }

            public void Save()
            {
                Save(true);
            }

            public void Save(bool persistData)
            {
                XmlDBState.SaveObject(this, persistData);
            }

            public static List<DynamicType> Search<DynamicType>(
                                System.Predicate<DynamicType> searchFunction)
                                where DynamicType : IXmlDBSerializable
            {
                return XmlDBState.Search<DynamicType>(searchFunction);
            }

            public static List<DynamicType> Search<DynamicType>(
                                string query)
                                where DynamicType : IXmlDBSerializable
            {
                return XmlDBState.Search<DynamicType>(query);
            }

            public static DynamicType GetSingle<DynamicType>(
                                System.Predicate<DynamicType> searchFunction)
                                where DynamicType : IXmlDBSerializable
            {
                List<DynamicType> results =
                      XmlDBState.Search<DynamicType>(searchFunction);

                return (results.Count == 0) ? default(DynamicType) : results[0];
            }

            public System.Guid Identity
            {
                get { return _identity; }
                set { _identity = value; }
            }

            [System.Xml.Serialization.XmlIgnore]
            public XmlNode Node
            {
                get { return _node; }
                set { _node = value; }
            }
        }

    要注意的是,这些方法都可以同时用来进行XPath和Predicate查询。它们也提供了Delete(删除)和Save功能。这些方法中的大多数基本上都是传给XmlDBState对象的传递方法。

    有一个很有意思的事情需要指出,那就是对Node属性使用XmlIgnore,使用它是因为我们不希望Node属性被序列化到数据库里。如果将这个属性序列化,我们事实上就是在把序列化对象的一个副本保存在这个对象里。

    最终的想法

    虽然这个数据库不是一个企业级的面向对象的数据库,但是我相信这是体现.NET框架灵活性的一个好例子。让这个数据库工作起来只需要非常少的代码, 所需要的代码基本上只是用来将各种不同的.NET框架的功能关联起来。这个数据库的每个主要函数都已经由.NET框架提供——从用于查询的 Predicates/XPath,到用于数据管理的XmlDocument对象。

    [转自]http://www.hiweblog.com/articles/73.aspx

  • 相关阅读:
    常用模块的作业
    一些常用的模块
    软件开发的目录规范

    匿名函数作业
    模块
    算法,面向过程与匿名函数
    生成器表达式作业
    C. Perfect Keyboard
    C. Ehab and Path-etic MEXs
  • 原文地址:https://www.cnblogs.com/anan/p/684178.html
Copyright © 2011-2022 走看看