Criar um aplicativo do .NET Core com plug-ins
Este tutorial mostra como criar um AssemblyLoadContext personalizado para carregar plug-ins. Um AssemblyDependencyResolver é usado para resolver as dependências do plug-in. O tutorial isola corretamente as dependências do plug-in do aplicativo de hospedagem. Você aprenderá a:
- Estruturar um projeto para permitir plug-ins.
- Criar um AssemblyLoadContext personalizado para carregar cada plug-in.
- Usar o tipo System.Runtime.Loader.AssemblyDependencyResolver para permitir que os plug-ins tenham dependências.
- Criar plug-ins que possam ser implantados facilmente apenas copiando os artefatos de build.
Pré-requisitos
- Instale o SDK do .NET 5 ou uma versão mais recente.
Observação
O código de exemplo tem como destino o .NET 5, mas todos os recursos que ele usa foram introduzidos no .NET Core 3.0 e estão disponíveis em todas as versões do .NET desde então.
Criar o aplicativo
A primeira etapa é criar o aplicativo:
Crie uma nova pasta e, nessa pasta, execute o seguinte comando:
dotnet new console -o AppWithPlugin
Para facilitar a criação do projeto, crie um arquivo de solução do Visual Studio na mesma pasta. Execute o comando a seguir:
dotnet new sln
Execute o seguinte comando para adicionar o projeto de aplicativo de classes à solução:
dotnet sln add AppWithPlugin/AppWithPlugin.csproj
Agora podemos preencher o esqueleto do aplicativo. Substitua o código no arquivo AppWithPlugin/Program.cs pelo código a seguir:
using PluginBase;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
namespace AppWithPlugin
{
class Program
{
static void Main(string[] args)
{
try
{
if (args.Length == 1 && args[0] == "/d")
{
Console.WriteLine("Waiting for any key...");
Console.ReadLine();
}
// Load commands from plugins.
if (args.Length == 0)
{
Console.WriteLine("Commands: ");
// Output the loaded commands.
}
else
{
foreach (string commandName in args)
{
Console.WriteLine($"-- {commandName} --");
// Execute the command with the name passed as an argument.
Console.WriteLine();
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
Criar as interfaces do plug-in
A próxima etapa na criação de um aplicativo com plug-ins é definir a interface que os plug-ins precisam implementar. Sugerimos a criação de uma biblioteca de classes contendo todos os tipos que você planeja usar para a comunicação entre o aplicativo e os plug-ins. Essa divisão permite que você publique sua interface de plug-in como um pacote sem precisar enviar seu aplicativo completo.
Na pasta raiz do projeto, execute dotnet new classlib -o PluginBase
. Além disso, execute dotnet sln add PluginBase/PluginBase.csproj
para adicionar o projeto ao arquivo de solução. Exclua o arquivo PluginBase/Class1.cs
e crie um arquivo na pasta PluginBase
chamada ICommand.cs
com a seguinte definição de interface:
namespace PluginBase
{
public interface ICommand
{
string Name { get; }
string Description { get; }
int Execute();
}
}
Essa interface ICommand
é aquela que todos os plug-ins implementarão.
Agora que a interface ICommand
está definida, o projeto de aplicativo pode ser um pouco mais preenchido. Adicione uma referência do projeto AppWithPlugin
ao projeto PluginBase
com o comando dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj
na pasta raiz.
Substitua o comentário // Load commands from plugins
pelo seguinte snippet de código para permitir que os plug-ins sejam carregados dos caminhos de arquivo fornecidos:
string[] pluginPaths = new string[]
{
// Paths to plugins to load.
};
IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
{
Assembly pluginAssembly = LoadPlugin(pluginPath);
return CreateCommands(pluginAssembly);
}).ToList();
Em seguida, substitua o comentário // Output the loaded commands
pelo snippet de código a seguir:
foreach (ICommand command in commands)
{
Console.WriteLine($"{command.Name}\t - {command.Description}");
}
Substitua o comentário // Execute the command with the name passed as an argument
pelo snippet a seguir:
ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
if (command == null)
{
Console.WriteLine("No such command is known.");
return;
}
command.Execute();
E, finalmente, adicione métodos estáticos à classe Program
denominada LoadPlugin
e CreateCommands
, como é mostrado aqui:
static Assembly LoadPlugin(string relativePath)
{
throw new NotImplementedException();
}
static IEnumerable<ICommand> CreateCommands(Assembly assembly)
{
int count = 0;
foreach (Type type in assembly.GetTypes())
{
if (typeof(ICommand).IsAssignableFrom(type))
{
ICommand result = Activator.CreateInstance(type) as ICommand;
if (result != null)
{
count++;
yield return result;
}
}
}
if (count == 0)
{
string availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName));
throw new ApplicationException(
$"Can't find any type which implements ICommand in {assembly} from {assembly.Location}.\n" +
$"Available types: {availableTypes}");
}
}
Carregar plug-ins
Agora o aplicativo pode carregar os comandos e criar uma instância deles corretamente a partir dos assemblies de plug-in carregados, mas ele ainda não pode carregar os assemblies de plug-in. Crie um arquivo chamado PluginLoadContext.cs na pasta AppWithPlugin com o seguinte conteúdo:
using System;
using System.Reflection;
using System.Runtime.Loader;
namespace AppWithPlugin
{
class PluginLoadContext : AssemblyLoadContext
{
private AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly Load(AssemblyName assemblyName)
{
string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (libraryPath != null)
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
}
}
O tipo PluginLoadContext
é derivado do tipo AssemblyLoadContext. O tipo AssemblyLoadContext
é um tipo especial no runtime que permite aos desenvolvedores isolarem os assemblies carregados em grupos diferentes para garantir que as versões do assembly não entrem em conflito. Além disso, um AssemblyLoadContext
personalizado pode escolher caminhos diferentes de onde carregar os assemblies e substituir o comportamento padrão. O PluginLoadContext
usa uma instância do tipo AssemblyDependencyResolver
introduzida no .NET Core 3.0 para resolver nomes de assembly para caminhos. O objeto AssemblyDependencyResolver
é construído com o caminho para uma biblioteca de classes .NET. Ele resolve assemblies e bibliotecas nativas para seus caminhos relativos com base no arquivo .deps.json da biblioteca de classes cujo caminho foi passado para o construtor AssemblyDependencyResolver
. O AssemblyLoadContext
personalizado permite que os plug-ins tenham suas próprias dependências e o AssemblyDependencyResolver
facilita o carregamento correto das dependências.
Agora que o projeto AppWithPlugin
tem o tipo PluginLoadContext
, atualize o método Program.LoadPlugin
com o seguinte corpo:
static Assembly LoadPlugin(string relativePath)
{
// Navigate up to the solution root
string root = Path.GetFullPath(Path.Combine(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(typeof(Program).Assembly.Location)))))));
string pluginLocation = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', Path.DirectorySeparatorChar)));
Console.WriteLine($"Loading commands from: {pluginLocation}");
PluginLoadContext loadContext = new PluginLoadContext(pluginLocation);
return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation)));
}
Com uma instância de PluginLoadContext
diferente para cada plug-in, os plug-ins pode ter dependências diferentes ou mesmo conflitantes sem problemas.
Plug-in simples sem dependências
Novamente na pasta raiz, faça o seguinte:
Execute o seguinte comando para criar um novo projeto de biblioteca de classes chamado
HelloPlugin
:dotnet new classlib -o HelloPlugin
Execute o seguinte comando para adicionar o projeto à solução
AppWithPlugin
:dotnet sln add HelloPlugin/HelloPlugin.csproj
Substitua o arquivo HelloPlugin/Class1.cs por um arquivo chamado HelloCommand.cs com o seguinte conteúdo:
using PluginBase;
using System;
namespace HelloPlugin
{
public class HelloCommand : ICommand
{
public string Name { get => "hello"; }
public string Description { get => "Displays hello message."; }
public int Execute()
{
Console.WriteLine("Hello !!!");
return 0;
}
}
}
Agora, abra o arquivo HelloPlugin.csproj. Ela deve parecer com o seguinte:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>
Entre as marcas <PropertyGroup>
, adicione o seguintes elemento:
<EnableDynamicLoading>true</EnableDynamicLoading>
O <EnableDynamicLoading>true</EnableDynamicLoading>
prepara o projeto para que ele possa ser usado como um plug-in. Entre outras coisas, isso copiará todas as dependências dele para a saída do projeto. Para obter mais informações, confira EnableDynamicLoading
.
Entre as marcas <Project>
, adicione os seguintes elementos:
<ItemGroup>
<ProjectReference Include="..\PluginBase\PluginBase.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
O elemento <Private>false</Private>
é importante. Ele informa ao MSBuild que ele não deve copiar o PluginBase.dll para o diretório de saída do HelloPlugin. Se o assembly PluginBase.dll estiver presente no diretório de saída, o PluginLoadContext
encontrará o assembly lá e o carregará ao carregar o assembly HelloPlugin.dll. Neste ponto, o tipo HelloPlugin.HelloCommand
implementará a interface ICommand
do PluginBase.dll no diretório de saída do projeto HelloPlugin
, não a interface ICommand
que é carregada no contexto de carregamento padrão. Como o runtime considera esses dois tipos como tipos diferentes de assemblies diferentes, o método AppWithPlugin.Program.CreateCommands
não localizará os comandos. Como resultado, os metadados <Private>false</Private>
serão necessários para a referência ao assembly que contém as interfaces de plug-in.
Da mesma forma, o elemento <ExcludeAssets>runtime</ExcludeAssets>
também será importante se o PluginBase
fizer referência a outros pacotes. Essa configuração tem o mesmo efeito de <Private>false</Private>
, mas funciona em referências de pacote que o projeto PluginBase
ou uma de suas dependências pode incluir.
Agora que o projeto HelloPlugin
está concluído, devemos atualizar o projeto AppWithPlugin
para saber onde o plug-in HelloPlugin
pode ser encontrado. Após o comentário // Paths to plugins to load
, adicione @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll"
(esse caminho pode ser diferente de acordo com a versão do .NET Core que você usa) como um elemento da matriz pluginPaths
.
Plug-in com dependências de biblioteca
Quase todos os plug-ins são mais complexos do que um simples "Olá, Mundo", e muitos plug-ins têm dependências de outras bibliotecas. Os projetos JsonPlugin
e OldJsonPlugin
no exemplo mostram dois exemplos de plug-ins com dependências de pacotes NuGet em Newtonsoft.Json
. Por isso, todos os projetos de plug-in devem adicionar <EnableDynamicLoading>true</EnableDynamicLoading>
às propriedades do projeto para que eles copiem todas as suas dependências para a saída de dotnet build
. A publicação da biblioteca de classes com dotnet publish
também copiará todas as suas dependências para a saída de publicação.
Outros exemplos na amostra
O código-fonte completo para este tutorial pode ser encontrado no repositório dotnet/samples. O exemplo completo inclui alguns outros exemplos do comportamento AssemblyDependencyResolver
. Por exemplo, o objeto AssemblyDependencyResolver
também pode resolver bibliotecas nativas, bem como assemblies satélites localizados incluídos em pacotes do NuGet. O UVPlugin
e FrenchPlugin
no repositório de amostras demonstram esses cenários.
Referenciar uma interface de plug-in de um pacote NuGet
Vamos supor que haja um aplicativo A que tenha uma interface de plug-in definida no pacote NuGet chamado A.PluginBase
. Como você referenciaria o pacote corretamente em seu projeto de plug-in? Para as referências do projeto, o uso dos metadados <Private>false</Private>
no elemento ProjectReference
no arquivo de projeto impediu que a dll fosse copiada para a saída.
Faça referenciar o pacote A.PluginBase
corretamente, altere o elemento <PackageReference>
no arquivo de projeto para o seguinte:
<PackageReference Include="A.PluginBase" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
Isso impede que os assemblies A.PluginBase
sejam copiados para o diretório de saída do plug-in e garante que o plug-in use a versão A do A.PluginBase
.
Recomendações de estrutura de destino do plug-in
Como o carregamento de dependência do plug-in usa o arquivo .deps.json, há uma pegadinha em relação à estrutura de destino do plug-in. Especificamente, os plug-ins devem ser direcionados a um runtime como o .NET Core 5 e não a uma versão do .NET Standard. O arquivo .deps.json é gerado com base na estrutura de destino do projeto e, como muitos pacotes compatíveis com o .NET Standard enviam assemblies de referência para compilar no .NET Standard e assemblies de implementação para runtimes específicos, o .deps.json pode não reconhecer corretamente os assemblies de implementação ou obter a versão do .NET Standard de um assembly em vez da versão do .NET Core esperada.
Referências da estrutura de plug-in
Atualmente, os plug-ins não podem introduzir novas estruturas no processo. Por exemplo, você não pode carregar um plug-in que usa a estrutura Microsoft.AspNetCore.App
em um aplicativo que usa apenas a estrutura raiz Microsoft.NETCore.App
. O aplicativo host deve declarar referências a todas as estruturas necessárias por plug-ins.