Skip to content

Commit

Permalink
Updates to support a menu system (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 authored Nov 3, 2020
1 parent b68ff23 commit 8217e6a
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 5 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>

<PropertyGroup>
<VersionPrefix>1.0.0-preview</VersionPrefix>
<VersionPrefix>1.1.1-preview</VersionPrefix>
<LangVersion>8.0</LangVersion>
<Copyright>Shane Krueger</Copyright>
<Authors>Shane Krueger</Authors>
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Random>(_ => {
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");
}
}
}
```
8 changes: 7 additions & 1 deletion Shane32.ConsoleDI.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/ExampleConsoleApp2/App.cs
Original file line number Diff line number Diff line change
@@ -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)}");
}
}
}
17 changes: 17 additions & 0 deletions src/ExampleConsoleApp2/App2.cs
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
18 changes: 18 additions & 0 deletions src/ExampleConsoleApp2/ExampleConsoleApp2.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<UserSecretsId>89bb3662-9213-4cf1-ae49-8ebf1fafa77a</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="3.1.9" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Shane32.ConsoleDI\Shane32.ConsoleDI.csproj" />
</ItemGroup>

</Project>
33 changes: 33 additions & 0 deletions src/ExampleConsoleApp2/Program.cs
Original file line number Diff line number Diff line change
@@ -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<Random>(_ => {
var seedStr = context.Configuration["Config:Seed"];
if (int.TryParse(seedStr, out int seed)) {
return new Random(seed);
}
return new Random();
});
}

}
}
132 changes: 129 additions & 3 deletions src/Shane32.ConsoleDI/ConsoleHost.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -63,10 +65,10 @@ public static void Run<T>(string[] args, Func<string[], IHostBuilder> createHost
// terminate program here
}

public static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> configureServices)
public static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> 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
Expand All @@ -92,7 +94,7 @@ public static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderCo
// read user secrets configured for the appropriate assembly
// note: cannot get the calling assembly here, because this is a lambda function, so the calling assembly is ConsoleDI
// note: cannot get the entry assembly here, because with EF migrations it would be EF, not the correct assembly
config.AddUserSecrets(assembly, optional: true);
config.AddUserSecrets(userSecretsAssembly, optional: true);

// read environment variables
config.AddEnvironmentVariables();
Expand All @@ -115,5 +117,129 @@ public static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderCo
// return the configured HostBuilder
return builder;
}

public static Task RunMainMenu(string[] args, Func<string[], IHostBuilder> 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<string[], IHostBuilder> 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<IServiceProvider, Task> 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<MainMenuAttribute>()))
.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<IServiceProvider, Task> 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<IServiceProvider, Task> 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;
}
}

}
}
12 changes: 12 additions & 0 deletions src/Shane32.ConsoleDI/IMenuOption.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
18 changes: 18 additions & 0 deletions src/Shane32.ConsoleDI/MainMenuAttribute.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}

0 comments on commit 8217e6a

Please sign in to comment.