探索.NET 新增的重要组成部分

来源:Stephen Toub

假设要公开特殊化排序例程,以就地对内存数据执行操作。可能要公开需要使用数组的方法,并提供对相应 T[] 执行操作的实现。如果方法的调用方有数组,且希望对整个数组进行排序,这样做就非常合适。但如果调用方只想对部分数组进行排序,该怎么办?可能还要公开需要使用偏移和计数的重载。

但如果要支持的内存数据不在数组中,而是来自本机代码(举个例子)或位于堆栈上,并且你只有指针和长度,该怎么办?如何才能让编写的排序方法对内存的任意区域执行操作,同时还对完整数组或部分数组以及托管数组和非托管指针同样有效?

又例如,假设要对 System.String 实现操作,如使用特殊化分析方法。可能要公开需要使用字符串的方法,并提供对字符串执行操作的实现。但如果要支持对部分字符串执行操作,该怎么办?虽然 String.Substring 可用于分离出仅感兴趣的部分,但此操作的成本相对高昂,涉及字符串分配和内存复制。

正如数组示例中提到的,可以使用偏移和计数。但如果调用方没有字符串,而是有 char[],该怎么办?或者,如果调用方有 char*(例如为了使用堆栈上某空间而使用 stackalloc 创建的,或通过调用本机代码而生成的),该怎么办?如果才能让编写的分析方法不强制调用方执行任何分配或复制操作,同时还对输入的类型字符串、char[] 和 char* 同样有效?

在这两个示例中,都可以使用不安全代码和指针,同时公开接受指针和长度的实现。不过,这样一来,就无法获取对 .NET 至关重要的安全保障,并且会遇到对大多数 .NET 开发人员而言已成为过去的问题,如缓冲区溢出和访问冲突。

此外,这还会引发其他性能损失,如需要在操作期间固定托管对象,让检索的指针一直有效。而且根据涉及的数据类型,获取指针根本就不可行。

此难题还是有解决方法的,即使用 Span。

什么是 Span?

System.Span 是在 .NET 中发挥关键作用的新值类型。使用它,可以表示任意内存的相邻区域,无论相应内存是与托管对象相关联,还是通过互操作由本机代码提供,亦或是位于堆栈上。除了具有上述用途外,它仍能确保安全访问和高性能特性,就像数组一样。

例如,可以通过数组创建 Span:

var arr = new byte[10];

Span bytes = arr; // Implicit cast from T[] to Span

随后,可以轻松高效地创建 Span,以利用 Span 的 Slice 方法重载,仅表示/指向此数组的子集。随后,可以为生成的 Span 编制索引,以编写和读取原始数组中相关部分的数据:

Span slicedBytes = bytes.Slice(start: 5, length: 2);

slicedBytes[0] = 42;

slicedBytes[1] = 43;

Assert.Equal(42, slicedBytes[0]);

Assert.Equal(43, slicedBytes[1]);

Assert.Equal(arr[5], slicedBytes[0]);

Assert.Equal(arr[6], slicedBytes[1]);

slicedBytes[2] = 44; // Throws IndexOutOfRangeException

bytes[2] = 45; // OK

Assert.Equal(arr[2], bytes[2]);

Assert.Equal(45, arr[2]);

正如之前提到的,Span 不仅仅只能用于访问数组和分离出数组子集。还可用于引用堆栈上的数据。例如,

Span bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans

bytes[0] = 42;

bytes[1] = 43;

Assert.Equal(42, bytes[0]);

Assert.Equal(43, bytes[1]);

bytes[2] = 44; // throws IndexOutOfRangeException

更为普遍的是,Span 可用于引用任意指针和长度(如通过本机堆分配的内存),如下所示:

IntPtr ptr = Marshal.AllocHGlobal(1);

try

{

Span bytes;

unsafe { bytes = new Span((byte*)ptr, 1); }

bytes[0] = 42;

Assert.Equal(42, bytes[0]);

Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);

bytes[1] = 43; // Throws IndexOutOfRangeException

}

finally { Marshal.FreeHGlobal(ptr); }

Span 索引器利用 C# 7.0 中引入的 C# 语言功能,即引用返回。

索引器使用“引用 T”返回类型进行声明,其中提供为数组编制索引的语义,同时返回对实际存储位置的引用,而不是相应位置上存在的副本:

public ref T this[int index] { get { ... } }

通过示例,可以最明显地体现这种引用返回类型索引器带来的影响,如将它与不是引用返回类型的 List 索引器进行比较。例如:

struct MutableStruct { public int Value; }

...

Span spanOfStructs = new MutableStruct[1];

spanOfStructs[0].Value = 42;

Assert.Equal(42, spanOfStructs[0].Value);

var listOfStructs = new List { new MutableStruct() };

listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable

Span 的第二个变体为 System.ReadOnlySpan,可启用只读访问。此类型与 Span 基本类似,不同之处在于前者的索引器利用新 C# 7.2 功能来返回“引用只读 T”,而不是“引用 T”,这样就可以处理 System.String 等不可变数据类型。使用 ReadOnlySpan,可以非常高效地分离字符串,而无需执行分配或复制操作,如下所示:

string str = "hello, world";

string worldString = str.Substring(startIndex: 7, length: 5); // Allocates ReadOnlySpan worldSpan =

str.AsReadOnlySpan().Slice(start: 7, length: 5); // No allocation

Assert.Equal('w', worldSpan[0]);

worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to

Span 的优势还有许多,远不止已提到的这些。

例如,Span 支持 reinterpret_cast 的理念,即可以将 Span 强制转换为 Span(其中,Span 中的索引 0 映射到 Span 的前四个字节)。这样一来,如果读取字节缓冲区,可以安全高效地将它传递到对分组字节(视作整数)执行操作的方法。

如何实现 Span?

开发人员通常无需了解要使用的库是如何实现的。不过,对于 Span,对背后的运作机制详情至少有一个基本了解是值得的,因为这些详情暗含有关性能和使用约束的相关信息。

首先,Span 是包含引用和长度的值类型,定义大致如下:

public readonly ref struct Span

{

private readonly ref T _pointer;

private readonly int _length;

...

}

“引用 T”字段这一概念初看起来有些奇怪,因为其实无法在 C# 或甚至 MSIL 中声明“引用 T”字段。不过,Span 实际上旨在于运行时使用特殊内部类型,可看作是内部实时 (JIT) 类型,由 JIT 为其生成等效的“引用 T”字段。以可能更为熟悉的引用用法为例:

public static void AddOne(ref int value) => value += 1;

...

var values = new int[] { 42, 84, 126 };

AddOne(ref values[2]);

Assert.Equal(127, values[2]);

此代码通过引用传递数组中的槽,这样(除优化外)还可以在堆栈上生成引用 T。Span 中的引用 T 有异曲同工之妙,直接封装在结构中。直接或间接包含此类引用的类型被称为类似引用的类型,C# 7.2 编译器支持在签名中使用引用结构,从而声明这种类似引用的类型。

根据这一简要说明,应明确两点:

Span 的定义方式可确保操作效率与数组一样高:为 Span 编制索引无需通过计算来确定指针开头及其起始偏移,因为“引用”字段本身已对两者进行了封装。(相比之下,ArraySegment 有单独的偏移字段,这就增加了索引编制和数据传递操作的成本。)

鉴于类似引用的类型这一本质,Span 因其“引用 T”字段而受到一些约束。

第二点带来了一些有趣的后果,即导致 .NET 包含第二组相关的类型(由 Memory 主导)。

什么是 Memory?为什么需要它?

Span 是类似引用的类型,因为它包含“引用”字段,而且“引用”字段不仅可以引用数组等对象的开头,还可以引用它们的中间部分:

var arr = new byte[100];

Span interiorRef1 = arr.AsSpan().Slice(start: 20);

Span interiorRef2 = new Span(arr, 20, arr.Length – 20);

Span interiorRef3 =

Span.DangerousCreate(arr, ref arr[20], arr.Length – 20);

这些引用被称为“内部指针”。对于 .NET 运行时的垃圾回收器,跟踪这些指针是一项成本相对高昂的操作。因此,运行时将这些引用约束为仅存在于堆栈上,因为它隐式规定了可以存在的内部指针数量下限。

此外,如前所述,Span 大于计算机的字大小;也就是说,对 Span 执行的读取和写入操作不是原子操作。

如果多个线程同时对 Span 在堆上的字段执行读取和写入操作,存在“撕裂”风险。 假设现有一个已初始化的 Span,其中包含有效引用和值为 50 的相应 _length。

一个线程开始编写新 Span,并且还编写新 _pointer 值。然后,还未将相应的 _length 设置为 20,另一个线程就开始读取 Span,其中包含新 _pointer 和更长的旧 _length。

这样一来,Span 示例只能存在于堆栈上,而不能存在于堆上。也就是说,无法将 Span 装箱,进而无法将 Span 与现有反射调用 API(举个例子)结合使用,因为它们需要执行装箱。这意味着,无法将 Span 字段封装在类中,甚至也无法封装在不类似引用的结构中。也就是说,如果 Span 可能会隐式成为类中的字段,则无法使用它们。例如,将它们捕获到 lambda 中,或将它们捕获为异步方法或迭代器中的本地字段,因为这些本地字段可能最终会成为编译器生成的状态机上的字段。 这还意味着,无法将 Span 用作泛型参数,因为类型参数实例可能最终会被装箱或以其他方式存储到堆上(暂无“where T : ref struct”约束)。

对于许多方案,尤其是对于受计算量限制和同步处理功能,这些限制无关紧要。不过,异步功能却是另一回事。无论是处理同步操作还是异步操作,本文开头提到的大部分有关数组、数组切片和本机内存等问题仍存在。但如果 Span 无法存储到堆,因而无法跨异步操作暂留,那么还有什么解决方法?答案就是 Memory。

Memory looks very much like an ArraySegment:

public readonly struct Memory

{

private readonly object _object;

private readonly int _index;

private readonly int _length;

...

}

可以通过数组创建 Memory,并进行切片。这与处理 Span 基本相同,不同之处在于 Memory 是不类似引用的结构,可以存在于堆上。然后,若要执行同步处理,可以从中获取 Span,例如:

static async Task ChecksumReadAsync(Memory buffer, Stream stream)

{

int bytesRead = await stream.ReadAsync(buffer);

return Checksum(buffer.Span.Slice(0, bytesRead));

// Or buffer.Slice(0, bytesRead).Span

}

static int Checksum(Span buffer) { ... }

与 Span 和 ReadOnlySpan 一样,Memory 也有等效的只读类型,即 ReadOnlyMemory。与预期一样,它的 Span 属性返回 ReadOnlySpan。请参阅图 1,快速概览在这些类型之间进行转换的内置机制。

图 1:在 Span 相关类型之间进行非分配/非复制转换

将会注意到,Memory 的 _object 字段并未强类型化为 T[],而是存储为对象。这突出说明 Memory 可以包装数组以外的内容,如 System.Buffers.OwnedMemory。OwnedMemory 是抽象类,可用于包装需要密切管理其生存期的数据,如从池中检索到的内存。此主题更为高级,超出了本文的介绍范围,但这就是 Memory 的用途所在(例如,用于将指针包装到本机内存)。ReadOnlyMemory 也可以与字符串结合使用,就像 ReadOnlySpan 一样。

Span 和 Memory 如何与 .NET 库集成?

在上面的 Memory 代码片段中,将会注意到传入 Memory 的 Stream.ReadAsync 调用。但如今在 .NET 中,Stream.ReadAsync 被定义为接受 byte[]。它的工作原理是什么?

为了支持 Span 及其成员,即将向 .NET 添加数百个新成员和类型。其中大多是现有基于数组和基于字符串的方法的重载,而另一些则是专注于特定处理方面的全新类型。例如,除了包含需要使用字符串的现有重载外,所有原始类型(如 Int32)现在都包含接受 ReadOnlySpan 的 Parse 重载。假设字符串包含两部分数字(用逗号隔开,如“123,456”),且希望分析这部分数字。现在,可以编写如下代码:

string input = ...;

int commaPos = input.IndexOf(',');

int first = int.Parse(input.Substring(0, commaPos));

int second = int.Parse(input.Substring(commaPos + 1));

不过,这会生成两个字符串分配。若要编写高性能代码,两个字符串分配可能就太多了。此时,可以改为编写如下代码:

string input = ...;

ReadOnlySpan inputSpan = input.AsReadOnlySpan();

int commaPos = input.IndexOf(',');

int first = int.Parse(inputSpan.Slice(0, commaPos));

int second = int.Parse(inputSpan.Slice(commaPos + 1));

通过使用基于 Span 的新 Parse 重载,可以在这整个操作期间避免执行分配操作。类似分析和格式化方法可用于原始类型(如 Int32),其中包括 DateTime、TimeSpan 和 Guid 等核心类型,甚至还包括 BigInteger 和 IPAddress 等更高级别类型。

实际上,已跨框架添加了许多这样的方法。

从 System.Random 到 System.Text.StringBuilder,再到 System.Net.Socket,这些重载的添加有利于轻松高效地处理 Span 和 Memory。其中一些甚至带来了额外的好处。例如,Stream 现包含以下方法:

public virtual ValueTask ReadAsync(

Memory destination,

CancellationToken cancellationToken = default) { ... }

将会注意到,不同于接受 byte[] 并返回 Task 的现有 ReadAsync 方法,此重载不仅接受 Memory(而不是 byte[]),还返回 ValueTask(而不是 Task)。

在以下情况下,ValueTask 是有助于避免执行分配操作的结构:经常要求使用异步方法来同步返回内容,以及不太可能为所有常见返回值缓存已完成任务。

例如,运行时可以为结果 true 和 false 缓存已完成的 Task,但无法为 Task 的所有可能结果值缓存四十亿任务对象。

由于相当常见的是 Stream 实现的缓冲方式让 ReadAsync 调用同步完成,因此这一新 ReadAsync 重载返回 ValueTask。也就是说,同步完成的异步 Stream 读取操作可以完全避免执行分配操作。ValueTask 也用于其他新重载,如 Socket.ReceiveAsync、Socket.SendAsync、WebSocket.ReceiveAsync 和 TextReader.ReadAsync 重载。

此外,在一些情况下,Span 还支持向框架添加在过去引发内存安全问题的方法。假设要创建的字符串包含随机生成的值(如某类 ID)。现在,可能会编写要求分配字符数组的代码,如下所示:

int length = ...;

Random rand = ...;

var chars = new char[length];

for (int i = 0; i

{

chars[i] = (char)(rand.Next(0, 10) + '0');

}

string id = new string(chars);

可以改用堆栈分配,甚至能够利用 Span,这样就无需使用不安全代码。此方法还利用接受 ReadOnlySpan 的新字符串构造函数,如下所示:

int length = ...;

Random rand = ...;

Span chars = stackalloc char[length];

for (int i = 0; i

{

chars[i] = (char)(rand.Next(0, 10) + '0');

}

string id = new string(chars);

这样做更好,因为避免了堆分配,但仍不得不将堆栈上生成的数据复制到字符串中。同样,只有在所需空间大小对于堆栈而言足够小时,此方法才有效。如果长度较短(如 32 个字节),可以使用此方法;但如果长度为数千字节,很容易就会引发堆栈溢出问题。如果可以改为直接写入字符串的内存,该怎么办?Span 可以实现此目的。除了包含新构造函数以外,字符串现在还包含 Create 方法:

public static string Create(

int length, TState state, SpanAction action);

...

public delegate void SpanAction(Span span, TArg arg);

实现此方法是为了分配字符串,并分发可写 Span,执行写入操作后可以在构造字符串的同时填写字符串的内容。请注意,在此示例中,Span 的仅限堆栈这一本质非常有用,因为可以保证在字符串的构造函数完成前 Span(引用字符串的内部存储)就不存在,这样便无法在构造完成后使用 Span 改变字符串了:

int length = ...;

Random rand = ...;

string id = string.Create(length, rand, (Span chars, Random r) =>

{

for (int i = 0; chars.Length; i++)

{

chars[i] = (char)(r.Next(0, 10) + '0');

}

});

现在,不仅避免了分配操作,还可以直接写入字符串在堆上的内存,即也避免了复制操作,且不受堆栈大小限制的约束。

除了核心框架类型有新成员外,我们还正在积极开发许多可与 Span 结合使用的新 .NET 类型,从而在特定方案中实现高效处理。例如,对于要编写高性能微服务和处理大量文本的网站的开发人员,如果在使用 UTF-8 时无需编码和解码字符串,则性能会大大提升。

为此,我们即将添加 System.Buffers.Text.Base64、System.Buffers.Text.Utf8Parser 和 System.Buffers.Text.Utf8Formatter 等新类型。这些类型对字节 Span 执行操作,不仅避免了 Unicode 编码和解码,还能够处理在各种网络堆栈的最低级别中常见的本机缓冲:

ReadOnlySpan utf8Text = ...;

if (!Utf8Parser.TryParse(utf8Text, out Guid value,

out int bytesConsumed, standardFormat = 'P'))

throw new InvalidDataException();

所有此类功能不仅仅只用于公共使用用途;框架本身也可以利用这些基于 Span 和基于 Memory 的新方法来提升性能。跨 .NET Core 调用网站已切换为使用新的 ReadAsync 重载,以避免不必要的分配操作。

分析过去是通过分配子字符串完成,现在可以避免执行分配操作。

甚至 Rfc2898DeriveBytes 等间隙类型也实际运用了此功能,利用 System.Security.Cryptography.Hash­Algorithm 上基于 Span 的新 TryComputeHash 方法显著减少分配操作量(每次算法迭代的字节数组,可能迭代数千次)和提升吞吐量。

这并未止步于核心 .NET 库一级,而是继续全面影响堆栈。ASP.NET Core 现在严重依赖 Span;例如,在 Span 基础之上编写 Kestrel 服务器的 HTTP 分析程序。Span 今后可能会通过较低级别 ASP.NET Core 中的公共 API 公开,如在它的中间件管道中。

.NET 运行时又如何呢?

.NET 运行时提供安全保障的方法之一是,确保为数组编制的索引不超出数组的长度,这种做法称为“边界检查”。例如,以下面这个方法为例:

[MethodImpl(MethodImplOptions.NoInlining)]

static int Return4th(int[] data) => data[3];

在我撰写本文使用的 x64 计算机上,针对此方法生成的程序集如下所示:

sub rsp, 40

cmp dword ptr [rcx+8], 3

jbe SHORT G_M22714_IG04

mov eax, dword ptr [rcx+28]

add rsp, 40

ret

G_M22714_IG04:

call CORINFO_HELP_RNGCHKFAIL

int3

cmp 指令将数据数组的长度与索引 3 进行比较。如果 3 超出范围(异常抛出),后续 jbe 指令会转到范围检查失败例程。虽然 JIT 需要生成代码,以确保此类访问不会超出数组边界,但这并不意味着每个数组访问都需要进行边界检查。以下面的 Sum 方法为例:

static int Sum(int[] data)

{

int sum = 0;

for (int i = 0; i

return sum;

}

虽然 JIT 此时需要生成代码,以确保对 data[i] 的访问不超出数组边界,但因为 JIT 能够通过循环结构判断 i 一直在范围内(循环从头到尾遍历每个元素),所以 JIT 可以优化为不对数组进行边界检查。因此,针对循环生成的程序集代码如下所示:

G_M33811_IG03:

movsxd r9, edx

add eax, dword ptr [rcx+4*r9+16]

inc edx

cmp r8d, edx

jg SHORT G_M33811_IG03

虽然 cmp 指令仍在循环中,但只需将 i 值(存储在 edx 寄存器中)与数组长度(存储在 r8d 寄存器中)进行比较,无需额外进行边界检查。

运行时向 Span(Span 和 ReadOnlySpan)应用类似优化。将上面的示例与下面的代码进行比较,唯一的变化是参数类型:

static int Sum(Span data)

{

int sum = 0;

for (int i = 0; i

return sum;

}

针对此代码生成的程序集几乎完全相同:

G_M33812_IG03:

movsxd r9, r8d

add ecx, dword ptr [rax+4*r9]

inc r8d

cmp r8d, edx

jl SHORT G_M33812_IG03

程序集代码如此相似,部分是因为不用进行边界检查。此外,同样重要的是 JIT 将 Span 索引器识别为内部类型,即 JIT 为索引器生成特殊代码,而不是将它的实际 IL 代码转换为程序集。

所有这些都是为了说明运行时可以为 Span 应用与数组相同的优化类型,让 Span 成为高效的数据访问机制。如需了解更多详情,请参阅 bit.ly/2zywvyI 上的博客文章。

C# 语言和编译器又如何呢?

我已暗示,添加到 C# 语言和编译器的功能有助于让 Span 成为 .NET 中的一流成员。C# 7.2 的多项功能都与 Span 相关(实际上,C# 7.2 编译器必须使用 Span)。接下来,将介绍三个此类功能。

引用结构。如前所述,Span 是类似引用的类型,自版本 7.2 起在 C# 中公开为引用结构。

通过将引用关键字置于结构前,可以指示 C# 编译器将其他引用结构类型(如 Span)用作字段,这样做还会注册要分配给类型的相关约束。

例如,若要为 Span 编写结构枚举器,枚举器需要存储 Span,因此它本身必须是引用结构,如下所示:

public ref struct Enumerator

{

private readonly Span _span;

private int _index;

...

}

Span 的 stackalloc 初始化。在旧版 C# 中,只能将 stackalloc 的结果存储到指针本地变量中。

自 C# 7.2 起,现在可以在表达式中使用 stackalloc,并能定目标到 Span,而不使用不安全关键字。因为,无需编写:

Span bytes;

unsafe

{

byte* tmp = stackalloc byte[length];

bytes = new Span(tmp, length);

}

只需编写:

Span bytes = stackalloc byte[length];

如果需要一些空间来执行操作,但又希望避免分配相对较小的堆内存,此代码就非常有用。过去有以下两种选择:

编写两个完全不同的代码路径,对基于堆栈的内存和基于堆的内存执行分配和操作。

固定与托管分配相关联的内存,再委托到实现,实现也用于基于堆栈的内存,并通过不安全代码中的指针控制进行编写。

现在,不使用代码复制,即可完成相同的操作,而且还可以使用安全代码和最简单的操作:

Span bytes = length

... // Code that operates on the Span

Span 使用验证。因为 Span 可以引用可能与给定堆栈帧相关联的数据,所以传递 Span 可能存在危险,此操作可能会引用不再有效的内存。例如,假设方法尝试执行以下操作:

static Span FormatGuid(Guid guid)

{

Span chars = stackalloc char[100];

bool formatted = guid.TryFormat(chars, out int charsWritten, "d");

Debug.Assert(formatted);

return chars.Slice(0, charsWritten); // Uh oh

}

此时,空间从堆栈进行分配,然后尝试返回对此空间的引用,但在返回的同时,此空间不再可用。幸运的是,C# 编译器使用引用结构检测此类无效使用,并会停止编译,同时显示以下错误:

错误 CS8352:无法在此上下文中使用本地“字符”,因为它可能会在声明范围外公开引用的变量

接下来会怎样呢?

本文介绍的类型、方法、运行时优化和其他元素即将顺利添加到 .NET Core 2.1 中。之后,我预计它们会全面影响 .NET Framework。核心类型(如 Span)和新类型(如 Utf8Parser)也即将顺利添加到与 .NET Standard 1.1 兼容的 System.Memory.dll 包中。这样一来,相关功能将适用于现有 .NET Framework 和 .NET Core 版本,尽管在内置于平台时没有实现一些优化。现在,可以试用此包的预览版,只需添加对 NuGet 上 System.Memory.dll 包的引用即可。

当然,请注意,当前预览版与实际发布的稳定版之间可能会有重大变革。此类变革很大程度上源于像你这样的开发人员在试用功能集时提供的反馈。因此,请试用预览版,并关注 github.com/dotnet/coreclr 和 github.com/dotnet/corefx 存储库,以掌握最新动态。此外,有关文档,还可以访问 aka.ms/ref72。

总的来说,此功能集能否取得成功依赖开发人员试用预览版、提供反馈以及利用这些类型生成自己的库,所有这些都是为了能够在新式 .NET 程序中高效安全地访问内存。我们热切期待聆听大家的使用体验反馈,最好能够与大家一起在 GitHub 上进一步改进 .NET。

看完本文有收获?请转发分享给更多人

关注「DotNet」,提升.Net技能

淘口令:复制以下红色内容,再打开手淘即可购买

范品社,使用¥极客T恤¥抢先预览(长按复制整段文案,打开手机淘宝即可进入活动内容)

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180227B0GIUC00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券