Criar uma aplicação .NET Core com plug-ins
Este tutorial mostra-lhe como criar um plug-in personalizado AssemblyLoadContext para carregar. Um AssemblyDependencyResolver é utilizado para resolver as dependências do plug-in. O tutorial isola corretamente as dependências do plug-in da aplicação de alojamento. Vai aprender a:
- Estruturar um projeto para suportar plug-ins.
- Crie um personalizado AssemblyLoadContext para carregar cada plug-in.
- Utilize o System.Runtime.Loader.AssemblyDependencyResolver tipo para permitir que os plug-ins tenham dependências.
- Crie plug-ins que podem ser facilmente implementados ao copiar apenas os artefactos de compilação.
Pré-requisitos
- Instale o SDK .NET 5 ou uma versão mais recente.
Nota
O código de exemplo destina-se a .NET 5, mas todas as funcionalidades que utiliza foram introduzidas no .NET Core 3.0 e estão disponíveis em todas as versões do .NET desde então.
Criar a aplicação
O primeiro passo é criar a aplicação:
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 ficheiro de solução do Visual Studio na mesma pasta. Execute o seguinte comando:
dotnet new sln
Execute o seguinte comando para adicionar o projeto da aplicação à solução:
dotnet sln add AppWithPlugin/AppWithPlugin.csproj
Agora podemos preencher o esqueleto da nossa aplicação. Substitua o código no ficheiro AppWithPlugin/Program.cs pelo seguinte código:
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 de plug-in
O próximo passo na criação de uma aplicação com plug-ins é definir a interface que os plug-ins precisam de implementar. Sugerimos que crie uma biblioteca de classes que contenha quaisquer tipos que planeie utilizar para comunicar entre a sua aplicação e plug-ins. Esta divisão permite-lhe publicar a interface de plug-in como um pacote sem ter de enviar a sua aplicação completa.
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 ficheiro de solução. Elimine o PluginBase/Class1.cs
ficheiro e crie um novo ficheiro na pasta com o PluginBase
nome ICommand.cs
com a seguinte definição de interface:
namespace PluginBase
{
public interface ICommand
{
string Name { get; }
string Description { get; }
int Execute();
}
}
Esta ICommand
interface é a interface que todos os plug-ins irão implementar.
Agora que a ICommand
interface está definida, o projeto da aplicação pode ser preenchido um pouco mais. Adicione uma referência do AppWithPlugin
projeto ao PluginBase
projeto com o dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj
comando da pasta raiz.
Substitua o // Load commands from plugins
comentário pelo fragmento de código seguinte para permitir que carregue plug-ins de determinados caminhos de ficheiro:
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 // Output the loaded commands
comentário pelo seguinte fragmento de código:
foreach (ICommand command in commands)
{
Console.WriteLine($"{command.Name}\t - {command.Description}");
}
Substitua o // Execute the command with the name passed as an argument
comentário pelo fragmento seguinte:
ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
if (command == null)
{
Console.WriteLine("No such command is known.");
return;
}
command.Execute();
Por fim, adicione métodos estáticos à classe com o Program
nome LoadPlugin
e CreateCommands
, conforme 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}");
}
}
Plug-ins de carga
Agora, a aplicação pode carregar e instanciar corretamente comandos de assemblagens de plug-in carregadas, mas continua a não conseguir carregar as assemblagens de plug-in. Crie um ficheiro com o nome PluginLoadContext.cs na pasta AppWithPlugin com os seguintes conteúdos:
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 PluginLoadContext
tipo deriva de AssemblyLoadContext. O AssemblyLoadContext
tipo é um tipo especial no runtime que permite aos programadores isolar assemblagens carregadas em diferentes grupos para garantir que as versões de assemblagem não entram em conflito. Além disso, um personalizado AssemblyLoadContext
pode escolher caminhos diferentes para carregar assemblagens e substituir o comportamento predefinido. Utiliza PluginLoadContext
uma instância do AssemblyDependencyResolver
tipo introduzida no .NET Core 3.0 para resolver nomes de assemblagem para caminhos. O AssemblyDependencyResolver
objeto é construído com o caminho para uma biblioteca de classes .NET. Resolve assemblagens e bibliotecas nativas para os respetivos caminhos relativos com base no ficheiro .deps.json da biblioteca de classes cujo caminho foi passado para o AssemblyDependencyResolver
construtor. O personalizado AssemblyLoadContext
permite que os plug-ins tenham as suas próprias dependências e facilita o AssemblyDependencyResolver
carregamento correto das dependências.
Agora que o AppWithPlugin
projeto tem o PluginLoadContext
tipo, atualize o Program.LoadPlugin
método 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)));
}
Ao utilizar uma instância diferente PluginLoadContext
para cada plug-in, os plug-ins podem ter dependências diferentes ou mesmo em conflito 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 com o nome
HelloPlugin
:dotnet new classlib -o HelloPlugin
Execute o seguinte comando para adicionar o projeto à
AppWithPlugin
solução:dotnet sln add HelloPlugin/HelloPlugin.csproj
Substitua o ficheiro HelloPlugin/Class1.cs por um ficheiro com o nome HelloCommand.cs pelos seguintes conteúdos:
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 ficheiro HelloPlugin.csproj . Deve ter um aspeto semelhante ao seguinte:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>
Entre as <PropertyGroup>
etiquetas, adicione o seguinte elemento:
<EnableDynamicLoading>true</EnableDynamicLoading>
O <EnableDynamicLoading>true</EnableDynamicLoading>
prepara o projeto para que possa ser utilizado como um plug-in. Entre outras coisas, esta ação irá copiar todas as dependências para a saída do projeto. Para obter mais detalhes, veja EnableDynamicLoading
.
Entre as <Project>
etiquetas, adicione os seguintes elementos:
<ItemGroup>
<ProjectReference Include="..\PluginBase\PluginBase.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
O <Private>false</Private>
elemento é importante. Isto indica ao MSBuild para não copiar PluginBase.dll para o diretório de saída da HelloPlugin. Se a assemblagem PluginBase.dll estiver presente no diretório de saída, PluginLoadContext
irá encontrar a assemblagem e carregá-la quando carregar a assemblagem HelloPlugin.dll . Neste momento, o HelloPlugin.HelloCommand
tipo irá implementar a ICommand
interface a partir do PluginBase.dll no diretório de saída do HelloPlugin
projeto e não na ICommand
interface que é carregada para o contexto de carga predefinido. Uma vez que o runtime vê estes dois tipos como tipos diferentes de assemblagens diferentes, o AppWithPlugin.Program.CreateCommands
método não encontrará os comandos. Como resultado, os <Private>false</Private>
metadados são necessários para a referência à assemblagem que contém as interfaces de plug-in.
Da mesma forma, o <ExcludeAssets>runtime</ExcludeAssets>
elemento também é importante se fizer referência a PluginBase
outros pacotes. Esta definição tem o mesmo efeito <Private>false</Private>
que, mas funciona em referências de pacotes que o PluginBase
projeto ou uma das respetivas dependências pode incluir.
Agora que o HelloPlugin
projeto está concluído, deve atualizar o AppWithPlugin
projeto para saber onde o HelloPlugin
plug-in pode ser encontrado. Após o // Paths to plugins to load
comentário, adicione @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll"
(este caminho pode ser diferente com base na versão do .NET Core que utiliza) como um elemento da pluginPaths
matriz.
Plug-in com dependências de biblioteca
Quase todos os plug-ins são mais complexos do que um simples "Hello World" e muitos plug-ins têm dependências noutras bibliotecas. Os JsonPlugin
projetos e OldJsonPlugin
no exemplo mostram dois exemplos de plug-ins com dependências de pacotes NuGet em Newtonsoft.Json
. Por este motivo, todos os projetos de plug-in devem adicionar <EnableDynamicLoading>true</EnableDynamicLoading>
às propriedades do projeto para que copiem todas as respetivas dependências para o resultado de dotnet build
. A publicação da biblioteca de classes com dotnet publish
também copiará todas as respetivas dependências para a saída de publicação.
Outros exemplos no exemplo
O código fonte completo deste tutorial pode ser encontrado no repositório dotnet/samples. O exemplo concluído inclui alguns outros exemplos de AssemblyDependencyResolver
comportamento. Por exemplo, o AssemblyDependencyResolver
objeto também pode resolver bibliotecas nativas, bem como assemblagens de satélite localizadas incluídas em pacotes NuGet. Os UVPlugin
e FrenchPlugin
no repositório de exemplos demonstram estes cenários.
Referenciar uma interface de plug-in a partir de um pacote NuGet
Digamos que existe uma aplicação A que tem uma interface de plug-in definida no pacote NuGet com o nome A.PluginBase
. Como referencia o pacote corretamente no projeto de plug-in? Para referências de projeto, a utilização dos <Private>false</Private>
metadados no ProjectReference
elemento no ficheiro de projeto impediu que o dll fosse copiado para o resultado.
Para referenciar corretamente o A.PluginBase
pacote, quer alterar o <PackageReference>
elemento no ficheiro do projeto para o seguinte:
<PackageReference Include="A.PluginBase" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
Isto impede que as A.PluginBase
assemblagens sejam copiadas para o diretório de saída do plug-in e garante que o plug-in utilizará a versão A do A.PluginBase
.
Recomendações de arquitetura de destino de plug-in
Uma vez que o carregamento de dependências de plug-in utiliza o ficheiro .deps.json , existe um gotcha relacionado com a arquitetura de destino do plug-in. Especificamente, os plug-ins devem visar um runtime, como .NET 5, em vez de uma versão do .NET Standard. O ficheiro .deps.json é gerado com base na arquitetura que o projeto visa e, uma vez que muitos pacotes compatíveis com o .NET Standard enviam assemblagens de referência para compilação em conjuntos de implementação e .NET Standard para runtimes específicos, o .deps.json pode não ver corretamente assemblagens de implementação ou pode obter a versão .NET Standard de uma assemblagem em vez da versão do .NET Core esperada.
Referências de arquitetura de plug-in
Atualmente, os plug-ins não podem introduzir novas arquiteturas no processo. Por exemplo, não pode carregar um plug-in que utiliza a arquitetura numa aplicação Microsoft.AspNetCore.App
que utiliza apenas a arquitetura de raiz Microsoft.NETCore.App
. A aplicação anfitriã tem de declarar referências a todas as arquiteturas necessárias pelos plug-ins.