From 8217e6a85e2c690ea19bbda4fe3ee0706a46f654 Mon Sep 17 00:00:00 2001 From: Shane Krueger Date: Tue, 3 Nov 2020 15:57:57 -0500 Subject: [PATCH] Updates to support a menu system (#1) --- Directory.Build.props | 2 +- README.md | 56 ++++++++ Shane32.ConsoleDI.sln | 8 +- src/ExampleConsoleApp2/App.cs | 24 ++++ src/ExampleConsoleApp2/App2.cs | 17 +++ .../ExampleConsoleApp2.csproj | 18 +++ src/ExampleConsoleApp2/Program.cs | 33 +++++ src/Shane32.ConsoleDI/ConsoleHost.cs | 132 +++++++++++++++++- src/Shane32.ConsoleDI/IMenuOption.cs | 12 ++ src/Shane32.ConsoleDI/MainMenuAttribute.cs | 18 +++ 10 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/ExampleConsoleApp2/App.cs create mode 100644 src/ExampleConsoleApp2/App2.cs create mode 100644 src/ExampleConsoleApp2/ExampleConsoleApp2.csproj create mode 100644 src/ExampleConsoleApp2/Program.cs create mode 100644 src/Shane32.ConsoleDI/IMenuOption.cs create mode 100644 src/Shane32.ConsoleDI/MainMenuAttribute.cs diff --git a/Directory.Build.props b/Directory.Build.props index 08cafe0..1da3d10 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.0.0-preview + 1.1.1-preview 8.0 Shane Krueger Shane Krueger diff --git a/README.md b/README.md index 7688bf3..ec2e363 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,59 @@ The order of priority is configured as follows (last has highest priority): 3. User secrets 4. Environment variables 5. Command-line arguments + +If you have multiple programs within your console app, you can also set up a menu system like this: + +```csharp +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shane32.ConsoleDI; + +namespace ExampleConsoleApp2 +{ + class Program + { + static async Task Main(string[] args) + => await ConsoleHost.RunMainMenu(args, CreateHostBuilder, "Demonstration console app with menu"); + + // this function is necessary for Entity Framework Core tools to perform migrations, etc + // do not change signature!! + public static IHostBuilder CreateHostBuilder(string[] args) + => ConsoleHost.CreateHostBuilder(args, ConfigureServices); + + private static void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + // register your Entity Framework data contexts here + + services.AddScoped(_ => { + var seedStr = context.Configuration["Config:Seed"]; + if (int.TryParse(seedStr, out int seed)) { + return new Random(seed); + } + return new Random(); + }); + } + + } + + [MainMenu("App1")] + class App1 : IMenuOption + { + public async Task RunAsync() + { + Console.WriteLine("This is my first program"); + } + } + + [MainMenu("App2")] + class App2 : IMenuOption + { + public async Task RunAsync() + { + Console.WriteLine("This is an alternate program"); + } + } +} +``` diff --git a/Shane32.ConsoleDI.sln b/Shane32.ConsoleDI.sln index 3bb3925..a09b457 100644 --- a/Shane32.ConsoleDI.sln +++ b/Shane32.ConsoleDI.sln @@ -16,7 +16,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shane32.ConsoleDI", "src\Shane32.ConsoleDI\Shane32.ConsoleDI.csproj", "{A9DD16A8-DEF6-453E-B5D5-CE7B4A397E3F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleConsoleApp", "src\ExampleConsoleApp\ExampleConsoleApp.csproj", "{54493642-9A30-4416-92DA-00257B14C218}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleConsoleApp", "src\ExampleConsoleApp\ExampleConsoleApp.csproj", "{54493642-9A30-4416-92DA-00257B14C218}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleConsoleApp2", "src\ExampleConsoleApp2\ExampleConsoleApp2.csproj", "{236194E6-B103-4B44-A970-39A4E61D30E0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -32,6 +34,10 @@ Global {54493642-9A30-4416-92DA-00257B14C218}.Debug|Any CPU.Build.0 = Debug|Any CPU {54493642-9A30-4416-92DA-00257B14C218}.Release|Any CPU.ActiveCfg = Release|Any CPU {54493642-9A30-4416-92DA-00257B14C218}.Release|Any CPU.Build.0 = Release|Any CPU + {236194E6-B103-4B44-A970-39A4E61D30E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {236194E6-B103-4B44-A970-39A4E61D30E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {236194E6-B103-4B44-A970-39A4E61D30E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {236194E6-B103-4B44-A970-39A4E61D30E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ExampleConsoleApp2/App.cs b/src/ExampleConsoleApp2/App.cs new file mode 100644 index 0000000..2873519 --- /dev/null +++ b/src/ExampleConsoleApp2/App.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Shane32.ConsoleDI; + +namespace ExampleConsoleApp2 +{ + [MainMenu("App1")] + class App + { + private readonly Random _rng; + + public App(Random rng) + { + _rng = rng; + } + + public async Task RunAsync() + { + Console.WriteLine($"Generating a random number via Dependency Injection: {_rng.Next(1, 100)}"); + } + } +} diff --git a/src/ExampleConsoleApp2/App2.cs b/src/ExampleConsoleApp2/App2.cs new file mode 100644 index 0000000..f217390 --- /dev/null +++ b/src/ExampleConsoleApp2/App2.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Shane32.ConsoleDI; + +namespace ExampleConsoleApp2 +{ + [MainMenu("App2")] + class App2 : IMenuOption + { + public async Task RunAsync() + { + Console.WriteLine("This is an alternate program"); + } + } +} diff --git a/src/ExampleConsoleApp2/ExampleConsoleApp2.csproj b/src/ExampleConsoleApp2/ExampleConsoleApp2.csproj new file mode 100644 index 0000000..6a2bbe5 --- /dev/null +++ b/src/ExampleConsoleApp2/ExampleConsoleApp2.csproj @@ -0,0 +1,18 @@ + + + + Exe + netcoreapp3.1 + false + 89bb3662-9213-4cf1-ae49-8ebf1fafa77a + + + + + + + + + + + diff --git a/src/ExampleConsoleApp2/Program.cs b/src/ExampleConsoleApp2/Program.cs new file mode 100644 index 0000000..defb880 --- /dev/null +++ b/src/ExampleConsoleApp2/Program.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shane32.ConsoleDI; + +namespace ExampleConsoleApp2 +{ + class Program + { + static async Task Main(string[] args) + => await ConsoleHost.RunMainMenu(args, CreateHostBuilder, "Demonstration console app with menu"); + + // this function is necessary for Entity Framework Core tools to perform migrations, etc + // do not change signature!! + public static IHostBuilder CreateHostBuilder(string[] args) + => ConsoleHost.CreateHostBuilder(args, ConfigureServices); + + private static void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + // register your Entity Framework data contexts here + + services.AddScoped(_ => { + var seedStr = context.Configuration["Config:Seed"]; + if (int.TryParse(seedStr, out int seed)) { + return new Random(seed); + } + return new Random(); + }); + } + + } +} diff --git a/src/Shane32.ConsoleDI/ConsoleHost.cs b/src/Shane32.ConsoleDI/ConsoleHost.cs index 4bbf184..0798e52 100644 --- a/src/Shane32.ConsoleDI/ConsoleHost.cs +++ b/src/Shane32.ConsoleDI/ConsoleHost.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -63,10 +65,10 @@ public static void Run(string[] args, Func createHost // terminate program here } - public static IHostBuilder CreateHostBuilder(string[] args, Action configureServices) + public static IHostBuilder CreateHostBuilder(string[] args, Action configureServices, Assembly userSecretsAssembly = null) { //determine the calling assembly for the user secrets - var assembly = Assembly.GetCallingAssembly(); + userSecretsAssembly ??= Assembly.GetCallingAssembly(); var builder = new HostBuilder() // set location to scan for appsettings.json file @@ -92,7 +94,7 @@ public static IHostBuilder CreateHostBuilder(string[] args, Action createHostBuilder, string title = null, bool loop = true, Assembly menuAssembly = null) + { + // attempting to use Assembly.GetCallingAssembly() from within an async method doesn't work (always returns CoreLib), so that reference must exist here + return RunMainMenu(args, createHostBuilder, menuAssembly ?? Assembly.GetCallingAssembly(), title, loop); + } + + private static async Task RunMainMenu(string[] args, Func createHostBuilder, Assembly assembly, string title = null, bool loop = true) + { + if (title != null) + Console.WriteLine(title + Environment.NewLine); + + // scan for main menu options + List<(Type Type, MainMenuAttribute Info, Func Action)> options = assembly + // get all classes that are not abstract + .GetTypes() + .Where(x => x.IsClass && !x.IsAbstract) + // which are marked [MainMenu] + .Select(x => (x, x.GetCustomAttribute())) + .Where(x => x.Item2 != null) + // and implement IMenuOption or have a public RunAsync() method + .Select(x => (x.x, x.Item2, CreateFunc(x.x))) + .Where(x => x.Item3 != null) + // sort the list + .OrderBy(x => x.Item2.SortOrder) + .ThenBy(x => x.Item2.Name) + .ToList(); + + if (options.Count == 0) + throw new Exception("No classes found marked with the [MainMenu] attribute and that implement IMenuOption"); + + // create the host builder (see above) + var hostBuilder = createHostBuilder(args); + + // create service collection for database access via dependency injection + // also have the main app be a service, similar to a controller in asp.net core + // then the main app can request services in the constructor + IServiceProvider rootServiceProvider; + using (var host = hostBuilder.Build()) { + rootServiceProvider = host.Services; + + while (loop) { + // display the main menu + Console.WriteLine("Main menu:"); + for (int i = 0; i < options.Count; i++) { + Console.WriteLine($" {i + 1}: {options[i].Info.Name}"); + } + + // ask for a selection + Console.Write("Please select: "); + var str = Console.ReadLine(); + + // attempt to run the selection + if (int.TryParse(str, out int num) && num >= 1 && num <= options.Count) { + Console.WriteLine(); + await RunSelection(options[num - 1].Action); + } + + // quit when just pressing enter + if (str == "") + return; + + // if looping, write a blank line + if (loop) + Console.WriteLine(); + } + + // disposing of the host will dispose of any singleton objects + } + + // terminate program here + + async Task RunSelection(Func func) + { + // create a scope and run the app (synchronously) + using (var scope = rootServiceProvider.CreateScope()) { + var serviceProvider = scope.ServiceProvider; + + // if looping, catch and display errors + if (loop) { + try { + await func(serviceProvider); + } catch (Exception e) { + Console.WriteLine(e.ToString()); + } + } else { + await func(serviceProvider); + } + + // disposing of the scope will dispose of required scoped objects like database contexts + } + } + + Func CreateFunc(Type t) + { + if (t is IMenuOption) { + return serviceProvider => { + // if T is not registered with the service provider, create an instance of it for us to use here + var obj = ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, t); + return ((IMenuOption)obj).RunAsync(); + }; + } else { + var method = t.GetMethod("RunAsync"); + if (method != null && method.ReturnType == typeof(Task) && method.GetParameters().Length == 0) { + return serviceProvider => { + // if T is not registered with the service provider, create an instance of it for us to use here + var obj = ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, t); + return (Task)method.Invoke(obj, null); + }; + } + method = t.GetMethod("Run"); + if (method != null && method.ReturnType == null && method.GetParameters().Length == 0) { + return serviceProvider => { + // if T is not registered with the service provider, create an instance of it for us to use here + var obj = ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, t); + method.Invoke(obj, null); + return Task.CompletedTask; + }; + } + } + return null; + } + } + } } diff --git a/src/Shane32.ConsoleDI/IMenuOption.cs b/src/Shane32.ConsoleDI/IMenuOption.cs new file mode 100644 index 0000000..a1d78af --- /dev/null +++ b/src/Shane32.ConsoleDI/IMenuOption.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Shane32.ConsoleDI +{ + public interface IMenuOption + { + public Task RunAsync(); + } +} diff --git a/src/Shane32.ConsoleDI/MainMenuAttribute.cs b/src/Shane32.ConsoleDI/MainMenuAttribute.cs new file mode 100644 index 0000000..223559c --- /dev/null +++ b/src/Shane32.ConsoleDI/MainMenuAttribute.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Shane32.ConsoleDI +{ + [AttributeUsage(AttributeTargets.Class)] + public class MainMenuAttribute : Attribute + { + public string Name { get; set; } + public MainMenuAttribute(string name) + { + Name = name; + } + + public int SortOrder { get; set; } + } +}