zoukankan      html  css  js  c++  java
  • C#/Net代码精简优化技巧

    (一)

    在我们写代码的时候可以使用一些小的技巧来使代码变得更加简洁,易于维护和高效。下面介绍几种在C#/Net中非常有用的一些编程技巧。

    1 空操作符(??)

    在程序中经常会遇到对字符串或是对象判断null的操作,如果为null则给空值或是一个指定的值。通常我们会这样来处理

    string name = value;
    if (name == null)
    {
        name = string.Empty;
    }

    可以使用三元操作符(?:)对上面对吗进行优化

    string name = value == null ? string.Empty : value;

    这样使代码简洁了不少,但这还不是最简洁的,我们还可以使用??操作符来进行进一步优化,??操作符意思是如果为null取操作符左边的值,否则取右边的值。

    string name = value ?? string.Empty;

    我们甚至可以写一个扩展方法来过滤掉null和空格,使返回的结果可以更好的使用??操作符

    public static class StringUtility
    {
        public static string TrimToNull(string source)
        {
            return string.IsNullOrWhiteSpace(source) ? null : source.Trim();
        }
    }

    使用代码如下:

    string name = string.TrimToNull(value) ?? "None Specified";

    2 使用As转换类型

    在C#中进行类型转换有很多种方式比如可以进行强制类型转换,通常在转换前会使用Is进行类型的判断,所以您可能经常写过或见过类似下面的代码

    if (employee is SalariedEmployee)
    {
        var salEmp = (SalariedEmployee)employee;
        pay = salEmp.WeeklySalary;
        // ...
    }

    上面的代码不会报异常,但在整个过程中做了两次转换操作,这样会降低性能。我们可以使用as操作符来进行类型的转换,同样也不会报异常,如果类型不兼容则返回null,而是用as进行转换整个过程只转换一次。代码如下:

    var salEmployee = employee as SalariedEmployee;
    if (salEmployee != null)
    {
        pay = salEmployee.WeeklySalary;
        // ...
    }

    3 自动属性

    自动属性是C#3.0以上版本中的新功能,可以使代码变得更简洁,在以前定义属性我们会写如下代码

    public class Point
    {
        private int _x, _y;
    
        public int X
        {
            get { return _x; }
            set { _x = value; }
        }
        public int Y
        {
            get { return _y; }
            set { _y = value; }
        }
    }

    使用自动属性代码就会简洁了很多

    public class Point
    {
        public int X { get; set; }
        public int Y { get; set; }
    }

    在自动属性中,我们可以给get或set访问器设置访问级别,使属性变成只读属性或是只写属性

    public class Asymetrical
    {
        public string ThisIsReadOnly { get; private set; }
        public double ThisIsWriteOnly { private get; set; }
    }

    4 StopWatch类

    在程序开发中有时会需要统计一个方法或是一个存储过程执行了多长时间,比如在做一些方法的性能测试时就需要用到这用的时间统计功能,很自然想到的方法是在处理的方法前后各记录一个时间,然后计算时间差,如下

    DateTime start = DateTime.Now;
    SomeCodeToTime();
    DateTime end = DateTime.Now;
    Console.WriteLine("Method took {0} ms", (end - start).TotalMilliseconds);

    尽管使用DateTime的时间差可以达到目的,但DateTime统计出来的时间差并不是很精确,想要精确我们可以使用Win32 API调用PInvoke,但是做法非常麻烦而且容易出错。

    这时我们就需要使用StopWatch类了,使用这个类必须引用命名空间 System.Diagnostics

    var timer = Stopwatch.StartNew();
    SomeCodeToTime();
    timer.Stop();
    Console.WriteLine("Method took {0} ms", timer.ElapsedMilliseconds);

    5 使用TimeSpan的静态方法

    当我们需要在程序中设置一个时间间隔或是等待多长时间后再做下一步操作时,往往会写如下的代码:

    Thread.Sleep(50);

    上面代码中的参数50很明确是指50毫秒,这个是在定义方法的时候就定义好的类型,并不是很灵活,而且我们经常会使用int类型来定义传入的参数,类型下面的代码

    void PerformRemoteWork(int timeout) { ... }

    上面代码中的timeout是指秒、毫秒还是小时或天,这个就需要开发者自己去定义了,这样的代码在调用时就非常不明确,我们可以使用TimeSpan来解决这个问题

    void PerformRemoteWork(TimeSpan timeout) { ... }

    调用的代码

    PerformRemoteWork(new TimeSpan(0, 0, 0, 0, 50));

    这样的代码也让人看着很头疼,因为TimeSpan有5个构造函数的重载,如下

    TimeSpan();
    TimeSpan(long ticks);
    TimeSpan(int hours, int minutes, int seconds);
    TimeSpan(int days, int hours, int minutes, int seconds);
    TimeSpan(int days, int hours, int minutes, int seconds, int milliseconds);

    由于这些重载在调用时很容易让人混淆,而且写出的代码可读性也不是很好,像上面的50如果不是很熟悉TimeSpan构造函数的并不能一眼就看出是50毫秒。更好的方法是使用TimeSpan的静态方法

    private static readonly TimeSpan _defaultTimeout=TimeSpan.FromSeconds(30);

    上面的代码的调用也可以写成

    PerformRemoteWork(TimeSpan.FromMilliseconds(50));

    这样无论是在写程序时还是在阅读代码时都会感觉非常清楚明了。

    (二)

    1 string.IsNullOrEmpty() and string.IsNullOrWhiteSpace()

    在Net2.0中String类型有一个静态方法IsNullOrEmpty,到了Net4.0中String类又增加了一个新的静态方法IsNullOrWhiteSpace。这两个方法看名称也可以知道IsNullOrEmpty是判断空引用和空字符串,而IsNullOrWhiteSpace是判断空引用和字符串中的每一个字符是否是空格。

    在有这两个方法之前,我们要进行这样的判断,需要些如下代码

    public string GetFileName(string fullPathFileName)
    {
        if (fullPathFileName == null || fullPathFileName.Length == 0)
        {
            throw new ArgumentNullException(fullPathFileName);
        } 
        //...
    } 

    使用IsNullOrEmpty

    public string GetFileName(string fullPathFileName)
    {
        if (string.IsNullOrEmpty(fullPathFileName))
        {
           
          throw new ArgumentNullException(fullPathFileName);
        } 
        //...
    } 

    下面又了新的需求,需要将三个名字连接在一起,并且希望中间名字不为空字符串和不出现多余的空格,我们会写出下面的代码

    public string GetFullName(string firstName, string middleName, string lastName)
    {
        if (middleName == null || middleName.Trim().Length == 0)
        {
            return string.Format("{0} {1}", firstName, lastName);
        }
        return string.Format("{0} {1} {2}", firstName, middleName, lastName);
    } 

    上面的代码中使用了Trim来去掉空格然后判断其长度是否为0,代码也非常的清晰简洁,但是会产生额外的String对象以至于影响性能,这时就应该使用Net4.0中的IsNullOrWhiteSpace方法

    public string GetFullName(string firstName, string middleName, string lastName)
    {
        if (string.IsNullOrWhiteSpace(middleName))
        {
            return string.Format("{0} {1}", firstName, lastName);
        }
        return string.Format("{0} {1} {2}", firstName, middleName, lastName);
    } 

    上面的代码非常简洁,而且也不用担心会产生额外的String对象没有及时的进行垃圾回收而影响性能。

    2 string.Equals()

    string.Equals方法有很多的重载供我们使用,但是其中有些常常会被我们忽视掉。通常我们比较字符串会使用下面的方法

    public Order CreateOrder(string orderType, string product, int quantity, double price)
    {
        if (orderType.Equals("equity"))
        {
        }
        // ...
    }   

    如果orderType为null会抛出NullReferenceException异常,所以为了不抛出异常,在判断之前先要进行null的判断,如下:

    if (orderType != null && orderType.Equals("equity"))

    相当于每次都要做两次判断,很麻烦而且有时还有可能遗忘,如果使用string.Equals就可以解决这个问题,代码如下:

    if (string.Equals(orderType, "equity"))

    上面的代码当orderType为null时不会抛出异常而是直接返回false。

    判断字符串相等的时候有时会需要区分大小写,很多人的习惯做法是先转换成大小或是小些在进行比较(建议转换成大写,因为编译器做了优化可以提高性能),但是当转换成大写或是小写时又会创建的的字符串,使性能降低。这时应该使用StringComparison.InvariantCultureIgnoreCase,代码如下

    if (orderType.Equals("equity", StringComparison.InvariantCultureIgnoreCase))

    如果要考虑到null的情况,还是应该使用string.Equal

    if (string.Equals(orderType, "equity", StringComparison.InvariantCultureIgnoreCase))

    3 using语句

    我们都知道using最常用的地方就是在类中引用命名空间。除此之外还可以用作设置别名和应用在一些实现了IDisposable 借口的对象实例上,可以使这些对象在using的作用范围内自动释放资源。下面的代码示例是没有使用using的情况:

    public IEnumerable<Order> GetOrders()
    {
        var orders = new List<Order>();
        var con = new SqlConnection("some connection string");
        var cmd = new SqlCommand("select * from orders", con);
        var rs = cmd.ExecuteReader();
        while (rs.Read())
        {
            // ...
        }
        rs.Dispose();
        cmd.Dispose();
        con.Dispose();
        return orders;
    } 

    上面的代码不怎么好看,而且也没有对异常的处理,如果在代码执行过程中出现了异常将会导致有些资源不能及时释放,尽管最终还是会被垃圾回收,但还是会影响性能呢。下面的代码添加了异常处理

    public IEnumerable<Order> GetOrders()
    {
        SqlConnection con = null;
        SqlCommand cmd = null;
        SqlDataReader rs = null;
        var orders = new List<Order>();
        try
        {
            con = new SqlConnection("some connection string");
            cmd = new SqlCommand("select * from orders", con);
            rs = cmd.ExecuteReader();
            while (rs.Read())
            {
                // ...
            }
        }
        finally
        {
            rs.Dispose();
            cmd.Dispose();
            con.Dispose();
        }
        return orders;
    }

    上面的代码仍然存在不足,如果SqlCommand对象创建失败或是抛出了异常,rs就会为null,那么最后在执行rs.Dispose()时就会抛出异常,会导致con.Dispose不能被调用,所以我们应该避免这种情况的发生

    public IEnumerable<Order> GetOrders()
    {
        var orders = new List<Order>();
        using (var con = new SqlConnection("some connection string"))
        {
            using (var cmd = new SqlCommand("select * from orders", con))
            {
                using (var rs = cmd.ExecuteReader())
                {
                    while (rs.Read())
                    {
                        // ...
                    }
                }
            }
        }
        return orders;
    } 

    上面的代码中的using嵌套了好几层,看起来很繁琐,而且可读性也不是很好,我们可以像下面这样改进

    public IEnumerable<Order> GetOrders()
    {
        var orders = new List<Order>();
    
        using (var con = new SqlConnection("some connection string"))
        using (var cmd = new SqlCommand("select * from orders", con))
        using (var rs = cmd.ExecuteReader())
        {
            while (rs.Read())
            {
                // ...
            }
        }
        return orders;
    } 

    4 静态类(Static)

    很多人在创建类的时候没有使用过Static修饰符,可能他们并不知道Static修饰符的作用,Static修饰符所做的一些限制可以在其他开发人员使用我们代码的时候使我们的代码变得更加安全。比如我们现在写一个XmlUtility类,这个类的作用是实现XML的序列化,代码如下:

    public class XmlUtility
    {
        public string ToXml(object input)
        {
            var xs = new XmlSerializer(input.GetType());
            using (var memoryStream = new MemoryStream())
            using (var xmlTextWriter = new XmlTextWriter(memoryStream, new UTF8Encoding()))
            {
                xs.Serialize(xmlTextWriter, input);
                return Encoding.UTF8.GetString(memoryStream.ToArray());
            }
        }
    } 

    上面的是很典型的XML序列化代码,但是我们在使用时需要先实例化这个类的对象,然后用对象来调用方法

    var xmlUtil = new XmlUtility();
    string result = xmlUtil.ToXml(someObject);

    这样显然很麻烦,不过我们可以给方法加上static修饰符,然后给类加上私有的构造函数防止类实例化来使类的使用变得简单

    public class XmlUtility
    {
        private XmlUtility()
        { 
        
        }
        public static string ToXml(object input)
        {
            var xs = new XmlSerializer(input.GetType());
            using (var memoryStream = new MemoryStream())
            using (var xmlTextWriter = new XmlTextWriter(memoryStream, new UTF8Encoding()))
            {
                xs.Serialize(xmlTextWriter, input);
                return Encoding.UTF8.GetString(memoryStream.ToArray());
            }
        }
    } 

    上面的代码可以实现类直接调用方法,但是给类设置私有构造函数的做法不是很好,当我们给类误添加了非静态方法时,类不能实例化,添加的非静态方法就形同虚设了

    public T FromXml<T>(string xml) { ... }

    所以我们需要将类设置成静态的,这样当类中有非静态方法时编译时就会抛出异常,告诉我们类中只能包含静态成员

    public static class XmlUtility
    {
        public static string ToXml(object input)
        {
            var xs = new XmlSerializer(input.GetType());
            using (var memoryStream = new MemoryStream())
            using (var xmlTextWriter = new XmlTextWriter(memoryStream, new UTF8Encoding()))
            {
                xs.Serialize(xmlTextWriter, input);
                return Encoding.UTF8.GetString(memoryStream.ToArray());
            }
        }
    } 

    给类添加Static修饰符,该类就只能包含静态成员,并且不能被实例化,我们也不可能随便就给添加了一个非静态的成员,否则是不能编译通过的。

    5 对象和集合初始化器

    在C#3.0及以上版本中增加了对象和集合初始化器的新特性,会使代码看起来更加简洁,还有可能带来更高的性能。初始化器其实就是一个语法糖。看下面的例子,给出的是一个结构

    public struct Point
    {
        public int X { get; set; }
        public int Y { get; set; }
    } 

    普通初始化如下

    var startingPoint = new Point();
    startingPoint.X = 5;
    startingPoint.Y = 13;

    使用初始化器初始化

    var startingPoint = new Point() { X = 5, Y = 13 };

    代码的确精简了不少,从三行减到了一行,可以让我们少敲很多字。

    下面再来看看集合的初始化,假设我们在一个集合List中添加5个整数

    var list = new List<int>();
    list.Add(1);
    list.Add(7);
    list.Add(13);
    list.Add(42);

    使用集合初始化器,代码如下

    var list = new List<int> { 1, 7, 13, 42 };

    如果我们事先知道要加载的数量,可以给List设置默认的容量值,如下

    var list = new List<int>(4) { 1, 7, 13, 42 };

    下面来看一个通常情况下对象和集合一起使用的例子

    var list = new List<Point>();
    var point = new Point();
    point.X = 5;
    point.Y = 13;
    list.Add(point);
    point = new Point();
    point.X = 42;
    point.Y = 111;
    list.Add(point);
    point = new Point();
    point.X = 7;
    point.Y = 9;
    list.Add(point); 

    下面为使用了初始化器的代码,可以对比一下区别

    var list = new List<Point>
    {
        new Point { X = 5, Y = 13 },
        new Point { X = 42, Y = 111 },
        new Point { X = 7, Y = 9 }
    }; 

    使用对象或集合初始化器给我们带来了非常简洁的代码,尽管有时一条语句会占用多行,但是可读性是非常好的。

    有些时候在性能上也会带来提升,看下面两个类

    public class BeforeFieldInit
    {
        public static List<int> ThisList = new List<int>() { 1, 2, 3, 4, 5 };
    }
    
    public class NotBeforeFieldInit
    {
        public static List<int> ThisList;
    
        static NotBeforeFieldInit()
        {
            ThisList = new List<int>();
            ThisList.Add(1);
            ThisList.Add(2);
            ThisList.Add(3);
            ThisList.Add(4);
            ThisList.Add(5);
        }
    } 

    这两个类都是做同样的事情,都是创建一个静态的List字段,然后添加了1到5五个整数。不同的是第一个类在生成的IL代码中类上会添加beforefieldinit标记,对比两个类生成的IL代码

    .class public auto ansi beforefieldinit BeforeFieldInit
           extends [mscorlib]System.Object
    {
    } // end of class BeforeFieldInit 
     
    .class public auto ansi NotBeforeFieldInit
           extends [mscorlib]System.Object
    {
    } // end of class NotBeforeFieldInit 

    有关静态构造函数的性能问题可以参考CLR Via C# 学习笔记(5) 静态构造函数的性能

    (三)

    前面两篇中已经介绍了10个小技巧,本篇是本次系列的最后一篇,将再介绍5个。这些小技巧看着并不起眼,有些您可能知道,但在平时的开发中可能由于惯性并没有去使用。所以建议大家掌握并去使用这些小技巧,他们将使我们的代码变得更简洁和易于维护。

    1 隐式类型

    首先了解一下概念,隐式类型并不是动态类型,隐式类型是用关键字var来定义,var定义的类型仍然是强类型。

    很多人认为使用隐式类型是懒惰的表现,刚开始我也是这么认为的,但是想想我使用STL中迭代指针的开发经理,我就明白了。看下面代码:

    for (list<int>::const_iterator it = myList.begin(); it != myList.end(); ++it)
    {
        // ...
    }

    很多时候我们会写出下面这样的代码

    // pretty obvious
    ActiveOrdersDataAccessObject obj = new ActiveOrdersDataAccessObject();
     
    // still obvious but even more typing
    Dictionary<string,List<Product>> productsByCategory = 
        new Dictionary<string,List<Product>>();

    上面的代码的类型定义很明显,是什么类型就用什么类型来定义,下面尝试用var关键字来定义

    // nicer!
    var obj = new ActiveOrdersDataAccessObject();
    // Ah, so much nicer!
    var productsByCategory = new Dictionary<string,List<Product>>();

    用var关键字后代码变得简洁多了,编译器会在编译时去推断是什么类型,var关键字只相当于是一个占位符。

    而且使用var关键字在我们使用泛型或是Linq表达式时会提供更好的可读性,比较下面两行代码:

    // 隐式类型
    var results1 = from p in products where p.Value > 100 group p by p.Category;
    
    // 显示类型
    IEnumerable<IGrouping<string, Product>> results2 = 
        from p in products where p.Value > 100 group p by p.Category;

    2 Linq 扩展方法

    在以前的编码中,很多时候我们需要去写一些自己的函数库,如排序、分组、查找或是其他的一些算法。并且我们要花很多的时间来为这些函数写单元测试,很多时候困扰我们的一些bug正是在这些方法中出现的。

    随着Linq扩展方法的推出,你可以使用现成的这些标准的算法,而不需要自己再去写一遍,提供了极大的方便。需要排序可以使用OrderBy(),当需要查询条件时可以使用Where(),当需要选择一些类的属性时可以使用Select(),当需要分组查询时可以使用GroupBy(),这些Linq中的扩展方法经过了全面的测试,不需要我们来为他写单元测试代码,也不会出现让人困扰的bug。

    看下面的例子,假设有一个集合List<Product>,集合里装载的是Product对象,Product有Value和Category两个属性,现在要按类别来查找Value值大于$100的数据,以前我们可能会像下面这样写

    var results = new Dictionary<string, List<Product>>();
    
    foreach (var p in products)
    {
        if (p.Value > 100)
        {
            List<Product> productsByGroup;
            if (!results.TryGetValue(p.Category, out productsByGroup))
            {
                productsByGroup = new List<Product>();
                results.Add(p.Category, productsByGroup);
            }
    
            productsByGroup.Add(p);
        }
    }

    使用Linq扩展方法

    var results = products
                   .Where(p => p.Value > 100)
                   .GroupBy(p => p.Category);

    也可以像下面这样写

    var results = from p in products where p.Value > 100 group p by p.Category;

    3 扩展方法

    扩展方法可以让我们自己对一些类型进行方法的扩展,像上面讲到的Linq的一些扩展方法。扩展方法是一个静态方法,而且必须在一个静态类中。看下面这个例子,编写一个扩展方法将所以对象转换成XML。

    public static class ObjectExtensions
    {
        public static string ToXml(this object input, bool shouldPrettyPrint)
        {
            if (input == null) throw new ArgumentNullException("input");
    
            var xs = new XmlSerializer(input.GetType());
    
            using (var memoryStream = new MemoryStream())
            using (var xmlTextWriter = new XmlTextWriter(memoryStream, new UTF8Encoding()))
            {
                xs.Serialize(xmlTextWriter, input);
                return Encoding.UTF8.GetString(memoryStream.ToArray());
            }
        }
    }

    需要注意的是,包含扩展方法的类必须为静态类;扩展方法必须为静态方法;方法的第一个参数必须在类型前面使用this关键字。下面看看怎样调用该扩展方法

    // can convert primatives to xml
    string intXml = 5.ToXml();
    
    // can convert complex types to xml
    string objXml = employee.ToXml();
    
    // can even call as static method if you choose:
    objXml = ObjectExtensions.ToXml(employee);

    其实扩展方法只是一个语法糖,他可以使我们在类型上添加一些自己的方法。适当的使用扩展方法可以给我们的编码带来方便,但过度使用会适得其反,会使代码易读性变差,而且我们的只能提示项也会变得非常庞大。

    4 System.IO.Path

    Net中的System.IO.Path方法有很多的静态方法来处理文件和路径。很多时候我们尝试手动的将路径和文件名结合在一起而导致产生的文件路径不可用,因为我们往往忽视了路径后面可能有一个结尾符号‘’。现在使用Path.Combine()方法可以避免这种错误

    string fullPath = Path.Combine(workingDirectory, fileName);

    假设现在有一个带文件名的完整的路径名,我们需要取其中的路径、文件名或是文件的扩展名。Path类的很多静态方法可以满足我们的需要,如下

    string fullPath = "c:\Downloads\output\t0.html";
    // gets "c:"
    string pathPart = Path.GetPathRoot(fullPath);
    // gets "t0.html"
    string filePart = Path.GetFileName(fullPath);
    // gets ".html"
    string extPart = Path.GetExtension(fullPath);
    // gets "c:downloadsoutput"
    string dirPart = Path.GetDirectoryName(fullPath);

    所以当我们遇到这种需要对文件路径进行操作时,我们可以去使用Path类的静态方法。

    5 泛型委托

    如果你写过或使用过带事件的类,或是用过Linq的一些扩展方法,那么您很多可能直接或间接的使用过委托。委托可以以一种强有力的方式类创建一个类型,用来描述一个方法的签名。在运行时来使用和调用这个方法。这个有点类似于C++中的函数指针。

    委托最伟大的是比类的继承有更好的重用性,假设你要设计一个缓存类,该类有一些方法供用户调用,但是取决于缓存项是否过期或是被删除了。你向用户提供一个抽象方法,让用户去继承类并重载该方法,这意味着增加了很多额外的工作。

    如果使用委托,可以在被调用是在指定的方法中进行缓存项的过期检查,可以传递或设置委托方法,匿名委托或是lambda表达式进行调用,这样没有必须创建子类,我们可以将类设置成密封的以防止任何意外的发生,这样使类更加安全和有更好的可重用性。

    那么这些和泛型委托有什么关系呢?现在有三个委托的基本“类型”反复的出现,而又不想去反复写这些委托类型。就要使用泛型委托了,泛型委托还可以提高我们代码的可读性。下面是三个Net提供的泛型委托类型

    Action<T>

    Predicate<T>

    Func<TResult>

    关于上面三个泛型委托类型的详细解释和用法,可以点击链接看MSDN

    再回到刚才说到的缓存的例子,你希望该缓存接受一个缓存策略,并且有一个委托,委托的返回值来表示缓存是否过期,代码如下:

    public sealed class CacheItem<T>
    {
        public DateTime Created { get; set; }
    
        public DateTime LastAccess { get; set; }
    
        public T Value { get; set; }
    }
    
    public sealed class Cache<T>
    {
        private ConcurrentDictionary<string, CacheItem<T>> _cache;
        private Predicate<CacheItem<T>> _expirationStrategy;
    
        public Cache(Predicate<CacheItem<T>> expirationStrategy)
        {
            // set the delegate
            _expirationStrategy = expirationStrategy;
        }
        // ...
        private void CheckForExpired()
        {
            foreach (var item in _cache)
            {
                // call the delegate
                if (_expirationStrategy(item.Value))
                {
                    // remove the item...
                }
            }
        }
    }

    现在就可以创建和使用缓存类了

    var cache = 
    new Cache<int>(item => DateTime.Now - item.LastAccess > TimeSpan.FromSeconds(30));

    事实上我们可以发挥我们的想象对缓存创建很多的过期策略,但不要去使用继承。了解和使用泛型委托他会增加我们类的可重用性。

  • 相关阅读:
    ArrayBlockingQueue
    mysql数据库引擎
    数据库主从复制,分库分表
    Java并发工具类-Semaphore,Exchanger
    分片策略
    kafka
    关于数据库设计
    生产者消费者模式

    Callable、Future和FutureTask
  • 原文地址:https://www.cnblogs.com/ITzhangyunpeng/p/9436923.html
Copyright © 2011-2022 走看看