다음을 통해 공유


플러그 인을 사용하여 .NET Core 애플리케이션 만들기

이 자습서에서는 플러그 인을 로드하는 사용자 지정 AssemblyLoadContext 만드는 방법을 보여줍니다. AssemblyDependencyResolver 플러그 인의 종속성을 확인하는 데 사용됩니다. 이 자습서에서는 플러그 인의 종속성에 대한 별도의 어셈블리 컨텍스트를 제공하여 플러그 인과 호스팅 애플리케이션 간에 서로 다른 어셈블리 종속성을 허용합니다. 다음을 배우게 됩니다:

  • 플러그 인을 지원하도록 프로젝트를 구성합니다.
  • 사용자 지정 AssemblyLoadContext를 생성하여 각 플러그인을 로드합니다.
  • System.Runtime.Loader.AssemblyDependencyResolver 형식을 사용하여 플러그 인이 종속성을 가질 수 있도록 합니다.
  • 빌드 아티팩트만 복사하여 쉽게 배포할 수 있는 플러그 인을 작성합니다.

비고

신뢰할 수 없는 코드는 신뢰할 수 있는 .NET 프로세스에 안전하게 로드할 수 없습니다. 보안 또는 안정성 경계를 제공하려면 OS 또는 가상화 플랫폼에서 제공하는 기술을 고려합니다.

필수 조건

애플리케이션 만들기

첫 번째 단계는 애플리케이션을 만드는 것입니다.

  1. 새 폴더를 만들고 해당 폴더에서 다음 명령을 실행합니다.

    dotnet new console -o AppWithPlugin
    
  2. 프로젝트를 더 쉽게 빌드하려면 동일한 폴더에 Visual Studio 솔루션 파일을 만듭니다. 다음 명령을 실행합니다.

    dotnet new sln
    
  3. 다음 명령을 실행하여 솔루션에 앱 프로젝트를 추가합니다.

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

이제 애플리케이션의 구조를 채울 수 있습니다. AppWithPlugin/Program.cs 파일의 코드를 다음 코드로 바꿉다.

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);
            }
        }
    }
}

플러그 인 인터페이스 만들기

플러그 인을 사용하여 앱을 빌드하는 다음 단계는 플러그 인이 구현해야 하는 인터페이스를 정의하는 것입니다. 앱과 플러그 인 간의 통신에 사용할 형식을 포함하는 클래스 라이브러리를 만드는 것이 좋습니다. 이 부서를 사용하면 전체 애플리케이션을 배송하지 않고도 플러그 인 인터페이스를 패키지로 게시할 수 있습니다.

프로젝트의 루트 폴더에서 dotnet new classlib -o PluginBase실행합니다. 또한 dotnet sln add PluginBase/PluginBase.csproj 실행하여 솔루션 파일에 프로젝트를 추가합니다. PluginBase/Class1.cs 파일을 삭제하고 다음 인터페이스 정의를 사용하여 ICommand.csPluginBase 폴더에 새 파일을 만듭니다.

namespace PluginBase
{
    public interface ICommand
    {
        string Name { get; }
        string Description { get; }

        int Execute();
    }
}

ICommand 인터페이스는 모든 플러그 인이 구현할 인터페이스입니다.

이제 ICommand 인터페이스가 정의되었으므로 애플리케이션 프로젝트를 조금 더 채울 수 있습니다. 루트 폴더의 dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj 명령을 사용하여 AppWithPlugin 프로젝트의 참조를 PluginBase 프로젝트에 추가합니다.

지정된 파일 경로에서 플러그 인을 로드할 수 있도록 // Load commands from plugins 주석을 다음 코드 조각으로 바꿉니다.

string[] pluginPaths = new string[]
{
    // Paths to plugins to load.
};

IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
{
    Assembly pluginAssembly = LoadPlugin(pluginPath);
    return CreateCommands(pluginAssembly);
}).ToList();

그런 다음, // Output the loaded commands 주석을 다음 코드 조각으로 바꿉다.

foreach (ICommand command in commands)
{
    Console.WriteLine($"{command.Name}\t - {command.Description}");
}

// Execute the command with the name passed as an argument 주석을 다음 코드 조각으로 바꿉다.

ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
if (command == null)
{
    Console.WriteLine("No such command is known.");
    return;
}

command.Execute();

마지막으로 다음과 같이 LoadPluginCreateCommandsProgram 클래스에 정적 메서드를 추가합니다.

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}");
    }
}

플러그 인 로드

이제 애플리케이션이 로드된 플러그 인 어셈블리에서 명령을 올바르게 로드하고 인스턴스화할 수 있지만 플러그 인 어셈블리를 로드할 수는 없습니다. 다음 내용이 포함된 AppWithPlugin 폴더에 PluginLoadContext.cs 파일을 만듭니다.

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;
        }
    }
}

PluginLoadContext 형식은 AssemblyLoadContext파생됩니다. AssemblyLoadContext 형식은 개발자가 로드된 어셈블리를 다른 그룹으로 격리하여 어셈블리 버전이 충돌하지 않도록 하는 런타임의 특수 형식입니다. 또한 사용자 지정 AssemblyLoadContext 어셈블리를 로드할 다른 경로를 선택하고 기본 동작을 재정의할 수 있습니다. PluginLoadContext은 .NET Core 3.0에서 도입된 AssemblyDependencyResolver 유형의 인스턴스를 사용하여 어셈블리 이름을 경로로 변환합니다. AssemblyDependencyResolver 개체는 .NET 클래스 라이브러리의 경로를 사용하여 생성됩니다. AssemblyDependencyResolver 생성자에 전달된 클래스 라이브러리 파일의 경로를 기준으로, .deps.json 파일을 통해 어셈블리 및 네이티브 라이브러리를 해당 상대 경로로 해결합니다. 사용자 지정 AssemblyLoadContext 사용하면 플러그 인이 자체 종속성을 가질 수 있으며 AssemblyDependencyResolver 종속성을 쉽게 로드할 수 있습니다.

이제 AppWithPlugin 프로젝트에 PluginLoadContext 형식이 있으므로 Program.LoadPlugin 메서드를 다음 본문으로 업데이트합니다.

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)));
}

각 플러그 인에 대해 다른 PluginLoadContext 인스턴스를 사용하면 플러그 인에 문제 없이 다른 종속성 또는 충돌하는 종속성이 있을 수 있습니다.

종속성이 없는 간단한 플러그 인

루트 폴더로 돌아가서 다음을 수행합니다.

  1. 다음 명령을 실행하여 HelloPlugin새 클래스 라이브러리 프로젝트를 만듭니다.

    dotnet new classlib -o HelloPlugin
    
  2. 다음 명령을 실행하여 AppWithPlugin 솔루션에 프로젝트를 추가합니다.

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. HelloPlugin/Class1.cs 파일을 다음 내용으로 HelloCommand.cs 파일로 바꿉니다.

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;
        }
    }
}

이제 HelloPlugin.csproj 파일을 엽니다. 다음과 같이 표시되어야 합니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

</Project>

<PropertyGroup> 태그 사이에 다음 요소를 추가합니다.

  <EnableDynamicLoading>true</EnableDynamicLoading>

<EnableDynamicLoading>true</EnableDynamicLoading> 플러그 인으로 사용할 수 있도록 프로젝트를 준비합니다. 무엇보다도 모든 종속성을 프로젝트의 출력에 복사합니다. 자세한 내용은 EnableDynamicLoading참조하세요.

<Project> 태그 사이에 다음 요소를 추가합니다.

<ItemGroup>
    <ProjectReference Include="..\PluginBase\PluginBase.csproj">
        <Private>false</Private>
        <ExcludeAssets>runtime</ExcludeAssets>
    </ProjectReference>
</ItemGroup>

<Private>false</Private> 요소가 중요합니다. 그러면 MSBuild에서 HelloPlugin의 출력 디렉터리에 PluginBase.dll 복사하지 않도록 지시합니다. PluginBase.dll 어셈블리가 출력 디렉터리에 있는 경우 PluginLoadContext 어셈블리를 찾아서 HelloPlugin.dll 어셈블리를 로드할 때 로드합니다. 이 시점에서 HelloPlugin.HelloCommand 형식은 기본 로드 컨텍스트에 로드되는 ICommand 인터페이스가 아니라 HelloPlugin 프로젝트의 출력 디렉터리에 있는 PluginBase.dllICommand 인터페이스를 구현합니다. 런타임에서 이러한 두 형식을 서로 다른 어셈블리의 다른 형식으로 간주하므로 AppWithPlugin.Program.CreateCommands 메서드는 명령을 찾을 수 없습니다. 따라서 플러그 인 인터페이스를 포함하는 어셈블리에 대한 참조에는 <Private>false</Private> 메타데이터가 필요합니다.

마찬가지로 PluginBase 다른 패키지를 참조하는 경우에도 <ExcludeAssets>runtime</ExcludeAssets> 요소가 중요합니다. 이 설정은 <Private>false</Private> 효과와 동일하지만 PluginBase 프로젝트 또는 해당 종속성 중 하나에 포함될 수 있는 패키지 참조에서 작동합니다.

이제 HelloPlugin 프로젝트가 완료되었으므로 HelloPlugin 플러그 인을 찾을 수 있는 위치를 알 수 있도록 AppWithPlugin 프로젝트를 업데이트해야 합니다. // Paths to plugins to load 주석 후에 pluginPaths 배열의 요소로 @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll" 추가합니다(이 경로는 사용하는 .NET Core 버전에 따라 다를 수 있음).

라이브러리 종속성이 있는 플러그 인

거의 모든 플러그 인은 간단한 "Hello World"보다 더 복잡하며 많은 플러그 인은 다른 라이브러리에 종속되어 있습니다. 샘플의 JsonPluginOldJsonPlugin 프로젝트는 Newtonsoft.JsonNuGet 패키지 종속성이 있는 플러그 인의 두 가지 예를 보여 줍니다. 이 때문에 모든 플러그 인 프로젝트는 모든 종속성을 dotnet build출력에 복사하도록 프로젝트 속성에 <EnableDynamicLoading>true</EnableDynamicLoading> 추가해야 합니다. dotnet publish 사용하여 클래스 라이브러리를 게시하면 모든 종속성이 게시 출력에 복사됩니다.

샘플의 다른 예제

이 자습서의 전체 소스 코드는 dotnet/samples 리포지토리 찾을 수 있습니다. 완성된 샘플에는 AssemblyDependencyResolver 동작의 몇 가지 다른 예제가 포함되어 있습니다. 예를 들어 AssemblyDependencyResolver 개체는 NuGet 패키지에 포함된 지역화된 위성 어셈블리뿐만 아니라 네이티브 라이브러리도 확인할 수 있습니다. 샘플 리포지토리의 UVPluginFrenchPlugin 이러한 시나리오를 보여 줍니다.

NuGet 패키지에서 플러그 인 인터페이스 참조

A.PluginBaseNuGet 패키지에 정의된 플러그 인 인터페이스가 있는 앱 A가 있다고 가정해 보겠습니다. 플러그 인 프로젝트에서 패키지를 올바르게 참조하려면 어떻게 해야 합니까? 프로젝트 참조의 경우 프로젝트 파일의 ProjectReference 요소에 <Private>false</Private> 메타데이터를 사용하면 dll이 출력에 복사되지 않습니다.

A.PluginBase 패키지를 올바르게 참조하려면 프로젝트 파일의 <PackageReference> 요소를 다음으로 변경하려고 합니다.

<PackageReference Include="A.PluginBase" Version="1.0.0">
    <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>

이렇게 하면 A.PluginBase 어셈블리가 플러그 인의 출력 디렉터리에 복사되지 않고 플러그 인에서 A 버전의 A.PluginBase사용할 수 있습니다.

플러그 인 대상 프레임워크 권장 사항

플러그 인 종속성 로드는 .deps.json 파일을 사용하므로 플러그 인의 대상 프레임워크와 관련된 문제가 있습니다. 특히 플러그 인은 .NET Standard 버전 대신 .NET 5와 같은 런타임을 대상으로 해야 합니다. .deps.json 파일은 프로젝트가 대상으로 하는 프레임워크에 따라 생성되며, 많은 .NET Standard 호환 패키지가 특정 런타임에 대해 .NET Standard 및 구현 어셈블리에 대해 빌드하기 위한 참조 어셈블리를 제공하므로 .deps.json 구현 어셈블리가 올바르게 표시되지 않거나 예상하는 .NET Core 버전 대신 어셈블리의 .NET Standard 버전을 사용할 수 있습니다.

플러그 인 프레임워크 참조

현재 플러그 인은 프로세스에 새 프레임워크를 도입할 수 없습니다. 예를 들어 Microsoft.AspNetCore.App 프레임워크를 사용하는 플러그 인을 루트 Microsoft.NETCore.App 프레임워크만 사용하는 애플리케이션에 로드할 수 없습니다. 호스트 애플리케이션은 플러그 인에 필요한 모든 프레임워크에 대한 참조를 선언해야 합니다.