C#使用SourceGenerator写一个简单的AOP

前言:
近期在学习微软基于dotnet5以上的SourceGenerator,使用该工具制作了一个比较简易的AOP工具,如有不正确的地方请大家斧正,谢谢

关于SourceGenerator

本文主要还是讲写代码的过程,历史渊源和其他基本使用可以参考其他博客,直接搜索SourceGenerator,相当多的内容,我也是学习前人的分享

几点注意事项

1、项目的*.csproj 文件内容建议:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<LangVersion>preview</LangVersion>
		<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
		<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
		<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
		<PackageTags>Analyzer</PackageTags>
		<IncludeBuildOutput>false</IncludeBuildOutput>
	</PropertyGroup>

    <ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" PrivateAssets="all" />
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
			<PrivateAssets>all</PrivateAssets>
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
		</PackageReference>
		<!--因为我自己的其他生成器使用了Newtonsoft.Json-->
		<PackageReference Include="Newtonsoft.Json" Version="13.0.2" GeneratePathProperty="true" PrivateAssets="all" />
	</ItemGroup>

    <PropertyGroup>
		<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
	</PropertyGroup>

    <!--要避免引用该生成器项目的项目出现引用Newtonsoft.Json失败的问题,需在此处写上这个引用添加-->
    <Target Name="GetDependencyTargetPaths">
		<ItemGroup>
			<TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
		</ItemGroup>
	</Target>
</Project>

2、如果还要生成NuGet包的话

<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<LangVersion>preview</LangVersion>
		<Authors>作者姓名</Authors>
		<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
		<Version>1.0.1</Version>
		<Deterministic>false</Deterministic>
		<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
		<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
		<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
		<Description>nuget包描述</Description>
		<PackageTags>Analyzer</PackageTags>
		<IncludeBuildOutput>false</IncludeBuildOutput>
		<PackageIcon>包图标.ioc</PackageIcon>
		<!--<PackageLicenseExpression>MIT</PackageLicenseExpression>-->
		<PackageLicenseFile>LICENSE</PackageLicenseFile>
		<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.1.0" PrivateAssets="all" />
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
			<PrivateAssets>all</PrivateAssets>
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
		</PackageReference>
		<PackageReference Include="Newtonsoft.Json" Version="13.0.2" GeneratePathProperty="true" PrivateAssets="all" />
	</ItemGroup>

	<PropertyGroup>
		<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
	</PropertyGroup>

	<Target Name="GetDependencyTargetPaths">
		<ItemGroup>
		    <!--如有其他引用的包,也需要按照这样的格式-->
			<TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
		</ItemGroup>
	</Target>

    <!--最重要的-->
	<ItemGroup>
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/donet/cs" Visible="false" />
	</ItemGroup>

进入正文

1.准备工作

类名介绍
AopMethod用于储存方法、执行类、返回值、异常信息等,也可根据这个自行扩展
AopBaseAttributeAOP特性基类(派生特性命名需以AOP结尾)
GeneratorAopProxyAttribute标记在partial类上,用于生成存储当前类中的代理方法

1.1 文本格式的3个类内容,用于直接生成,方便调用

1.1.1 AopMethod.txt
// <auto-generated/>
#pragma warning disable
#nullable enable


using System;
using System.Reflection;

namespace {你自定义一个命名空间就行,最好3个生成的文件处于同一个命名空间}
{

    /// <summary>
    /// Aop传递方法类
    /// </summary>
    public class AopMethod
    {
        public AopMethod(object _TargetClass,MethodInfo method,params object[] para)
        {
            TargetClass=_TargetClass;
            MethodTarget=method;
            Paramters=para;
        }

        /// <summary>
        /// 使用方法的类
        /// </summary>
        public object TargetClass {get; private set;}

        /// <summary>
        /// 方法主体
        /// </summary>
        public MethodInfo MethodTarget {get; private set;}

        /// <summary>
        /// 方法的参数
        /// </summary>
        public object[] Paramters {get; private set;}
    
        /// <summary>
        /// 方法的返回值
        /// </summary>
        public object ReturnValue {get; private set;}

        /// <summary>
        /// 执行方法
        /// </summary>
        public void Excute()
        {
            ReturnValue=MethodTarget.Invoke(TargetClass, Paramters);
        }
    
    }

}
1.1.2 AopBaseAttribute.txt
// <auto-generated/>
#pragma warning disable
#nullable enable

using System;
using System.Reflection;

namespace {你自定义一个命名空间就行,最好3个生成的文件处于同一个命名空间}
{
    /// <summary>
    /// AOP特性基类(派生特性命名需以AOP结尾)
    /// </summary>
    [AttributeUsage(AttributeTargets.Method)]
    public abstract class AopBaseAttribute : Attribute
    {
        /// <summary>
        /// 执行位置
        /// </summary>
        public InvokeLocation Location { set; get; } = InvokeLocation.Both;

        /// <summary>
        /// 方法执行前
        /// </summary>
        public abstract void ExcuteBefore();

        /// <summary>
        /// 方法执行中
        /// </summary>
        public abstract void ExcuteMiddle(AopMethod method);

        /// <summary>
        /// 方法执行后
        /// </summary>
        public abstract void ExcuteAfter(AopMethod method,Exception ex);
    }

    /// <summary>
    /// 执行位置枚举
    /// </summary>
    public enum InvokeLocation
    {
        /// <summary>
        /// 中间
        /// </summary>
        Both = 0,
        /// <summary>
        /// 执行前
        /// </summary>
        Before = 1,
        /// <summary>
        /// 执行后
        /// </summary>
        After = 2
    }
}
1.1.3 GeneratorAopProxyAttribute.txt
// <auto-generated/>
#pragma warning disable
#nullable enable

using System;

namespace {你自定义一个命名空间就行,最好3个生成的文件处于同一个命名空间}
{
    /// <summary>
    /// 标记之后会生成一个Proxy代理类,配合AopBase的派生特性实现AOP功能
    /// </summary>
    [AttributeUsage( AttributeTargets.Class,Inherited=true,AllowMultiple=false)]
    public class GeneratorAopProxyAttribute:Attribute
    { 
    }
}

1.2 再准备2个帮助类(从前人那学习缝合来的)

1.2.1 Utils
/// <summary>
/// 工具类
/// </summary>
public static class Utils
{
    #region 代码规范风格化
    /// <summary>
    /// 转换为Pascal风格-每一个单词的首字母大写
    /// </summary>
    /// <param name="fieldName">字段名</param>
    /// <param name="fieldDelimiter">分隔符</param>
    /// <returns></returns>
    public static string ConvertToPascal(string fieldName, string fieldDelimiter)
    {
        string result = string.Empty;
        if (fieldName.Contains(fieldDelimiter))
        {
            //全部小写
            string[] array = fieldName.ToLower().Split(fieldDelimiter.ToCharArray());
            foreach (var t in array)
            {
                //首字母大写
                result += t.Substring(0, 1).ToUpper() + t.Substring(1);
            }
        }
        else if (string.IsNullOrWhiteSpace(fieldName))
        {
            result = fieldName;
        }
        else if (fieldName.Length == 1)
        {
            result = fieldName.ToUpper();
        }
        else
        {
            result = fieldName.Substring(0, 1).ToUpper() + fieldName.Substring(1);
        }
        return result;
    }
    /// <summary>
    /// 转换为Camel风格-第一个单词小写,其后每个单词首字母大写
    /// </summary>
    /// <param name="fieldName">字段名</param>
    /// <param name="fieldDelimiter">分隔符</param>
    /// <returns></returns>
    public static string ConvertToCamel(string fieldName, string fieldDelimiter)
    {
        //先Pascal
        string result = ConvertToPascal(fieldName, fieldDelimiter);
        //然后首字母小写
        if (result.Length == 1)
        {
            result = result.ToLower();
        }
        else
        {
            result = result.Substring(0, 1).ToLower() + result.Substring(1);
        }

        return result;
    }
    #endregion

    /// <summary>
    /// 获取模版文件并转成string
    /// </summary>
    /// <param name="FileName">文件名称</param>
    /// <returns>string</returns>
    internal static string GetTemplateByFileName(string FileRelativePath)
    {
        Assembly assembly = Assembly.GetExecutingAssembly();

        string[] resNames = assembly.GetManifestResourceNames();
        using (Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.Template.{FileRelativePath}"))
        {
            if (stream != null)
            {
                using (StreamReader sr = new StreamReader(stream))
                {
                    string context = sr.ReadToEnd();

                    return context;
                }
            }
            else
            {
                return "";
            }

        }
    }

    internal static string GetContentInParentheses(string value)
    {
        var match = Regex.Match(value, @"\(([^)]*)\)");
        return match.Groups[1].Value;
    }
}
1.2.2 SyntaxUtils
public class SyntaxUtils
{
    public static bool HasModifier(MemberDeclarationSyntax syntax, params SyntaxKind[] modifiers)
    {
        return syntax.Modifiers.Any(m => modifiers.Contains(m.Kind()));
    }


    public static bool HasModifiers(MemberDeclarationSyntax syntax, params SyntaxKind[] modifiers)
    {
        var syntaxKinds = syntax.Modifiers.Select(n => n.Kind()).ToList();
        return modifiers.All(m => syntaxKinds.Contains(m));
    }

    public static string GetName(SyntaxNode syntaxNode)
    {
        switch (syntaxNode)
        {
            case BaseTypeDeclarationSyntax baseTypeDeclarationSyntax:
                return baseTypeDeclarationSyntax.Identifier.Text;
            case BaseNamespaceDeclarationSyntax baseNamespaceDeclarationSyntax:
                return baseNamespaceDeclarationSyntax.Name.ToString();
            case VariableDeclaratorSyntax variableDeclaratorSyntax:
                return variableDeclaratorSyntax.Identifier.Text;
            case NameEqualsSyntax nameEqualsSyntax:
                return nameEqualsSyntax.Name.Identifier.Text;
            default:
                throw new NotImplementedException();
        }
    }

    public static bool HasAttribute(MemberDeclarationSyntax classDeclaration, Func<string, bool> func)
    {
        return GetAttribute(classDeclaration, func) != null;
    }

    public static bool HasAttribute(MemberDeclarationSyntax classDeclaration, string AttributeName)
    {
        var dto = GetAttribute(classDeclaration, AttributeName);

        return dto != null;
    }

    public static AttributeSyntax GetAttribute(MemberDeclarationSyntax classDeclaration, Func<string, bool> func)
    {
        return classDeclaration.AttributeLists.SelectMany(m => m.Attributes)
            .FirstOrDefault(m => func(m.Name.ToString()));
    }

    public static AttributeSyntax GetAttribute(MemberDeclarationSyntax classDeclaration, string AttributeName)
    {
        var attributes = classDeclaration.AttributeLists.SelectMany(m => m.Attributes);

        var attr= attributes.FirstOrDefault(m =>
        {
            var nameSyntax = m.Name as IdentifierNameSyntax;
            if (nameSyntax.Identifier.Text.Equals(AttributeName))
            {
                return true;
            }
            return false;
        });

        return attr;
    }

    /// <summary>
    /// 检查是否包含AOP特性
    /// </summary>
    /// <param name="classDeclaration"></param>
    /// <returns></returns>
    public static bool HasAopAttribute(MemberDeclarationSyntax classDeclaration,out AttributeSyntax attribute)
    {
        attribute = GetAopAttribute(classDeclaration);

        return attribute != null;
    }

    /// <summary>
    /// 获取已AOP结尾的特性
    /// </summary>
    /// <param name="classDeclaration"></param>
    /// <returns></returns>
    public static AttributeSyntax GetAopAttribute(MemberDeclarationSyntax classDeclaration)
    {
        var attributes= classDeclaration.AttributeLists.SelectMany(m => m.Attributes);

        var attr = attributes.FirstOrDefault(m =>
        {
            var nameSyntax = m.Name as IdentifierNameSyntax;
            if (nameSyntax.Identifier.ValueText.EndsWith("AOP"))
            {
                return true;
            }
            return false;
        });

        return attr;
    }

    public static List<string> GetUsings(ClassDeclarationSyntax classDeclarationSyntax)
    {
        var compilationUnitSyntax = classDeclarationSyntax.SyntaxTree.GetRoot() as CompilationUnitSyntax;
        var usings = compilationUnitSyntax.Usings.Select(m => m.ToString()).ToList();
        return usings;
    }
}

2.写生成器的代码

2.1 先写一个SyntaxReceiver,筛选出我们需要的内容

public class AopProxySyntaxReceiver : ISyntaxReceiver
{
    //存储筛选出来标记了GeneratorAopProxyAttribute特性的类
    public List<AttributeSyntax> CandidateClasses { get; } = new List<AttributeSyntax>();
    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is AttributeSyntax cds && cds.Name is IdentifierNameSyntax identifierName && identifierName.Identifier.ValueText == "GeneratorAopProxy")
        {
            CandidateClasses.Add(cds);
        }
    }
}

2.2 再写生成器代码


[Generator]//必须标记该特性,并实现该接口
public partial class AopProxyGenerator : ISourceGenerator
{
    //初始化注册刚才的接收器
    public void Initialize(GeneratorInitializationContext context)
    {
#if DEBUG
        //Debugger.Launch(); 
        //使用这个方式,在编译引用该生成器的项目的时候,会进入断点,查看我们写的生成器运行情况
#endif

        context.RegisterForSyntaxNotifications(() => new AopProxySyntaxReceiver());
    }

    public void Execute(GeneratorExecutionContext context)
    {
        var syntaxReceiver = (AopProxySyntaxReceiver)context.SyntaxReceiver;
        var attributeSyntaxList = syntaxReceiver.CandidateClasses;

        if (attributeSyntaxList.Count == 0)
        {
            return;
        }
        
        List<string> ClassName = new List<string>();
        
        foreach (AttributeSyntax attributeSyntax in attributeSyntaxList)
        {
            // 找到class,并且判断一下是否有parital字段
            var classDeclarationSyntax = attributeSyntax.FirstAncestorOrSelf<ClassDeclarationSyntax>();
            if (classDeclarationSyntax == null || !classDeclarationSyntax.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
            {
                continue;
            }

            //判断是否已经处理过这个class了
            if (ClassName.Contains(classDeclarationSyntax.Identifier.ValueText))
            {
                continue;
            }

            // 找到namespace
            var namespaceDeclarationSyntax = classDeclarationSyntax.FirstAncestorOrSelf<BaseNamespaceDeclarationSyntax>();
            
            // 找到methods
            var methodDeclarationList = classDeclarationSyntax.Members.OfType<MethodDeclarationSyntax>().ToList();
            if (methodDeclarationList.Count == 0)
            {
                continue;
            }

            StringBuilder sb = new StringBuilder();

            sb.AppendLine("using {引用你上文生成3个文件的那个命名空间};");
            sb.AppendLine("using System.Reflection;");
            sb.AppendLine();

            sb.AppendLine($"namespace {namespaceDeclarationSyntax.Name}");
            sb.AppendLine("{");

            sb.AppendLine($"    public partial class {classDeclarationSyntax.Identifier.Text}");
            sb.AppendLine("    {");

            foreach (MethodDeclarationSyntax methodDeclaration in methodDeclarationList)
            {
                //判断一下是否包含AOP结尾的特性
                if (!SyntaxUtils.HasAopAttribute(methodDeclaration, out AttributeSyntax AopAttribute))
                {
                    continue;
                }

                var AopAttributeName = (AopAttribute.Name as IdentifierNameSyntax).Identifier.ValueText;

                //获取方法的返回值字符串
                var ReturnTypeStr = (methodDeclaration.ReturnType as PredefinedTypeSyntax).Keyword.ValueText;

                //获取方法内的参数
                var ParameterList = methodDeclaration.ParameterList.Parameters;

                //var asd= ParameterList.Select(s => $"{s.ToFullString().Split(' ')[1]}").ToList();

                sb.AppendLine("/// <summary>");
                sb.AppendLine($"/// {methodDeclaration.Identifier.Text}的AOP代理方法");
                sb.AppendLine($"/// <para>多个AOP特性均会执行</para>");
                sb.AppendLine("/// </summary>");
                sb.AppendLine($"public {ReturnTypeStr} {methodDeclaration.Identifier.Text}_Proxy ({string.Join(",", ParameterList.Select(s => $"{s.ToFullString()}"))})");
                sb.AppendLine("{");
                
                if (ReturnTypeStr != "void")
                {
                    sb.AppendLine($"return ({ReturnTypeStr})ExcuteProxy(\"{ methodDeclaration.Identifier.Text}\",{string.Join(",", ParameterList.Select(s => $"{s.ToFullString().Split(' ')[1]}"))});");
                }
                else
                {
                    sb.AppendLine($"ExcuteProxy(\"{methodDeclaration.Identifier.Text}\",{string.Join(",", ParameterList.Select(s => $"{s.ToFullString().Split(' ')[1]}"))});");
                }

                sb.AppendLine("}");
            }

            sb.AppendLine("/// <summary>");
            sb.AppendLine("/// 执行代理方法");
            sb.AppendLine("/// </summary>");
            sb.AppendLine($@"


private object ExcuteProxy(string MethodName,params object[] args)
    {{
        object result = null;

        var method = this.GetType().GetMethod(MethodName);
        object[]? attrs = method.GetCustomAttributes(true);

        foreach (var item in attrs)
        {{
            if (item is AopBaseAttribute aop)
            {{
                AopMethod aopMethod = new AopMethod(this, method, args);
                Exception ex = null;
                try
                {{
                    if (aop.Location == InvokeLocation.Before)
                    {{
                        aop.ExcuteBefore();
                    }}

                    if (aop.Location == InvokeLocation.Both)
                    {{
                        aop.ExcuteMiddle(aopMethod);
                    }}
                }}
                catch (Exception exx)
                {{
                    ex=exx;
                }}

                if (aopMethod.ReturnValue is Task)
                {{
                    (aopMethod.ReturnValue as Task).ContinueWith((Task t) =>
                    {{
                        aop.ExcuteAfter(aopMethod, t.Exception);
                    }});
                }}
                else
                {{
                    aop.ExcuteAfter(aopMethod, ex);
                }}

                result = aopMethod.ReturnValue;
            }}
        }}

        return result;
    }}
");

            sb.AppendLine("    }");
            sb.AppendLine("}");
            
            //使用这个方式格式化代码,免得生成的格式太难看    
            string extensionTextFormatted = CSharpSyntaxTree.ParseText(sb.ToString(), new CSharpParseOptions(LanguageVersion.CSharp8)).GetRoot().NormalizeWhitespace().SyntaxTree.GetText().ToString();
            // 添加到源代码,这样IDE才能感知
            context.AddSource($"{classDeclarationSyntax.Identifier}.g.cs", SourceText.From(extensionTextFormatted, Encoding.UTF8));
            // 保存一下类名,避免重复生成
            ClassName.Add(classDeclarationSyntax.Identifier.ValueText);
        }
    }
}

3. 如何使用

3.1 2种引用方式

3.1.1 直接工程内项目间引用

需在引用的那个项目的csproj文件中,添加:ReferenceOutputAssembly=“false” OutputItemType=“Analyzer” ,这样才会出现在分析器中

3.1.2 使用NuGet包引用,就没这么麻烦,不需要添加上面的

3.2 开始使用

3.2.1 新建一个特性,继承AopBaseAttribute,命名需XXAOPAttribute
public class testAOPAttribute : AopBaseAttribute
{
    public override void ExcuteAfter(AopMethod method, Exception ex)
    {
    }

    public override void ExcuteBefore()
    {
    }

    public override void ExcuteMiddle(AopMethod method)
    {
        try
        {
            method.Excute();
            throw new Exception("test");
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
}
3.2.2 新建一个业务类
[GeneratorAopProxy]
public partial class TestViewModel
{
    public TestViewModel()
    {
        string result = haha_Proxy("11");
    }

    //标记了这个特性之后,SourceGenertor会自动在对应的partial类中生成代理方法,就像在构造函数中那样直接使用
    [testAOP(Location = InvokeLocation.Both)]
    public string haha(string asd)
    {
        return asd + "123";
    }

    [testAOP(Location = InvokeLocation.Before)]
    public void testAOP(string a,int b,decimal c)
    {

    }
}

3.3 查看生成的类

在这里插入图片描述
可以看到,已经自动生成了

    public partial class TestViewModel
    {
        /// <summary>
        /// testAOP的AOP代理方法
        /// <para>多个AOP特性均会执行</para>
        /// </summary>
        public void testAOP_Proxy(string a, int b, decimal c)
        {
            ExcuteProxy("testAOP", a, b, c);
        }

        /// <summary>
        /// haha的AOP代理方法
        /// <para>多个AOP特性均会执行</para>
        /// </summary>
        public string haha_Proxy(string asd)
        {
            return (string)ExcuteProxy("haha", asd);
        }

        /// <summary>
        /// 执行代理方法
        /// </summary>
        private object ExcuteProxy(string MethodName, params object[] args)
        {
            object result = null;
            var method = this.GetType().GetMethod(MethodName);
            object[]? attrs = method.GetCustomAttributes(true);
            foreach (var item in attrs)
            {
                if (item is AopBaseAttribute aop)
                {
                    AopMethod aopMethod = new AopMethod(this, method, args);
                    Exception ex = null;
                    try
                    {
                        if (aop.Location == InvokeLocation.Before)
                        {
                            aop.ExcuteBefore();
                        }

                        if (aop.Location == InvokeLocation.Both)
                        {
                            aop.ExcuteMiddle(aopMethod);
                        }
                    }
                    catch (Exception exx)
                    {
                        ex = exx;
                    }

                    if (aopMethod.ReturnValue is Task)
                    {
                        (aopMethod.ReturnValue as Task).ContinueWith((Task t) =>
                        {
                            aop.ExcuteAfter(aopMethod, t.Exception);
                        });
                    }
                    else
                    {
                        aop.ExcuteAfter(aopMethod, ex);
                    }

                    result = aopMethod.ReturnValue;
                }
            }

            return result;
        }
    }

结束

对于SourceGenerator的使用,还有很多很多的应用场景,希望能在后续的工作中,能切实的帮助到我们。
文中的代码可能不是那么准确,但是这部分的使用我已经实际测试过可以使用,如有更好的建议,可给我留言哈,让我学习和提高!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值