从0开发一款命令行工具
8/19/2024 4

在某一天,我突然发现.Net控制台应用的参数是可以传递的;额,理论上所有应用都支持命令行传参。

命令行参数无非两种:
(1)命令+参数
(2)选项+参数
通常命令行工具都使用空格区分;选项格式是--name或-n,分别对应选项全名和简写;命令行需要对参数、命令、选项进行解释,标配-v(打印版本),-h(打印帮助信息);有这些个思路,就可以开干了。

项目仓库:https://github.com/hedonghua/Cracker.CommandLine

我们只需要建立一个类,缓存命令、选项;当参数传入时,内部进行解析,再匹配到相应的命令,调用命令类的执行方法,例如:

csharp 复制代码
var app = new CommandApp();
app.Add<GuidCommand>("uuid").WithDescription("生成UUID");
app.Add<TestCommand>("test").WithDescription("测试泛型命令");
await app.StartAsync(args);

CommandApp类详解:
(1)Add方法进行挂载命令,缓存到字典
(2)StartAsync方法启动,解析参数并执行
如何新建一个命令类?
(1)类必须继承CommandBase.cs基类
(2)实现ExecuteAsync抽象方法
(3)CommandContext参数是挂载命令时传入的
如何新建一个命令参数接收类?
(1)属性使用CliArgumentAttribute标记参数
(2)属性使用CliOptionAttribute标记选项
(3)作为泛型参数传入CommandBase<T>
(4)子类使用Receive属性调用参数类

核心代码:

CommandApp.cs

csharp 复制代码
using Cracker.CommandLine.Attributes;
using Cracker.CommandLine.Helpers;
using Cracker.CommandLine.Models;
using System.Reflection;

namespace Cracker.CommandLine
{
    public class CommandApp
    {
        private readonly IDictionary<string, CommandConfiguration> _cmds;
        private readonly string[] _helpOptionNames = ["-h", "--help"];
        private readonly string[] _versionOptionNames = ["-v", "--version"];
        private const string VERSION = "v1.0.1";

        public CommandApp()
        {
            _cmds = new Dictionary<string, CommandConfiguration>();
        }

        /// <summary>
        /// 添加命令
        /// </summary>
        /// <typeparam name="TCommand">命令</typeparam>
        /// <param name="name">命令名称,唯一</param>
        /// <returns></returns>
        /// <exception cref="Exception"></exception>
        public CommandConfiguration Add<TCommand>(string name) where TCommand : CommandBase, new()
        {
            if (_cmds.ContainsKey(name)) throw new Exception($"已存在{name}的命令");
            var conf = new CommandConfiguration(name, new TCommand());
            _cmds.TryAdd(name, conf);
            return conf;
        }

        /// <summary>
        /// 启动
        /// </summary>
        /// <param name="args"></param>
        /// <returns></returns>
        /// <exception cref="DirectoryNotFoundException"></exception>
        /// <exception cref="ArgumentNullException"></exception>
        public async Task StartAsync(string[] args)
        {
            try
            {
                if (args.Length > 0)
                {
                    if (args[0].Contains('-'))
                    {
                        if (_helpOptionNames.Contains(args[0]))
                        {
                            Console.WriteLine("选项:");
                            Console.WriteLine("\t{0,-20}\t{1}", string.Join(',', _helpOptionNames), "打印帮助信息");
                            Console.WriteLine("\t{0,-20}\t{1}", string.Join(',', _versionOptionNames), "打印版本信息");
                            Console.WriteLine("命令:");
                            foreach (var item in _cmds)
                            {
                                Console.WriteLine("\t{0,-20}\t{1}", item.Key, item.Value.Description);
                            }
                        }
                        else if (_versionOptionNames.Contains(args[0]))
                        {
                            Console.WriteLine(VERSION);
                        }
                    }
                    else
                    {
                        if (!_cmds.TryGetValue(args[0], out CommandConfiguration? value)) throw new DirectoryNotFoundException($"命令{args[0]}找不到");
                        var type = value.Instance.GetType();
                        var skipArgs = args.Skip(1).ToArray();
                        var arguments = new List<string>();
                        var options = new Dictionary<string, string>();
                        for (var i = 0; i < skipArgs.Length; i++)
                        {
                            if (skipArgs[i].StartsWith('-'))
                            {
                                if (_helpOptionNames.Contains(skipArgs[i]))
                                {
                                    // 打印帮助信息
                                    await Console.Out.WriteLineAsync(value.HelpInformation);
                                    return;
                                }
                                if (i + 1 < skipArgs.Length)
                                {
                                    options.TryAdd(skipArgs[i], skipArgs[i + 1]);
                                    break; // 选项只取一个值,后面的值不算参数不管
                                }
                            }
                            else
                            {
                                arguments.Add(skipArgs[i]);
                            }
                        }
                        CommandBase instance = value.Instance;
                        // 泛型命令
                        if (type.BaseType!.IsGenericType)
                        {
                            var genericType = type.BaseType.GetGenericArguments()[0];
                            var genericInstance = Activator.CreateInstance(genericType);
                            if (genericInstance != null)
                            {
                                LoadParams(arguments, options, genericType, genericInstance);
                                var receiveProp = type.GetProperty(nameof(CommandBase<object>.Receive));
                                receiveProp?.SetValue(instance, genericInstance);
                            }
                        }
                        else
                        {
                            LoadParams(arguments, options, type, instance);
                        }
                        await instance.ExecuteAsync(value.Context ?? new CommandContext());
                    }
                }
                else
                {
                    Console.WriteLine("欢迎使用自定义命令行!");
                }
            }
            catch (Exception ex)
            {
                await Console.Out.WriteLineAsync(ex.Message);
            }
        }

        /// <summary>
        /// 加载参数
        /// </summary>
        /// <param name="arguments"></param>
        /// <param name="options"></param>
        /// <param name="type"></param>
        /// <param name="instance"></param>
        /// <exception cref="ArgumentNullException"></exception>
        private static void LoadParams(List<string> arguments, Dictionary<string, string> options, Type type, object instance)
        {
            foreach (var prop in type.GetProperties())
            {
                var optionAttr = prop.GetCustomAttribute<CliOptionAttribute>();
                if (optionAttr != null && options.TryGetValue(optionAttr.Name, out var optionValue))
                {
                    var val = ConvertHelper.To(optionValue, prop.PropertyType);
                    prop.SetValue(instance, val);
                    continue;
                }
                var argAttr = prop.GetCustomAttribute<CliArgumentAttribute>();
                if (argAttr != null)
                {
                    if (arguments.Count >= 1 + argAttr.Position)
                    {
                        if (prop.PropertyType.IsArray)
                        {
                            // 数组参数取值:从位置开始直至末尾
                            string[] strs = [.. arguments[argAttr.Position..]];
                            prop.SetValue(instance, strs);
                        }
                        else
                        {
                            var val = ConvertHelper.To(arguments[argAttr.Position], prop.PropertyType);
                            prop.SetValue(instance, val);
                        }
                    }
                    else if (argAttr.Required)
                    {
                        throw new ArgumentNullException(prop.Name, "参数是必须的");
                    }
                }
            }
        }
    }
}

CommandConfiguration.cs

csharp 复制代码
using Cracker.CommandLine.Attributes;
using System.Reflection;
using System.Text;

namespace Cracker.CommandLine.Models
{
    public class CommandConfiguration
    {
        public CommandConfiguration(string name, CommandBase instance)
        {
            Name = name;
            Instance = instance;
            HelpInformation = GetCommandHelpInformation();
        }

        public CommandConfiguration WithDescription(string description)
        {
            Description = description;
            return this;
        }

        public CommandConfiguration WithData(object data)
        {
            Context = new CommandContext { Data = data };
            return this;
        }

        /// <summary>
        /// 命令名称
        /// </summary>
        public string Name { get; private set; }

        /// <summary>
        /// 命令实例
        /// </summary>
        public CommandBase Instance { get; private set; }

        /// <summary>
        /// 命令描述
        /// </summary>
        public string Description { get; private set; }

        /// <summary>
        /// 上下文
        /// </summary>
        public CommandContext Context { get; private set; }

        /// <summary>
        /// 帮助信息
        /// </summary>
        public string HelpInformation { get; private set; }

        private string GetCommandHelpInformation()
        {
            var type = Instance.GetType();
            if (type.BaseType != null && type.BaseType.IsGenericType)
            {
                type = type.BaseType!.GetGenericArguments()[0]!;
            }
            var options = new Dictionary<string, string>();
            var arguments = new Dictionary<int, string>();
            var sb = new StringBuilder();
            foreach (var prop in type.GetProperties())
            {
                var optionAttr = prop.GetCustomAttribute<CliOptionAttribute>();
                if (optionAttr != null) options.TryAdd(optionAttr.Name, optionAttr.Description);
                var argAttr = prop.GetCustomAttribute<CliArgumentAttribute>();
                if (argAttr != null)
                {
                    if (arguments.ContainsKey(argAttr.Position)) throw new Exception("参数位置不能重复");
                    arguments.TryAdd(argAttr.Position, argAttr.Description);
                }
                var arrayType = typeof(string[]);
                if (prop.PropertyType.IsArray && prop.PropertyType != arrayType)
                {
                    throw new ArgumentException("数组参数只支持" + arrayType);
                }
            }
            if (arguments.Count > 0)
            {
                sb.AppendLine("参数:");
                foreach (var item in arguments.OrderBy(x => x.Key))
                {
                    sb.AppendFormat("\t[{0}]\t{1}\r\n", item.Key + 1, item.Value);
                }
            }
            if (options.Count > 0)
            {
                sb.AppendLine("选项:");
                foreach (var item in options)
                {
                    sb.AppendFormat("\t{0,-20}\t{1}\r\n", item.Key, item.Value);
                }
            }
            return sb.ToString();
        }
    }
}

Comments