dotnet学习笔记-专题04-配置的读取和写入-01

配置的读取和写入

读取配置的类,包括手动从json中读取配置、将json配置与配置类绑定、从控制台读取配置、从环境变量读取配置

using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace LearnConfigurationSystem;
public static class ReadConfig
{
public static void ReadConfigManually()
{
// 获取ConfigurationBuilder实例
var configurationBuilder = new ConfigurationBuilder();
// configurationBuilder配置(path: 配置文件路径,optional: 配置文件是否可选,reloadOnChange: 配置文件改变时是否重新读取配置)
configurationBuilder.AddJsonFile(path: "config.json", optional: true, reloadOnChange: false);
// 从ConfigurationBuilder实例构建实现了IConfigurationRoot接口的实例
IConfigurationRoot config = configurationBuilder.Build();
// 从配置文件中读取数据,读取到的数据均用string?类型表示,即使对应数据在json中不是字符串类型,如果没有获取到指定名称的数据则用null表示
string? user = config["userName"];
string? proxyAddress = config.GetSection("proxy:address").Value;
string? port = config["proxy:port"];
Debug.Assert(user != null);
Debug.Assert(proxyAddress != null);
Debug.Assert(port != null);
Console.WriteLine($"{user} - {proxyAddress}:{port}");
}
public static void ReadConfigurationThenMapToConfigurationModels()
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddJsonFile("config.json", optional: false, reloadOnChange: true);
IConfigurationRoot config = configurationBuilder.Build();
// 使用依赖注入容器管理依赖注入
var service = new ServiceCollection();
service.AddOptions()
// 将config.json中的dataBaseConfig绑定到配置类DataBaseSettings(自动小驼峰->大驼峰转换,配置类中的属性大驼峰,json中写小驼峰就行)
.Configure<DataBaseSettings>(e => config.GetSection("dataBaseConfig").Bind(e))
// 将config.json中的smtpConfig绑定到配置类SmtpSettings(自动小驼峰->大驼峰转换,配置类中的属性大驼峰,json中写小驼峰就行)
.Configure<SmtpSettings>(e => config.GetSection("smtpConfig").Bind(e));
// 将Demo注册为瞬态服务
service.AddTransient<Demo>();
// 创建ServiceProvider服务
using var serviceProvider = service.BuildServiceProvider();
// 循环3次,方便测试(更改配置文件后查看输出是否变换)
/* 注:修改和编译得到的exe文件处在同一文件夹下的config.json,不要修改源码下的config.json(程序读取的不是这个config.json) */
for (int i = 0; i < 3; i++)
{
// 创建Scope(每次循环一个Scope,防止上次循环的环境干扰)
using var scope = serviceProvider.CreateScope();
var serviceProviderOfScope = scope.ServiceProvider;
// 从serviceProvider获取对应的服务,这一服务只在当前scope范围内生效
var demo = serviceProviderOfScope.GetRequiredService<Demo>();
demo.Test();
Console.WriteLine("可以改配置了");
Console.ReadKey();
}
}
// args从Main函数传递
public static void ReadConfigurationFromCommandLine(string[] args)
{
if (args.Length == 0)
{
Console.WriteLine($"{nameof(args)} is null. 没有从控制台接收额外的配置参数");
return;
}
// 显示从控制台传入的所有参数
for (var index = 0; index < args.Length; index++)
{
var arg = args[index];
Console.WriteLine($"ArgumentFromConsole: No.{index}, Content: {arg}");
}
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddCommandLine(args);
var config = configurationBuilder.Build();
var serverAddress = config["serverAddress"];
Console.WriteLine($"serverAddress:{serverAddress ?? "null"}");
}
public static void ReadConfigurationFromEnvironmentVariables()
{
var configBuilder = new ConfigurationBuilder();
// 读取所有环境变量(不推荐,环境变量太多,全部读入浪费资源,并且容易和其他程序的环境变量冲突)
//configBuilder.AddEnvironmentVariables();
// 读取前缀为 PROCESSOR_ 的环境变量
configBuilder.AddEnvironmentVariables("PROCESSOR_");
IConfigurationRoot config = configBuilder.Build();
// 获取环境变量PROCESSOR_IDENTIFIER
// 注意:去除前缀,例如:PROCESSOR_IDENTIFIER去除前缀PROCESSOR_后变为IDENTIFIER
var processorIdentifier = config["IDENTIFIER"];
Console.WriteLine($"{nameof(processorIdentifier)}: {processorIdentifier ?? "null"}");
}
// 模型类
public class DataBaseSettings
{
public string? DataBaseType { get; set; }
public string? ConnectionString { get; set; }
}
public class SmtpSettings
{
public string? Server { get; set; }
public string? UserName { get; set; }
public string? Password { get; set; }
}
// 用于测试读取配置的Demo类
public class Demo
{
/* 类似的接口有:
* 1. IOption<T>: 在配置改变后无法自动读取新值,除非重启程序
* 2. IOptionMonitor<T>: 配置改变后,新值会立即生效,可能会造成数据不一致,
* 例如:A、B先后读取某个配置,配置在A执行后改变,A使用了旧值,B使用了新值
* 3. IOptionSnapshot<T>: 配置改变后,新值会在下次进入这个范围时生效,
* 例如:A、B先后读取某个配置,配置在A执行后改变,A使用了旧值,B也使用了旧值,但下次A、B再读取配置时,读取的都是新值,保证数据的一致性
*/
/* 综上,这三个接口优先使用IOptionSnapshot<T>接口 */
private readonly IOptionsSnapshot<DataBaseSettings> _optionDataBaseSettings;
private readonly IOptionsSnapshot<SmtpSettings> _optionSmtpSettings;
public Demo(IOptionsSnapshot<DataBaseSettings> optionDataBaseSettings,
IOptionsSnapshot<SmtpSettings> optionSmtpSettings)
{
_optionDataBaseSettings = optionDataBaseSettings;
_optionSmtpSettings = optionSmtpSettings;
}
public void Test()
{
var db = _optionDataBaseSettings.Value;
var smtp = _optionSmtpSettings.Value;
Console.WriteLine($"Database: {db.DataBaseType ?? "null"}, {db.ConnectionString ?? "null"}");
Console.WriteLine($"Smtp: {smtp.Server ?? "null"}, {smtp.UserName ?? "null"}, {smtp.Password ?? "null"}");
}
}
}

项目文件(LearnConfigurationSystem.csproj)

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 程序在运行时默认加载EXE文件同文件夹下的配置文件,而不是项目中的config.json文件。
所以需要设置这一属性,在生成项目时自动将config.json文件复制到生成目录。
-->
<None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- 读取配置依赖的安装包,其中:
Microsoft.Extensions.Configuration是基础包,
Microsoft.Extensions.Configuration.Json用于读取Json配置
Microsoft.Extensions.Configuration.CommandLine用于从命令行读取配置
Microsoft.Extensions.Configuration.EnvironmentVariables用于从环境变量读取配置
Microsoft.Extensions.Options用于映射配置项,它可以辅助处理容器生命周期、配置刷新等。
Microsoft.Extensions.DependencyInjection用于依赖注入,配合Microsoft.Extensions.Options使用
Microsoft.Extensions.Configuration.Binder用于将配置项与配置类绑定(映射)
-->
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
</ItemGroup>
</Project>

从数据库读取配置

说明

  1. 依据Zack.AnyDBConfigProvider项目,按照自己的编码习惯重新构建。
  2. 该项目可从数据库中读取配置信息

重点内容

  1. 任何自定义的ConfigurationProvider都要实现IConfigurationProvider接口,由于.NET中的抽象类ConfigurationProvider已经实现了IConfigurationProvider接口,所以一般的做法是继承ConfigurationProvider,然后override抽象类ConfigurationProvider中的方法。
  2. Load方法用于加载配置数据,加载的数据按照键值对的形式保存到Data属性中。
  3. Data属性是IDictionary<string,string>类型,其中键为配置的名字。
  4. 如果配置项发生变化,则需要调用OnReload方法通知订阅者配置项发生改变。

DbConfigurationProvider类的构造方法

  1. 如果启用了ReloadOnChange选项,那么将一个委托方法送入队列,等到线程池有线程可用时执行该委托方法(ThreadPool.QueueUserWorkItem)。
  2. 该委托方法在方法体内每间隔一段时间(通过Thread.Sleep实现)执行一次Load方法,直到DbConfigurationProvider类的实例被释放。

Load方法

  1. Load方法首先创建了一个Data属性的副本clonedData,用于稍后比较数据是否修改了。
  2. 读取配置的代码最终会调用TryGet方法读取配置,为了避免TryGet读取到Load加载一半的数据,使用读写锁控制读写同步。
  3. 由于读的频率高于写的频率,为了避免使用普通的锁造成性能问题,这里使用ReaderWriteLockSlim类(.NET自带)实现“只允许一个线程写入,允许多个线程读取”。
  4. 为了实现3的写锁,需要把“将配置项写入Data属性“的代码放到EnterWriteLock和ExitWriteLock之间。
  5. 同时一定要把OnReload方法放到ExitWriteLock之后。这是因为OnReload方法中调用了TryGet方法,TryGet方法中有读锁,写锁中嵌套读锁是不被允许的
  6. Load中调用的DoLoad方法从数据库中读取配置,然后将数据加载到Data属性中。DoLoad方法遵循”多层级数据扁平化规则“来解析和加载数据。
  7. 在6之后调用DataIsChanged方法将旧数据和从数据库中读取的新数据比较,如果发现数据有变化就返回true,否则返回false。
  8. 如果7中的DataIsChanged方法返回true就调用OnReload方法向订阅者通知数据的变化。

代码实现

ConfigurationBuilderInterfaceExtension.cs

using System.Data;
using Microsoft.Extensions.Configuration;
namespace ReadConfigurationsFromDatabase;
// 扩展IConfigurationBuilder接口,提供AddDbConfiguration方法
public static class ConfigurationBuilderInterfaceExtension
{
public static IConfigurationBuilder AddDbConfiguration(this IConfigurationBuilder builder,
DbConfigOptions options) => builder.Add(new DbConfigurationSource(options));
public static IConfigurationBuilder AddDbConfiguration(this IConfigurationBuilder builder,
Func<IDbConnection> createDbConnection, string tableName = "T_DbConfigurations", bool reloadOnChange = false,
TimeSpan? reloadInterval = null)
{
return AddDbConfiguration(builder, new DbConfigOptions(createDbConnection)
{
TableName = tableName,
ReloadOnChange = reloadOnChange,
ReloadInterval = reloadInterval
});
}
}

DbConfigOptions.cs

using System.Data;
namespace ReadConfigurationsFromDatabase;
public sealed class DbConfigOptions
{
public DbConfigOptions(Func<IDbConnection> createDbConnection)
{
CreateDbConnection = createDbConnection;
}
// Func委托不能为空,又没有默认值,所以必须使用构造函数初始化
public Func<IDbConnection> CreateDbConnection { get; }
public string TableName { get; init; } = "T_DbConfigurations";
public bool ReloadOnChange { get; init; } = false;
public TimeSpan? ReloadInterval { get; init; }
}

DbConfigurationProvider.cs

using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
namespace ReadConfigurationsFromDatabase;
public class DbConfigurationProvider : ConfigurationProvider,
IDisposable, IAsyncDisposable
{
private readonly DbConfigOptions _dbConfigOptions;
// 默认值为false,可以不用显式赋值
private bool _isDisposed = false;
// 读写锁
private readonly ReaderWriterLockSlim _lockObj = new();
# region Constructor and DisposePattern
public DbConfigurationProvider(DbConfigOptions dbConfigOptions)
{
_dbConfigOptions = dbConfigOptions;
// 如果option中没有设置“在配置改变时重新加载,那么直接返回”
if (!_dbConfigOptions.ReloadOnChange) return;
// 默认reload的时间间隔
var interval = TimeSpan.FromSeconds(3);
// 如果option设置了reload的时间间隔,那么就应用这一间隔
if (_dbConfigOptions.ReloadInterval != null) interval = _dbConfigOptions.ReloadInterval.Value;
// 将委托扔进线程池队列,线程池有空闲线程就执行
ThreadPool.QueueUserWorkItem(_ =>
{
// 如果资源被回收了,直接返回
if (_isDisposed) return;
// 通过线程休眠的方式定时加载
Load();
Thread.Sleep(interval);
});
}
public void Dispose()
{
if (_isDisposed) return;
_lockObj.Dispose();
_isDisposed = true;
GC.SuppressFinalize(this);
}
public async ValueTask DisposeAsync()
{
if (_isDisposed) return;
await Task.Run(Dispose);
GC.SuppressFinalize(this);
}
~DbConfigurationProvider()
{
Dispose();
}
#endregion Constructor and DisposePattern
# region override Methods in ConfigurationProvider
public override IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string? parentPath)
{
_lockObj.EnterReadLock();
try
{
return base.GetChildKeys(earlierKeys, parentPath);
}
finally
{
_lockObj.ExitReadLock();
}
}
public override bool TryGet(string key, out string? value)
{
_lockObj.EnterReadLock();
try
{
return base.TryGet(key, out value);
}
finally
{
_lockObj.ExitReadLock();
}
}
public override void Load()
{
_lockObj.EnterWriteLock();
var tableName = _dbConfigOptions.TableName;
IDictionary<string, string?> clonedData = Data.Clone();
Data.Clear();
try
{
using var dbConnection = _dbConfigOptions.CreateDbConnection.Invoke();
dbConnection.Open();
DoLoad(tableName, dbConnection);
}
catch (DbException)
{
Data = clonedData;
throw;
}
finally
{
_lockObj.ExitWriteLock();
}
// 如果数据改变,则发出通知
if (DataIsChanged(clonedData, Data)) OnReload();
}
private static bool DataIsChanged(IDictionary<string, string?> oldData, IDictionary<string, string?> newData)
{
if (ReferenceEquals(oldData, newData) || oldData.Count != newData.Count) return true;
foreach (var (oldKey, oldValue) in oldData)
{
if (!newData.ContainsKey(oldKey)) return true;
if (newData[oldKey] != oldData[oldKey]) return true;
}
return false;
}
private void DoLoad(string tableName, IDbConnection dbConnection)
{
using var dbCommand = dbConnection.CreateCommand();
dbCommand.CommandText =
// 子查询的作用是通过Id筛选最新的配置信息
$"select name,value from {tableName} where id in (select MAX(id) from {tableName} group by name)";
using var dbReader = dbCommand.ExecuteReader();
while (dbReader.Read())
{
// 索引对应的列和查询语句(dbCommand.CommandText)有关,这里第一列是"名称",第二列是"值"
var name = dbReader.GetString(0);
var value = dbReader.GetString(1);
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (value is null)
{
Data[name] = value;
continue;
}
// 去除多余空格
value = value.Trim();
// 处理value是json的情况
if (value.StartsWith("[") && value.EndsWith("]") || value.StartsWith("{") && value.EndsWith("}"))
{
TryLoadAsJson(name, value);
continue;
}
Data[name] = value;
}
}
private void TryLoadAsJson(string name, string value)
{
var jsonOptions = new JsonDocumentOptions
{
// 允许json列表或数组末尾存在额外逗号(json默认的行为是不能存在逗号)
AllowTrailingCommas = true,
// 允许json存在注释并跳过这些注释不做任何处理(json的默认行为是不允许注释)
CommentHandling = JsonCommentHandling.Skip
};
try
{
// 将字符串的值解析为JsonDocument,并获取JsonDocument的根元素
var jsonRoot = JsonDocument.Parse(value, jsonOptions).RootElement;
if (jsonRoot.ValueKind is not (JsonValueKind.Array or JsonValueKind.Object))
{
Data[name] = GetValueForConfig(jsonRoot);
return;
}
var traceStack = new Stack<KeyValuePair<string, JsonElement>>();
traceStack.Push(new KeyValuePair<string, JsonElement>(name, jsonRoot));
while (traceStack.Count > 0) LoadJsonElement(traceStack);
}
catch (JsonException e)
{
// 如果不能转换为json字符串,将该字符串当作原始字符串对待
Data[name] = value;
Debug.WriteLine($"将{value}转换为json时出现异常,异常信息:{e}");
}
}
private void LoadJsonElement(Stack<KeyValuePair<string, JsonElement>> traceStack)
{
var (name, jsonRoot) = traceStack.Pop();
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (jsonRoot.ValueKind)
{
case JsonValueKind.Array:
{
int index = 0;
foreach (var item in jsonRoot.EnumerateArray())
{
string path = name + ConfigurationPath.KeyDelimiter + index;
traceStack.Push(new KeyValuePair<string, JsonElement>(path, item));
index++;
}
break;
}
case JsonValueKind.Object:
{
foreach (var jsonObj in jsonRoot.EnumerateObject())
{
string pathOfObj = name + ConfigurationPath.KeyDelimiter + jsonObj.Name;
traceStack.Push(new KeyValuePair<string, JsonElement>(pathOfObj, jsonObj.Value));
}
break;
}
default:
Data[name] = GetValueForConfig(jsonRoot);
break;
}
}
private static string? GetValueForConfig(JsonElement jsonRoot) => jsonRoot.ValueKind switch
{
JsonValueKind.String =>
//remove the quotes, "ab"-->ab
jsonRoot.GetString(),
JsonValueKind.Null =>
//remove the quotes, "null"-->null
null,
JsonValueKind.Undefined =>
//remove the quotes, "null"-->null
null,
_ => jsonRoot.GetRawText()
};
#endregion override Methods in ConfigurationProvider
}

DbConfigurationSource.cs

using Microsoft.Extensions.Configuration;
namespace ReadConfigurationsFromDatabase;
// 声明如何创建实现了IConfigurationProvider接口的对象DbConfigurationProvider
// DbConfigurationSource类似于:IConfigurationProvider系列产品中DbConfigurationProvider产品的生产说明
public sealed class DbConfigurationSource : IConfigurationSource
{
private readonly DbConfigOptions _dbConfigOptions;
public DbConfigurationSource(DbConfigOptions dbConfigOptions) => _dbConfigOptions = dbConfigOptions;
// 这里引入IConfigurationBuilder类型的形参builder的作用可类比于:生产本产品需要其他工厂生产出来的其他产品。
// 不过这里没有用到其他产品,所以没有正在使用形参builder(和产品生产线一样,要预留些可拓展部件,方便产线的升级改造)
public IConfigurationProvider Build(IConfigurationBuilder builder) => new DbConfigurationProvider(_dbConfigOptions);
}

DictionaryInterfaceExtension.cs

namespace ReadConfigurationsFromDatabase;
public static class DictionaryInterfaceExtension
{
public static IDictionary<string, string?> Clone(this IDictionary<string, string?> dictionary) =>
dictionary.ToDictionary(item => item.Key, item => item.Value);
}

测试代码如下:

using Microsoft.Extensions.Configuration;
using MySql.Data.MySqlClient;
using ReadConfigurationsFromDatabase;
using Xunit.Abstractions;
namespace ConfigurationSystem.Test;
public class ReadConfigurationsFromDatabaseTest
{
private const string ConnectionString =
"Data Source=localhost;Database=Test;User ID=root;Password=Aa123456+;pooling=true";
private readonly ITestOutputHelper _output;
public ReadConfigurationsFromDatabaseTest(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void DbConnectionTest()
{
using var connection = new MySqlConnection(ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "select * from Test.config";
using var dbReader = command.ExecuteReader();
while (dbReader.Read())
{
var name = dbReader.GetString(1);
var value = dbReader.GetString(2);
_output.WriteLine($"{name ?? "null"}: {value ?? "null"}");
}
connection.Close();
}
[Fact]
public void ReadConfigFromDb()
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddDbConfiguration(() =>
{
var connection = new MySqlConnection(ConnectionString);
return connection;
}, tableName: "config", reloadOnChange: false);
var config = configurationBuilder.Build();
var userName = config["userName"];
Assert.Equal("dbUserName", userName ?? "null");
var connectionString = config["databaseConfig:connectionString"];
Assert.Equal("mysqlConnectionString", connectionString ?? "null");
}
}

多配置源问题

.NET Core中的配置系统支持“可覆盖的配置”,可以向ConfigurationBuilder中注册多个配置提供程序,后添加的配置提供程序可以覆盖先添加的配置提供程序

现在从多个配置源读取配置,配置顺序如下:

添加顺序 配置来源(右边的列是配置内容) server userName password port
1 数据库 smtpFromDb.example.com dbUserName 80
2 JSON文件 smtp.example.com userNameFromJson passwordFromJson
3 命令行 passwordFromFromCommandLine

按照顺序读取配置后,各个配置项的实际值如下(后添加的会覆盖先添加的):

  • server=smtp.example.com
  • userName=userNameFromJson
  • password=passwordFromFromCommandLine
  • port=80

代码如下:

using Microsoft.Extensions.Configuration;
using MySql.Data.MySqlClient;
using ReadConfigurationsFromDatabase;
namespace GetConfigUsingMultipleWaysSimultaneously;
public static class ReadConfigFromMultipleSource
{
private const string ConnectionString =
"Data Source=localhost;Database=Test;User ID=root;Password=Aa123456+;pooling=true";
public static IConfigurationRoot GetConfigurationRoot(string[] args)
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder
// 配置来源:数据库
.AddDbConfiguration(() =>
{
var connection = new MySqlConnection(ConnectionString);
return connection;
}, tableName: "config", reloadOnChange: false)
// 配置来源Json
.AddJsonFile("config.json")
// 配置来源CommandLine
.AddCommandLine(args);
return configurationBuilder.Build();
}
}

其中,config.json内容如下:

{
"server": "smtp.example.com",
"userName": "userNameFromJson",
"password": "passwordFromJson"
}

测试代码如下:

using GetConfigUsingMultipleWaysSimultaneously;
namespace ConfigurationSystem.Test;
public class GetConfigUsingMultipleWaysSimultaneouslyTest
{
[Fact]
public void Test()
{
string[] args = new[] { "password=passwordFromFromCommandLine" };
var configRoot = ReadConfigFromMultipleSource.GetConfigurationRoot(args);
var server = configRoot["server"] ?? "null";
var userName = configRoot["userName"] ?? "null";
var password = configRoot["password"] ?? "null";
var port = configRoot["port"] ?? "null";
Assert.Equal("smtp.example.com", server);
Assert.Equal("userNameFromJson", userName);
Assert.Equal("passwordFromFromCommandLine", password);
Assert.Equal("80", port);
}
}
posted @   random_d  阅读(21)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端
点击右上角即可分享
微信分享提示