如何在单元测试中比较两个对象?

63
public class Student
{
    public string Name { get; set; }
    public int ID { get; set; }
}

...

var st1 = new Student
{
    ID = 20,
    Name = "ligaoren",
};

var st2 = new Student
{
    ID = 20,
    Name = "ligaoren",
};

Assert.AreEqual<Student>(st1, st2);// How to Compare two object in Unit test?

如何在单元测试中比较两个集合?


你是在询问比较对象的相等性并测试对象是否等效吗? - Intrigue
17个回答

66
你需要的是从 xUnit Test Patterns 中被称为测试特定相等性(Test-Specific Equality)的内容。
虽然你有时可以选择重写Equals方法,但这可能会导致相等污染(Equality Pollution),因为你在测试中需要的实现可能并不适用于该类型的一般情况。
例如,领域驱动设计区分了实体(Entities)值对象(Value Objects),它们具有非常不同的相等语义。
在这种情况下,你可以为所涉及的类型编写自定义比较方法。
如果你不想手动这样做,AutoFixture的Likeness类提供了通用的测试特定相等性。对于你的Student类,这将允许你编写像这样的测试:
[TestMethod]
public void VerifyThatStudentAreEqual()
{
    Student st1 = new Student();
    st1.ID = 20;
    st1.Name = "ligaoren";

    Student st2 = new Student();
    st2.ID = 20;
    st2.Name = "ligaoren";

    var expectedStudent = new Likeness<Student, Student>(st1);

    Assert.AreEqual(expectedStudent, st2);
}

这不需要你在学生类上重写 Equals 方法。

Likeness 进行语义比较,因此只要两个类型在语义上相似,它就可以比较两个不同的类型。


我以前从未听说过AutoFixture,这是一个有趣的项目!至于“平等污染”,我理解你的观点,但从我的角度来看,正因为平等可能是模糊的,所以在测试中明确(即使很痛苦)也是值得的。 - Mathias
1
是的,但您仍需要在可用性和可维护性方面保持平衡,并且比较两个深度图的相等性对我来说太痛苦了。 Likeness 的好处在于,一旦您知道它是什么,使用它非常明确,仍然非常轻量级。 - Mark Seemann
2
我发现@MarkSeemann的博客文章关于使用Likeness非常有用。 - Boggin
4
类似功能也可以在SemanticComparison NuGet包中找到。 - johlrich
我已经下载了NuGet包,但是如何使用以下功能呢? var expectedStudent = new Likeness<Student, Student>(st1); 针对这个函数应该使用什么呢? - Vivek Patel
2
@VivekPatel 如果您有新问题,请提出新问题。在此通知我,如果我能回答,我将很高兴为您解答。 - Mark Seemann

17
如果仅需比较公共成员变量,只需将对象转换为JSON格式并比较生成的字符串即可。
var js = new JavaScriptSerializer();
Assert.AreEqual(js.Serialize(st1), js.Serialize(st2));

JavaScriptSerializer 类

优点

  • 需要最少的代码,零努力和无需预先设置
  • 能够处理具有嵌套对象的复杂结构
  • 不会在您的类型中添加单元测试特定的代码,例如 Equals

缺点

  • 只考虑可序列化的公共成员 (尽管不需要对成员进行注释)
  • 不能处理循环引用

8
你应该提供 Object.EqualsObject.GetHashCode 的一个 {{override}}:
public override bool Equals(object obj) {
    Student other = obj as Student;
    if(other == null) {
        return false;
    }
    return (this.Name == other.Name) && (this.ID == other.ID);
}

public override int GetHashCode() {
    return 33 * Name.GetHashCode() + ID.GetHashCode();
}

关于检查两个集合是否相等,使用 Enumerable.SequenceEqual 方法:
// first and second are IEnumerable<T>
Assert.IsTrue(first.SequenceEqual(second)); 

请注意,您可能需要使用接受 IEqualityComparer<T>overload

@Michael Haren:谢谢!这是我犯的一个愚蠢的错误。 - jason
5
覆盖 Equals 的问题在于这并不总是正确的做法。这里发布的实现暗示了值对象语义,这对于领域驱动设计(DDD)中的值对象可能是正确的,但对于 DDD 实体(举几个一般的例子),就不是正确的做法。 - Mark Seemann
1
覆盖Equals / GetHashCode进行测试是最糟糕的事情。在遇到问题后曾经这样做过,但现在同意需要测试特定的相等性。 - user1325696
有用的文章:https://msdn.microsoft.com/zh-cn/library/ms173147.aspx - Rich

8
马克·西曼的回答涵盖了一个一般性问题:测试等价性是一个单独的问题,因此代码应该是外部于类本身。 (我以前没有见过“等价性污染”,但是这个问题也是如此。) 同时,这是一个仅限于你的单元测试项目的问题。更好的是,它在许多情况下都是“已解决问题”:有很多断言库可用,可以让您以任意数量的任意方式测试相等性。他建议了一个库,尽管这些年来出现了许多其他成熟的库。

为此,让我建议使用Fluent Assertions。它具有各种比较的能力。在这种情况下,非常简单:

st1.ShouldBeEquivalentTo(st2); // before 5.0

或者
st1.Should().BeEquivalentTo(st2); // 5.0 and later

7
这里是我们用于比较复杂图形的NUnit 2.4.6自定义约束条件。它支持嵌入式集合、父引用、为数字比较设置公差、识别要忽略的字段名称(即使在层次结构深处),以及装饰类型始终被忽略。
我相信这段代码可以适应于NUnit之外的使用,因为大部分代码不依赖于NUnit。
我们在成千上万的单元测试中使用它。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using NUnit.Framework;
using NUnit.Framework.Constraints;

namespace Tests
{
    public class ContentsEqualConstraint : Constraint
    {
        private readonly object expected;
        private Constraint failedEquality;
        private string expectedDescription;
        private string actualDescription;

        private readonly Stack<string> typePath = new Stack<string>();
        private string typePathExpanded;

        private readonly HashSet<string> _ignoredNames = new HashSet<string>();
        private readonly HashSet<Type> _ignoredTypes = new HashSet<Type>();
        private readonly LinkedList<Type> _ignoredInterfaces = new LinkedList<Type>();
        private readonly LinkedList<string> _ignoredSuffixes = new LinkedList<string>();
        private readonly IDictionary<Type, Func<object, object, bool>> _predicates = new Dictionary<Type, Func<object, object, bool>>();

        private bool _withoutSort;
        private int _maxRecursion = int.MaxValue;

        private readonly HashSet<VisitedComparison> _visitedObjects = new HashSet<VisitedComparison>();

        private static readonly HashSet<string> _globallyIgnoredNames = new HashSet<string>();
        private static readonly HashSet<Type> _globallyIgnoredTypes = new HashSet<Type>();
        private static readonly LinkedList<Type> _globallyIgnoredInterfaces = new LinkedList<Type>();

        private static object _regionalTolerance;

        public ContentsEqualConstraint(object expectedValue)
        {
            expected = expectedValue;
        }

        public ContentsEqualConstraint Comparing<T>(Func<T, T, bool> predicate)
        {
            Type t = typeof (T);

            if (predicate == null)
            {
                _predicates.Remove(t);
            }
            else
            {
                _predicates[t] = (x, y) => predicate((T) x, (T) y);
            }
            return this;
        }

        public ContentsEqualConstraint Ignoring(string fieldName)
        {
            _ignoredNames.Add(fieldName);
            return this;
        }

        public ContentsEqualConstraint Ignoring(Type fieldType)
        {
            if (fieldType.IsInterface)
            {
                _ignoredInterfaces.AddFirst(fieldType);
            }
            else
            {
                _ignoredTypes.Add(fieldType);
            }
            return this;
        }

        public ContentsEqualConstraint IgnoringSuffix(string suffix)
        {
            if (string.IsNullOrEmpty(suffix))
            {
                throw new ArgumentNullException("suffix");
            }
            _ignoredSuffixes.AddLast(suffix);
            return this;
        }

        public ContentsEqualConstraint WithoutSort()
        {
            _withoutSort = true;
            return this;
        }

        public ContentsEqualConstraint RecursingOnly(int levels)
        {
            _maxRecursion = levels;
            return this;
        }

        public static void GlobalIgnore(string fieldName)
        {
            _globallyIgnoredNames.Add(fieldName);
        }

        public static void GlobalIgnore(Type fieldType)
        {
            if (fieldType.IsInterface)
            {
                _globallyIgnoredInterfaces.AddFirst(fieldType);
            }
            else
            {
                _globallyIgnoredTypes.Add(fieldType);
            }
        }

        public static IDisposable RegionalIgnore(string fieldName)
        {
            return new RegionalIgnoreTracker(fieldName);
        }

        public static IDisposable RegionalIgnore(Type fieldType)
        {
            return new RegionalIgnoreTracker(fieldType);
        }

        public static IDisposable RegionalWithin(object tolerance)
        {
            return new RegionalWithinTracker(tolerance);
        }

        public override bool Matches(object actualValue)
        {
            typePathExpanded = null;
            actual = actualValue;
            return Matches(expected, actualValue);
        }

        private bool Matches(object expectedValue, object actualValue)
        {

            bool matches = true;

            if (!MatchesNull(expectedValue, actualValue, ref matches))
            {
                return matches;
            }
            // DatesEqualConstraint supports tolerance in dates but works as equal constraint for everything else
            Constraint eq = new DatesEqualConstraint(expectedValue).Within(tolerance ?? _regionalTolerance);
            if (eq.Matches(actualValue))
            {
                return true;
            }

            if (MatchesVisited(expectedValue, actualValue, ref matches))
            {
                if (MatchesDictionary(expectedValue, actualValue, ref matches) &&
                    MatchesList(expectedValue, actualValue, ref matches) &&
                    MatchesType(expectedValue, actualValue, ref matches) &&
                    MatchesPredicate(expectedValue, actualValue, ref matches))
                {
                    MatchesFields(expectedValue, actualValue, eq, ref matches);
                }
            }

            return matches;
        }

        private bool MatchesNull(object expectedValue, object actualValue, ref bool matches)
        {
            if (IsNullEquivalent(expectedValue))
            {
                expectedValue = null;
            }

            if (IsNullEquivalent(actualValue))
            {
                actualValue = null;
            }

            if (expectedValue == null && actualValue == null)
            {
                matches = true;
                return false;
            }

            if (expectedValue == null)
            {
                expectedDescription = "null";
                actualDescription = "NOT null";
                matches = Failure;
                return false;
            }

            if (actualValue == null)
            {
                expectedDescription = "not null";
                actualDescription = "null";
                matches = Failure;
                return false;
            }

            return true;
        }

        private bool MatchesType(object expectedValue, object actualValue, ref bool matches)
        {
            Type expectedType = expectedValue.GetType();
            Type actualType = actualValue.GetType();

            if (expectedType != actualType)
            {
                try
                {
                    Convert.ChangeType(actualValue, expectedType);
                }
                catch(InvalidCastException)             
                {
                    expectedDescription = expectedType.FullName;
                    actualDescription = actualType.FullName;
                    matches = Failure;
                    return false;
                }

            }
            return true;
        }

        private bool MatchesPredicate(object expectedValue, object actualValue, ref bool matches)
        {
            Type t = expectedValue.GetType();
            Func<object, object, bool> predicate;

            if (_predicates.TryGetValue(t, out predicate))
            {
                matches = predicate(expectedValue, actualValue);
                return false;
            }
            return true;
        }

        private bool MatchesVisited(object expectedValue, object actualValue, ref bool matches)
        {
            var c = new VisitedComparison(expectedValue, actualValue);

            if (_visitedObjects.Contains(c))
            {
                matches = true;
                return false;
            }

            _visitedObjects.Add(c);

            return true;
        }

        private bool MatchesDictionary(object expectedValue, object actualValue, ref bool matches)
        {
            if (expectedValue is IDictionary && actualValue is IDictionary)
            {
                var expectedDictionary = (IDictionary)expectedValue;
                var actualDictionary = (IDictionary)actualValue;

                if (expectedDictionary.Count != actualDictionary.Count)
                {
                    expectedDescription = expectedDictionary.Count + " item dictionary";
                    actualDescription = actualDictionary.Count + " item dictionary";
                    matches = Failure;
                    return false;
                }

                foreach (DictionaryEntry expectedEntry in expectedDictionary)
                {
                    if (!actualDictionary.Contains(expectedEntry.Key))
                    {
                        expectedDescription = expectedEntry.Key + " exists";
                        actualDescription = expectedEntry.Key + " does not exist";
                        matches = Failure;
                        return false;
                    }
                    if (CanRecurseFurther)
                    {
                        typePath.Push(expectedEntry.Key.ToString());
                        if (!Matches(expectedEntry.Value, actualDictionary[expectedEntry.Key]))
                        {
                            matches = Failure;
                            return false;
                        }
                        typePath.Pop();
                    }
                }
                matches = true;
                return false;
            }
            return true;
        }

        private bool MatchesList(object expectedValue, object actualValue, ref bool matches)
        {
            if (!(expectedValue is IList && actualValue is IList))
            {
                return true;
            }

            var expectedList = (IList) expectedValue;
            var actualList = (IList) actualValue;

            if (!Matches(expectedList.Count, actualList.Count))
            {
                matches = false;
            }
            else
            {
                if (CanRecurseFurther)
                {
                    int max = expectedList.Count;

                    if (max != 0 && !_withoutSort)
                    {
                        SafeSort(expectedList);
                        SafeSort(actualList);
                    }

                    for (int i = 0; i < max; i++)
                    {
                        typePath.Push(i.ToString());

                        if (!Matches(expectedList[i], actualList[i]))
                        {
                            matches = false;
                            return false;
                        }
                        typePath.Pop();
                    }
                }
                matches = true;
            }
            return false;
        }

        private void MatchesFields(object expectedValue, object actualValue, Constraint equalConstraint, ref bool matches)
        {
            Type expectedType = expectedValue.GetType();

            FieldInfo[] fields = expectedType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);

            // should have passed the EqualConstraint check
            if (expectedType.IsPrimitive ||
                expectedType == typeof(string) ||
                expectedType == typeof(Guid) ||
                fields.Length == 0)
            {
                failedEquality = equalConstraint;
                matches = Failure;
                return;
            }

            if (expectedType == typeof(DateTime))
            {
                var expectedDate = (DateTime)expectedValue;
                var actualDate = (DateTime)actualValue;

                if (Math.Abs((expectedDate - actualDate).TotalSeconds) > 3.0)
                {
                    failedEquality = equalConstraint;
                    matches = Failure;
                    return;
                }
                matches = true;
                return;
            }

            if (CanRecurseFurther)
            {
                while(true)
                {
                    foreach (FieldInfo field in fields)
                    {
                        if (!Ignore(field))
                        {
                            typePath.Push(field.Name);
                            if (!Matches(GetValue(field, expectedValue), GetValue(field, actualValue)))
                            {
                                matches = Failure;
                                return;
                            }
                            typePath.Pop();
                        }
                    }
                    expectedType = expectedType.BaseType;
                    if (expectedType == null)
                    {
                        break;
                    }
                    fields = expectedType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
                }
            }
            matches = true;
            return;
        }

        private bool Ignore(FieldInfo field)
        {
            if (_ignoredNames.Contains(field.Name) ||
                _ignoredTypes.Contains(field.FieldType) ||
                _globallyIgnoredNames.Contains(field.Name) ||
                _globallyIgnoredTypes.Contains(field.FieldType) ||
                field.GetCustomAttributes(typeof (IgnoreContentsAttribute), false).Length != 0)
            {
                return true;
            }

            foreach(string ignoreSuffix in _ignoredSuffixes)
            {
                if (field.Name.EndsWith(ignoreSuffix))
                {
                    return true;
                }
            }

            foreach (Type ignoredInterface in _ignoredInterfaces)
            {
                if (ignoredInterface.IsAssignableFrom(field.FieldType))
                {
                    return true;
                }
            }
            return false;
        }

        private static bool Failure
        {
            get
            {
                return false;
            }
        }

        private static bool IsNullEquivalent(object value)
        {
            return value == null ||
                    value == DBNull.Value ||
                   (value is int && (int) value == int.MinValue) ||
                   (value is double && (double) value == double.MinValue) ||
                   (value is DateTime && (DateTime) value == DateTime.MinValue) ||
                   (value is Guid && (Guid) value == Guid.Empty) ||
                   (value is IList && ((IList)value).Count == 0);
        }

        private static object GetValue(FieldInfo field, object source)
        {
            try
            {
                return field.GetValue(source);
            }
            catch(Exception ex)
            {
                return ex;
            }
        }

        public override void WriteMessageTo(MessageWriter writer)
        {
            if (TypePath.Length != 0)
            {
                writer.WriteLine("Failure on " + TypePath);
            }

            if (failedEquality != null)
            {
                failedEquality.WriteMessageTo(writer);
            }
            else
            {
                base.WriteMessageTo(writer);
            }
        }
        public override void WriteDescriptionTo(MessageWriter writer)
        {
            writer.Write(expectedDescription);
        }

        public override void WriteActualValueTo(MessageWriter writer)
        {
            writer.Write(actualDescription);
        }

        private string TypePath
        {
            get
            {
                if (typePathExpanded == null)
                {
                    string[] p = typePath.ToArray();
                    Array.Reverse(p);
                    var text = new StringBuilder(128);
                    bool isFirst = true;
                    foreach(string part in p)
                    {
                        if (isFirst)
                        {
                            text.Append(part);
                            isFirst = false;
                        }
                        else
                        {
                            int i;
                            if (int.TryParse(part, out i))
                            {
                                text.Append("[" + part + "]");
                            }
                            else
                            {
                                text.Append("." + part);
                            }
                        }
                    }
                    typePathExpanded = text.ToString();
                }
                return typePathExpanded;
            }
        }

        private bool CanRecurseFurther
        {
            get
            {
                return typePath.Count < _maxRecursion;
            }
        }

        private static bool SafeSort(IList list)
        {
            if (list == null)
            {
                return false;
            }

            if (list.Count < 2)
            {
                return true;
            }

            try
            {
                object first = FirstNonNull(list) as IComparable;
                if (first == null)
                {
                    return false;
                }

                if (list is Array)
                {
                    Array.Sort((Array)list);
                    return true;
                }
                return CallIfExists(list, "Sort");
            }
            catch
            {
                return false;
            }
        }

        private static object FirstNonNull(IEnumerable enumerable)
        {
            if (enumerable == null)
            {
                throw new ArgumentNullException("enumerable");
            }
            foreach (object item in enumerable)
            {
                if (item != null)
                {
                    return item;
                }
            }
            return null;
        }

        private static bool CallIfExists(object instance, string method)
        {
            if (instance == null)
            {
                throw new ArgumentNullException("instance");
            }
            if (String.IsNullOrEmpty(method))
            {
                throw new ArgumentNullException("method");
            }
            Type target = instance.GetType();
            MethodInfo m = target.GetMethod(method, new Type[0]);
            if (m != null)
            {
                m.Invoke(instance, null);
                return true;
            }
            return false;
        }

        #region VisitedComparison Helper

        private class VisitedComparison
        {
            private readonly object _expected;
            private readonly object _actual;

            public VisitedComparison(object expected, object actual)
            {
                _expected = expected;
                _actual = actual;
            }

            public override int GetHashCode()
            {
                return GetHashCode(_expected) ^ GetHashCode(_actual);
            }

            private static int GetHashCode(object o)
            {
                if (o == null)
                {
                    return 0;
                }
                return o.GetHashCode();
            }

            public override bool Equals(object obj)
            {
                if (obj == null)
                {
                    return false;
                }

                if (obj.GetType() != typeof(VisitedComparison))
                {
                    return false;
                }

                var other = (VisitedComparison) obj;
                return _expected == other._expected &&
                       _actual == other._actual;
            }
        }

        #endregion

        #region RegionalIgnoreTracker Helper

        private class RegionalIgnoreTracker : IDisposable
        {
            private readonly string _fieldName;
            private readonly Type _fieldType;

            public RegionalIgnoreTracker(string fieldName)
            {
                if (!_globallyIgnoredNames.Add(fieldName))
                {
                    _globallyIgnoredNames.Add(fieldName);
                    _fieldName = fieldName;
                }
            }

            public RegionalIgnoreTracker(Type fieldType)
            {
                if (!_globallyIgnoredTypes.Add(fieldType))
                {
                    _globallyIgnoredTypes.Add(fieldType);
                    _fieldType = fieldType;
                }
            }

            public void Dispose()
            {
                if (_fieldName != null)
                {
                    _globallyIgnoredNames.Remove(_fieldName);
                }
                if (_fieldType != null)
                {
                    _globallyIgnoredTypes.Remove(_fieldType);
                }
            }
        }

        #endregion

        #region RegionalWithinTracker Helper

        private class RegionalWithinTracker : IDisposable
        {
            public RegionalWithinTracker(object tolerance)
            {
                _regionalTolerance = tolerance;
            }

            public void Dispose()
            {
                _regionalTolerance = null;
            }
        }

        #endregion

        #region IgnoreContentsAttribute

        [AttributeUsage(AttributeTargets.Field)]
        public sealed class IgnoreContentsAttribute : Attribute
        {
        }

        #endregion
    }
    public class DatesEqualConstraint : EqualConstraint
    {
        private readonly object _expected;

        public DatesEqualConstraint(object expectedValue) : base(expectedValue)
        {
            _expected = expectedValue;
        }

        public override bool Matches(object actualValue)
        {
            if (tolerance != null && tolerance is TimeSpan)
            {
                if (_expected is DateTime && actualValue is DateTime)
                {
                    var expectedDate = (DateTime) _expected;
                    var actualDate = (DateTime) actualValue;
                    var toleranceSpan = (TimeSpan) tolerance;

                    if ((actualDate - expectedDate).Duration() <= toleranceSpan)
                    {
                        return true;
                    }
                }
                tolerance = null;
            }
            return base.Matches(actualValue);
        }
    }
}

1
我刚把这段代码复制到了VisualStudio10的一个新类中,但是tolarance没有定义。这是在新版本的Nunit中被移除了还是其他类似的问题? - TankorSmash
@TankorSmash,是的,他们经常更改NUnit的API。如果你只使用内置约束,你通常不会注意到,但当你创建自定义约束时,几乎每个版本都会受到影响。 - Samuel Neff

7

也许这个答案在比较集合中的项时会有所帮助。它使用了AutoFixture的Likeness,同时展示了它轻量级代理的能力。 - Nikos Baxevanis

4

我刚刚完成了以下操作:

Assert.AreEqual(Newtonsoft.Json.JsonConvert.SerializeObject(object1),
                Newtonsoft.Json.JsonConvert.SerializeObject(object2));

4

http://www.infoq.com/articles/Equality-Overloading-DotNET

这篇文章可能会有帮助,我解决了这个问题,只需使用反射将所有字段转储出来; 然后我们只需要比较两个字符串。

代码如下:

 /// <summary>
    /// output all properties and values of obj
    /// </summary>
    /// <param name="obj"></param>
    /// <param name="separator">default as ";"</param>
    /// <returns>properties and values of obj,with specified separator </returns>
    /// <Author>ligaoren</Author>
    public static string Dump(object obj, string separator)
    {
        try
        {
            if (obj == null)
            {
                return string.Empty;
            }
            if (string.IsNullOrEmpty(separator))
            {
                separator = ";";
            }
            Type t = obj.GetType();
            StringBuilder info = new StringBuilder(t.Name).Append(" Values : ");
            foreach (PropertyInfo item in t.GetProperties())
            {
                object value = t.GetProperty(item.Name).GetValue(obj, null);
                info.AppendFormat("[{0}:{1}]{2}", item.Name, value, separator);
            }
            return info.ToString();
        }
        catch (Exception ex)
        {
            log.Error("Dump Exception", ex);
            return string.Empty;
        }
    }

4
  1. Hello firstly add your test project Newtonsoft.Json with Nuget PM

    PM> Install-Package Newtonsoft.Json -Version 10.0.3

  2. Then add test file

    using Newtonsoft.Json;
    
  3. Usage:

    Assert.AreEqual(
        JsonConvert.SerializeObject(expected),
        JsonConvert.SerializeObject(actual));
    

3

您也可以使用NFluent的语法,深度比较两个对象而无需为您的对象实现相等性。 NFluent是一个试图简化编写可读测试代码的库。

Check.That(actual).HasFieldsWithSameValues(expected);

这种方法会引发异常,其中包含所有差异,而不是在第一个差异处失败。我认为这个特性是一个优点。


你如何在最新版本的NFluent中实现这个?方法IsDeepEqualTo似乎已经不存在了! - Dan Esparza
1
看起来方法应该是HasFieldsWithSameValues而不是IsDeepEqualTo,是吗? - Dan Esparza

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接