C#的MessagePack(unity)--01

简介

c#中极快的MessagePack序列化器。它比MsgPack-Cli快10倍,并且优于其他c#序列化器。

c#的MessagePack还内置了对LZ4压缩的支持------一种非常快的压缩算法。性能非常重要,特别是在游戏、分布式计算、微服务或数据缓存等应用程序中。

MessagePack具有紧凑的二进制大小和一套完整的通用表达数据类型。

地址:GitHub - MessagePack-CSharp/MessagePack-CSharp: Extremely Fast MessagePack Serializer for C#(.NET, .NET Core, Unity, Xamarin). / msgpack.org[C#]

安装

查看地址中README.md,分别有NuGet packages和UnityPack。对于Unity项目,发布页面提供了可下载的.unitypackage文件。当在Unity IL2CPP或Xamarin AOT环境中使用时,请仔细阅读预代码生成部分。

configuration中APILevel改为:.Net Framework

开始

定义要序列化的结构或类,并用[MessagePackObject]属性对其进行注释。用[Key]属性注释值应该序列化的成员(字段和属性)。

cs 复制代码
using MessagePack;
using UnityEngine;

public class Test_MessagePack : MonoBehaviour
{

}

[MessagePackObject]
public class MyClass
{
    // Key attributes take a serialization index (or string name)
    /*键属性接受序列化索引(或字符串名称)*/
    // The values must be unique and versioning has to be considered as well.
    /*这些值必须是唯一的,并且必须考虑版本控制。*/
    [Key(0)]
    public int Age { get; set; }

    [Key(1)]
    public string FirstName { get; set; }

    [Key(2)]
    public string LastName { get; set; }

    // 所有不应该序列化的字段或属性必须用[IgnoreMember]注释。
    [IgnoreMember]
    public string FullName { get { return FirstName + LastName; } }
}

调用 MessagePackSerializer.Serialize<T>/Deserialize<T> 来序列化/反序列化您的对象实例。您可以使用 ConvertToJson 方法获取任何 MessagePack 二进制数据的可读表示形式。

分析器

MessagePackAnalyzer 包有助于:

  1. 自动为可序列化对象定义。

  2. 在不正确使用属性、成员可访问性等时生成编译器警告。

内置支持的类型以及自定义自行看官方。

对象序列化

MessagePack for C# 可以序列化您自己的公共类或结构类型。默认情况下,可序列化类型必须使用 [MessagePackObject] 特性进行注释,成员使用 [Key] 特性进行注释。键可以是索引(int)或任意字符串。如果所有键都是索引,则会使用数组进行序列化,这在性能和二进制大小方面具有优势。否则,将使用 MessagePack 映射(字典)。如果您使用 [MessagePackObject(keyAsPropertyName: true)] ,则成员不需要显式的 Key 特性,但是会使用字符串键。

cs 复制代码
[MessagePackObject]
public class Sample1
{
    [Key(0)]
    public int Foo { get; set; }
    [Key(1)]
    public int Bar { get; set; }
}

[MessagePackObject]
public class Sample2
{
    [Key("foo")]
    public int Foo { get; set; }
    [Key("bar")]
    public int Bar { get; set; }
}

[MessagePackObject(keyAsPropertyName: true)]
public class Sample3
{
    //不需要 Key 特性。
    public int Foo { get; set; }

    //如果要忽略公共成员,可以使用 IgnoreMember 特性。
    [IgnoreMember]
    public int Bar { get; set; }
}
    private void Test_02()
    {
        // [10,20]
        Debug.Log(MessagePackSerializer.SerializeToJson(new Sample1 { Foo = 10, Bar = 20 }));

        // {"foo":10,"bar":20}
        Debug.Log(MessagePackSerializer.SerializeToJson(new Sample2 { Foo = 10, Bar = 20 }));

        // {"Foo":10}
        Debug.Log(MessagePackSerializer.SerializeToJson(new Sample3 { Foo = 10, Bar = 20 }));
    }

所有公共实例成员(包括字段和属性)都将被序列化。如果要忽略某些公共成员,请使用 [IgnoreMember] 特性注解该成员。

请注意,任何可序列化的结构或类都必须具有公共访问权限;私有和内部结构和类不能被序列化!要求 MessagePackObject 注释的默认设置是为了强制显式性,因此可能有助于编写更健壮的代码。

您应该使用索引(int)键还是字符串键?我们建议使用索引键以实现更快的序列化和比字符串键更紧凑的二进制表示形式。然而,字符串键中的字符串中的附加信息在调试时非常有用。

当类发生变化或被扩展时,要注意版本控制。MessagePackSerializer 将初始化成员为它们的默认值,如果序列化二进制数据中不存在某个键,则意味着使用引用类型的成员可以初始化为 null。如果您使用索引(int)键,则键应从 0 开始,并且应该是连续的。如果后续版本停止使用某些成员,则应在其他客户端有机会更新并删除对这些成员的使用之前保留这些废弃成员(C# 提供了一个 Obsolete 属性来注解此类成员)。此外,当索引键的值"跳动"很多时,会在序列中留下间隙,这将对二进制大小产生负面影响,因为 null 占位符会被插入到生成的数组中。但是,为了避免客户端之间的兼容性问题或尝试反序列化遗留数据包时出现兼容性问题,不应重用已删除成员的索引。

索引间隙和占位符的例子:

cs 复制代码
[MessagePackObject]
public class IntKeySample
{
    [Key(3)]
    public int A { get; set; }
    [Key(10)]
    public int B { get; set; }
}
   
 private void Test_03()
{
   // [null,null,null,0,null,null,null,null,null,null,0]
   Debug.Log(MessagePackSerializer.SerializeToJson(new IntKeySample()));
}

如果您不希望使用 MessagePackObject/Key 特性进行显式注解,而是希望像 e.g. Json.NET 一样使用 MessagePack for C#,则可以使用无合同解析器。

cs 复制代码
    private void Test_04()
    {
        var data = new ContractlessSample { MyProperty1 = 99, MyProperty2 = 9999 };
        var bin = MessagePackSerializer.Serialize(
          data,
          MessagePack.Resolvers.ContractlessStandardResolver.Options);

        // {"MyProperty1":99,"MyProperty2":9999}
        Debug.Log(MessagePackSerializer.ConvertToJson(bin));

        // 你也可以将ContractlessStandardResolver设置为默认值。
        // (全局状态;不建议在编写库代码时使用)
        MessagePackSerializer.DefaultOptions = MessagePack.Resolvers.ContractlessStandardResolver.Options;

        // Now serializable...
        var bin2 = MessagePackSerializer.Serialize(data);
        // {"MyProperty1":99,"MyProperty2":9999}
        Debug.Log(MessagePackSerializer.ConvertToJson(bin2));
    }
public class ContractlessSample
{
    public int MyProperty1 { get; set; }
    public int MyProperty2 { get; set; }
}

如果您希望序列化私有成员,您可以使用 *AllowPrivate 解析器之一。

如果您希望使用 MessagePack for C# 类似于 BinaryFormatter,具有无类型的序列化 API,则可以使用无类型解析器和助手。请参阅"无类型"部分。 解析器是为 MessagePack for C# 添加自定义类型专用支持的方法。请参考"扩展点"部分。

DataContract compatibility

您可以使用 [DataContract] 标记代替 [MessagePackObject] 标记。如果类型带有 DataContract 标记,您可以使用 [DataMember] 标记代替 [Key] 标记以及 [IgnoreDataMember] 替换 [IgnoreMember] 标记。

然后 [DataMember(Order = int)] 行为与 [Key(int)] 相同,[DataMember(Name = string)] 与 [Key(string)] 相同,而 [DataMember] 则与 [Key(nameof(成员名))] 相同。

使用 DataContract(例如在共享库中)可以使您的类/结构独立于 MessagePack for C# 序列化。但是,它不被分析器支持,也不被 mpc 工具用于代码生成。此外,诸如 UnionAttribute、MessagePackFormatter、SerializationConstructor 等功能也无法使用。因此,我们建议尽可能使用特定的 MessagePack for C# 注释。

MessagePack for C# 支持只读/不可变( readonly/immutable)对象成员的序列化。

MessagePack for C# 支持只读/不可变对象/成员的序列化。例如,这个结构体可以被序列化和反序列化。

cs 复制代码
[MessagePackObject]
public struct Point
{
    [Key(0)]
    public readonly int X;
    [Key(1)]
    public readonly int Y;

    public Point(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}
    private void Test_06()
    {
        var data = new Point(99, 9999);
        var bin = MessagePackSerializer.Serialize(data);
        //[99,9999]
        Debug.Log(MessagePackSerializer.ConvertToJson(bin));
        // 反序列化不可变对象
        var point = MessagePackSerializer.Deserialize<Point>(bin);
        //99**9999
        Debug.Log($"{point.X}**{point.Y}");
    }

MessagePackSerializer 将选择与索引键匹配的参数列表最好的构造函数,或者使用参数名称作为字符串键。如果找不到合适的构造函数,则会抛出 MessagePackDynamicObjectResolverException:无法找到匹配的构造函数参数异常。可以使用 [SerializationConstructor] 注解手动指定要使用的构造函数。

cs 复制代码
[MessagePackObject]
public struct Point
{
    [Key(0)]
    public readonly int X;
    [Key(1)]
    public readonly int Y;

    [SerializationConstructor]
    public Point(int x)
    {
        this.X = x;
        this.Y = -1;
    }

    // 如果没有标记属性,则使用这个(最匹配的参数)。
    public Point(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

序列化回调

在序列化/反序列化过程中,实现了 IMessagePackSerializationCallbackReceiver 接口的对象将收到 OnBeforeSerialize 和 OnAfterDeserialize 调用。

cs 复制代码
[MessagePackObject]
public class SampleCallback : IMessagePackSerializationCallbackReceiver
{
    [Key(0)]
    public int Key { get; set; }

    public void OnBeforeSerialize()
    {
        Debug.Log("OnBefore");
    }

    public void OnAfterDeserialize()
    {
        Debug.Log("OnAfter");
    }
}

联合

MessagePack for C# 支持接口类型和抽象类类型的对象的序列化。它的行为类似于 XmlInclude 或 ProtoInclude。在 MessagePack for C# 中,这些称为联合。只有接口和抽象类才允许标注联合属性。需要唯一的联合密钥。

cs 复制代码
// 注释继承类型
[MessagePack.Union(0, typeof(FooClass))]
[MessagePack.Union(1, typeof(BarClass))]
public interface IUnionSample { }

[MessagePackObject]
public class FooClass : IUnionSample
{
    [Key(0)]
    public int XYZ { get; set; }
}

[MessagePackObject]
public class BarClass : IUnionSample
{
    [Key(0)]
    public string OPQ { get; set; }
}
    private void Test_08()
    {
        //IUnionSample data = new FooClass() { XYZ = 999 };
        IUnionSample data = new BarClass() { OPQ = "BarClass" };
        // 序列化接口类型的对象。
        var bin = MessagePackSerializer.Serialize(data);

        // 再次进行反序列化。
        var reData = MessagePackSerializer.Deserialize<IUnionSample>(bin);

        // 与c# 7.0中的类型切换一起使用
        switch (reData)
        {
            case FooClass x:
                Debug.Log(x.XYZ);
                break;
            case BarClass x:
                Debug.Log(x.OPQ);
                break;
            default:
                break;
        }
    }

请注意,在衍生类型中不能重复使用在父类型中已经存在的相同键,因为内部会使用单一的扁平数组或映射,并且因此不能有重复的索引/键。

动态(无类型)反序列化

当调用MessagePackSerializer.Deserialize<object>MessagePackSerializer.Deserialize<dynamic>时,二进制大对象(blob)中的任何值都会被反序列化为其对应的原始类型。如bool, char, sbyte, byte, short, int, long, ushort, uint, ulong, float, double, DateTime, string, byte[], object[], IDictionary<object, object>.

使用字典索引器语法探索对象树是无类型反序列化的最快选项,但它很繁琐且难以阅读和编写。在性能不如代码可读性重要的情况下,可以考虑使用ExpandoObject进行反序列化。

对象类型序列化

StandardResolver 和 ContractlessStandardResolver 是 ServiceStack.Text 库中的两种序列化器,它们能够处理对象类型或匿名类型的序列化。

cs 复制代码
    private void Test_09()
    {
        var objects = new object[] { 1, "aaa", new FooClass { XYZ= 9999 } };
        var bin = MessagePackSerializer.Serialize(objects);

        // [1,"aaa",[9999]]
        Debug.Log(MessagePackSerializer.ConvertToJson(bin));

        // 支持匿名类型序列化。
        var anonType = new { Foo = 100, Bar = "foobar" };
        var bin2 = MessagePackSerializer.Serialize(anonType, MessagePack.Resolvers.ContractlessStandardResolver.Options);

        // {"Foo":100,"Bar":"foobar"}
        Debug.Log(MessagePackSerializer.ConvertToJson(bin2));
    }

在反序列化时, behavior 将与动态(非类型)反序列化相同。

无类型的

无类型API与BinaryFormatter类似,因为它会将类型信息嵌入到blob中,因此在调用API时不需要显式指定类型。

安全

从不受信任的源反序列化数据可能会引入应用程序的安全漏洞。根据反序列化期间使用的设置,不受信任的数据可能能够执行任意代码或导致拒绝服务攻击。不受信任的数据可能来自网络上的不受信任源(例如任何和每个联网客户端),或者在通过未认证连接传输时可以被中间人篡改,或者来自可能已被篡改的本地存储或其他许多来源。MessagePack for C#不提供任何方法来验证数据或使其防篡改。请在反序列化之前使用适当的方法来验证数据 - 例如MAC。

请注意这些攻击情况;过去很多项目、公司以及序列化库用户都曾受到过不受信任的用户数据反序列化的困扰。

当反序列化不受信任的数据时,请将MessagePack配置为更安全的模式,通过配置您的MessagePackSerializerOptions.Security属性:

cs 复制代码
var options = MessagePackSerializerOptions.Standard
    .WithSecurity(MessagePackSecurity.UntrustedData);

// Pass the options explicitly for the greatest control.
T object = MessagePackSerializer.Deserialize<T>(data, options);

// Or set the security level as the default.
MessagePackSerializer.DefaultOptions = options;

你还应该避免对不受信任的数据使用无类型的序列化程序/格式/解析器,因为这会使不受信任的数据有可能反序列化出未曾预料到的类型,从而破坏安全性。

UntrustedData模式只能抵御一些常见的攻击,但本身并不是一个完全安全的解决方案。

性能

MessagePack For C#使用了许多技术来提高性能。

序列化器使用IBufferWriter<byte>而不是System.IO.Stream来减少内存开销。

缓冲区是从池中租借的,以减少分配,通过减少GC压力保持高吞吐量。

不要创建中间实用工具实例(* Writer / * Reader,* Context等...)

利用动态代码生成和JIT避免值类型的装箱。在禁止JIT的平台上使用AOT生成。

缓存在静态泛型字段上的生成形式(不使用字典缓存,因为字典查找是开销)。参见Resolvers

针对值类型的装箱进行了大量优化的动态IL代码生成和JIT。参见DynamicObjectTypeBuilder。在禁止JIT的平台上使用AOT生成。

当IL代码生成确定目标类型为原始类型时直接调用Primitive API。

减少变量长度格式的分支,当IL代码生成知道目标类型(整数/字符串)范围时

尽可能不要使用IEnumerable<T>抽象迭代集合,参见:CollectionFormatterBase及其派生的集合格式器

使用预生成的查找表来减少mgpack类型约束检查,参见:MessagePackBinary

使用优化的类型键字典用于非泛型方法,参见:ThreadsafeTypeKeyHashTable

避免查找映射(字符串键)的字符串键解码(使用自动机名称查找并与内联IL代码生成一起使用,参见:AutomataDictionary

要编码字符串键,请使用预生成的成员名字节和IL中的固定大小字节数组副本,参见:UnsafeMemory.cs

在这个库之前,我已经实现了快速序列化器与ZeroFormatter# Performance。这是一个进一步进化的实现。MessagePack For C#始终快速且针对所有类型(原始类型、小结构、大对象、任何集合)进行优化。

字符串内联

msgpack格式不允许在数据流中重用字符串。这自然会导致反序列化器为遇到的每一个字符串创建一个新的字符串对象,即使它等于先前遇到的另一个字符串。

当反序列化可能包含重复字符串的数据时,让反序列化器花一点额外的时间检查是否以前见过给定的字符串,并在已见过的情况下重新使用它是值得的。

要在所有字符串值上启用字符串内联,请像这样使用一个在任何标准解析器之前指定StringInterningFormatter的解析器:

cs 复制代码
    private void Test_10()
    {
           var options = MessagePackSerializerOptions.Standard.WithResolver(
        CompositeResolver.Create(
            new MessagePack.Formatters.IMessagePackFormatter[] { new MessagePack.Formatters.StringInterningFormatter() },
            new IFormatterResolver[] { StandardResolver.Instance }));
        var data = new ClassOfStrings { InternedString= "InternedString",OrdinaryString= "OrdinaryString" };
        var lz4Options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);

        ClassOfStrings newClassOfStrings = MessagePackSerializer.Deserialize<ClassOfStrings>(
                   MessagePackSerializer.Serialize(data, lz4Options), options);
        Debug.Log($"{newClassOfStrings.InternedString}**{newClassOfStrings.OrdinaryString}");
    }
[MessagePackObject]
public class ClassOfStrings
{
    [Key(0)]
    [MessagePackFormatter(typeof(MessagePack.Formatters.StringInterningFormatter))]
    public string InternedString { get; set; }

    [Key(1)]
    public string OrdinaryString { get; set; }
}

如果您正在编写自己的某些含有字符串类型的格式器,则也可以直接从您的格式器中调用StringInterningFormatter。

LZ4压缩

MessagePack是一种快速紧凑的格式,但它不是压缩。LZ4是一个非常快的压缩算法,并且使用它MessagePack for C#可以获得极高的性能以及极其紧凑的二进制大小!

MessagePack for C#内置了LZ4支持。您可以修改options对象并将其传递到这样的API中来激活它:

cs 复制代码
var lz4Options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);
MessagePackSerializer.Serialize(obj, lz4Options);

MessagePackCompression有两种模式:Lz4Block和Lz4BlockArray。两者都不是简单的二进制LZ4压缩,而是集成到序列化管道中的特殊压缩,使用MessagePack扩展代码(Lz4BlockArray(98)或Lz4Block(99))。因此,它与其他语言提供的压缩兼容性不佳。

Lz4Block将整个MessagePack序列作为单个LZ4块压缩。这是实现最佳压缩比的简单压缩,但在必要时复制整个序列以获取连续内存。

Lz4BlockArray将整个MessagePack序列作为LZ4块数组压缩。压缩/解压块分块,不会进入GC的大对象堆,但是压缩比稍差。

我们建议使用Lz4BlockArray作为默认压缩方式。为了与MessagePack v1.x兼容,请使用Lz4Block。

无论在反序列化时设置了哪种LZ4选项,两种方法都可以反序列化。例如,当使用Lz4BlockArray选项时,可以反序列化使用Lz4Block和Lz4BlockArray的二进制数据。如果未设置压缩选项,则无法解压缩并因此不能进行反序列化。

当使用MessagePack for C#时提高性能的最佳实践

MessagePack for C#默认优先考虑最大性能。然而,也有些选项牺牲性能以换取方便。

1、使用索引键而不是字符串键(Contractless)

不同的选项对于反序列化性能部分显示了索引键(IntKey)和字符串键(StringKey)性能的结果。索引键将以MessagePack数组的形式序列化对象图。字符串键将以MessagePack地图的形式序列化对象图。例如,此类型被序列化为

cs 复制代码
[MessagePackObject]
public class Person
{
    [Key(0)] or [Key("name")]
    public string Name { get; set;}
    [Key(1)] or [Key("age")]
    public int Age { get; set;}
}

new Person { Name = "foobar", Age = 999 }
  • IntKey: ["foobar", 999]
  • StringKey: {"name:"foobar","age":999}.

IntKey在序列化和反序列化方面总是很快,因为它不必处理和查找键名,并且总能保持较小的二进制大小。

StringKey通常是一个有用的,无合同,简单的JSON替代品,与其他支持MessagePack的语言之间的互操作性和较少的版本错误倾向。但是为了获得最高性能,请使用IntKey。

创建自己的自定义复合解析器

CompositeResolver.Create是一种创建复合解析器的简便方法。但是查找器有一些开销。如果你创建自定义解析器(或使用StaticCompositeResolver.Instance),你可以避免这个开销。

注意:如果你正在创建库,则推荐使用上述自定义解析器而不是CompositeResolver.Create。此外,库不得使用StaticCompositeResolver------因为它是全局状态------以避免兼容性问题。

使用本机解析器

默认情况下,MessagePack for C#将以字符串形式序列化GUID。这比.NET原生格式GUID慢得多。Decimal也是如此。如果你的应用程序大量使用GUID或Decimal并且不必担心与其他语言的互操作性,则可以分别用NativeGuidResolver和NativeDecimalResolver替换它们。

此外,DateTime使用MessagePack时间戳格式进行序列化。通过使用NativeDateTimeResolver,可以保留Kind并更快地进行序列化。

注意复制缓冲区

默认情况下,MessagePackSerializer.Serialize返回byte[]。最终的byte[]是从内部缓冲池复制的。这是额外的成本。您可以使用IBufferWriter<T>或Stream API直接写入缓冲区。如果你想在序列化器之外使用缓冲池,你应该实现自定义的IBufferWriter<byte>或使用Nerdbank.Streams包中的Sequence<T>等现有缓冲池。

在反序列化期间,MessagePackSerializer.Deserialize(ReadOnlyMemory <byte> buffer)优于Deserialize(Stream stream)重载。这是因为Stream API版本首先读取数据、生成ReadOnlySequence <byte>,然后开始反序列化。

选择压缩

如果有重复数据,压缩通常是有效的。在MessagePack中,使用字符串键的对象数组(Contractless)可以有效地进行压缩,因为可以对多个重复的属性名称应用压缩。索引键不如字符串键压缩有效,但是索引键本身就更小。

IntKey(Lz4)压缩效果不佳,但性能仍然有所降低。另一方面,StringKey可以预期对二进制大小产生足够的影响。但是这只是一种示例。根据数据的不同,压缩也可能非常有效,或者除了减慢你的程序外几乎没有其他影响。还存在可以在值中很好地压缩的情况(例如包含许多重复HTML标签的长字符串)。重要的是要逐案核实压缩的实际效果。

扩展

MessagePack for C#提供了用于自定义类型的优化序列化支持的扩展点。官方提供了扩展支持包。

cs 复制代码
Install-Package MessagePack.ReactiveProperty
Install-Package MessagePack.UnityShims
Install-Package MessagePack.AspNetCoreMvcFormatter

MessagePack.ReactiveProperty包为ReactiveProperty库添加了支持。它增加了ReactiveProperty<>、IReactiveProperty<>、IReadOnlyReactiveProperty<>、ReactiveCollection<>、Unit序列化的支持。这对于保存视图模型状态很有用。

MessagePack.UnityShims包提供了Unity的标准结构(Vector2、Vector3、Vector4、Quaternion、Color、Bounds、Rect、AnimationCurve、Keyframe、Matrix4x4、Gradient、Color32、RectOffset、LayerMask、Vector2Int、Vector3Int、RangeInt、RectInt、BoundsInt)和相应的格式器的支持。它可以启用服务器和Unity客户端之间的正确通信。

cs 复制代码
    private void Test_11()
    {
        // Set extensions to default resolver.
        var resolver = MessagePack.Resolvers.CompositeResolver.Create(
            // 将扩展设为默认解析器。
            ReactivePropertyResolver.Instance,
            MessagePack.Unity.Extension.UnityBlitResolver.Instance,
            MessagePack.Unity.UnityResolver.Instance,

            // 最后使用标准(默认)解析器。
            StandardResolver.Instance
        );
        var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);

        // 每次传递选项或将之设为默认
        MessagePackSerializer.DefaultOptions = options;
    }
相关推荐
小吴同学·2 小时前
.NET6 WebApi第1讲:VSCode开发.NET项目、区别.NET5框架【两个框架启动流程详解】
c#·.netcore·.net core
bluefox19797 小时前
使用 Oracle.DataAccess.Client 驱动 和 OleDB 调用Oracle 函数的区别
开发语言·c#
鲤籽鲲8 小时前
C# MethodTimer.Fody 使用详解
开发语言·c#·mfc
工业3D_大熊8 小时前
3D可视化引擎HOOPS Luminate场景图详解:形状的创建、销毁与管理
java·c++·3d·docker·c#·制造·数据可视化
yngsqq9 小时前
c#使用高版本8.0步骤
java·前端·c#
hccee12 小时前
C# IO文件操作
开发语言·c#
广煜永不挂科14 小时前
Devexpress.Dashboard的调用二义性
c#·express
初九之潜龙勿用16 小时前
C#校验画布签名图片是否为空白
开发语言·ui·c#·.net
吾与谁归in17 小时前
【C#设计模式(13)——代理模式(Proxy Pattern)】
设计模式·c#·代理模式
吾与谁归in17 小时前
【C#设计模式(14)——责任链模式( Chain-of-responsibility Pattern)】
设计模式·c#·责任链模式