ASP.NET Core 백 엔드 서버 SDK를 사용하는 방법

이 문서에서는 ASP.NET Core 백 엔드 서버 SDK를 구성하고 사용하여 데이터 동기화 서버를 생성해야 합니다.

지원되는 플랫폼

ASP.NET Core 백 엔드 서버는 ASP.NET 6.0 이상을 지원합니다.

데이터베이스 서버는 밀리초 정확도로 저장된 형식 필드 또는 Timestamp 형식 필드가 다음 조건을 DateTime 충족해야 합니다. 리포지토리 구현은 Entity Framework CoreLiteDb에 대해 제공됩니다.

특정 데이터베이스 지원은 다음 섹션을 참조하세요.

새 데이터 동기화 서버 만들기

데이터 동기화 서버는 일반 ASP.NET Core 메커니즘을 사용하여 서버를 만듭니다. 다음 세 단계로 구성됩니다.

  1. ASP.NET 6.0 이상 서버 프로젝트를 만듭니다.
  2. Entity Framework Core 추가
  3. 데이터 동기화 서비스 추가

Entity Framework Core를 사용하여 ASP.NET Core 서비스를 만드는 방법에 대한 자세한 내용은 자습서를 참조하세요.

데이터 동기화 서비스를 사용하도록 설정하려면 다음 NuGet 라이브러리를 추가해야 합니다.

파일을 수정합니다 Program.cs . 다른 모든 서비스 정의 아래에 다음 줄을 추가합니다.

builder.Services.AddDatasyncControllers();

ASP.NET Core datasync-server 템플릿을 사용할 수도 있습니다.

# This only needs to be done once
dotnet new -i Microsoft.AspNetCore.Datasync.Template.CSharp
mkdir My.Datasync.Server
cd My.Datasync.Server
dotnet new datasync-server

템플릿에는 샘플 모델 및 컨트롤러가 포함됩니다.

SQL 테이블에 대한 테이블 컨트롤러 만들기

기본 리포지토리는 Entity Framework Core를 사용합니다. 테이블 컨트롤러를 만드는 것은 3단계 프로세스입니다.

  1. 데이터 모델에 대한 모델 클래스를 만듭니다.
  2. 애플리케이션에 대한 모델 클래스를 DbContext 추가합니다.
  3. 모델을 노출하는 새 TableController<T> 클래스를 만듭니다.

모델 클래스 만들기

모든 모델 클래스는 .를 구현 ITableData해야 합니다. 각 리포지토리 형식에는 구현하는 추상 클래스가 있습니다 ITableData. Entity Framework Core 리포지토리는 다음을 사용합니다 EntityTableData.

public class TodoItem : EntityTableData
{
    /// <summary>
    /// Text of the Todo Item
    /// </summary>
    public string Text { get; set; }

    /// <summary>
    /// Is the item complete?
    /// </summary>
    public bool Complete { get; set; }
}

인터페이스는 ITableData 데이터 동기화 서비스를 처리하기 위한 추가 속성과 함께 레코드의 ID를 제공합니다.

  • UpdatedAt (DateTimeOffset?)는 레코드가 마지막으로 업데이트된 날짜를 제공합니다.
  • Version (byte[])는 모든 쓰기에서 변경되는 불투명 값을 제공합니다.
  • Deleted (bool) 레코드가 삭제로 표시되었지만 아직 제거되지 않은 경우 true입니다.

데이터 동기화 라이브러리는 이러한 속성을 기본. 사용자 고유의 코드에서 이러한 속성을 수정하지 마세요.

다음을 업데이트합니다. DbContext

데이터베이스의 각 모델은 에 등록 DbContext되어야 합니다. 예시:

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<TodoItem> TodoItems { get; set; }
}

테이블 컨트롤러 만들기

테이블 컨트롤러는 특수화된 ApiController컨트롤러입니다. 다음은 최소 테이블 컨트롤러입니다.

[Route("tables/[controller]")]
public class TodoItemController : TableController<TodoItem>
{
    public TodoItemController(AppDbContext context) : base()
    {
        Repository = new EntityTableRepository<TodoItem>(context);
    }
}

참고 항목

  • 컨트롤러에는 경로가 있어야 합니다. 규칙에 따라 테이블은 하위 경로 /tables에 노출되지만 어디에나 배치할 수 있습니다. v5.0.0 이전의 클라이언트 라이브러리를 사용하는 경우 테이블은 다음의 /tables하위 경로여야 합니다.
  • 컨트롤러는 리포지토리 형식에 대한 구현의 구현인 ITableData 위치에서 TableController<T><T> 상속해야 합니다.
  • 모델과 동일한 형식을 기반으로 리포지토리를 할당합니다.

메모리 내 리포지토리 구현

영구 스토리지가 없는 메모리 내 리포지토리를 사용할 수도 있습니다. 다음에서 리포지토리에 대한 싱글톤 서비스를 추가합니다 Program.cs.

IEnumerable<Model> seedData = GenerateSeedData();
builder.Services.AddSingleton<IRepository<Model>>(new InMemoryRepository<Model>(seedData));

다음과 같이 테이블 컨트롤러를 설정합니다.

[Route("tables/[controller]")]
public class ModelController : TableController<Model>
{
    public MovieController(IRepository<Model> repository) : base(repository)
    {
    }
}

테이블 컨트롤러 옵션 구성

다음을 사용하여 TableControllerOptions컨트롤러의 특정 측면을 구성할 수 있습니다.

[Route("tables/[controller]")]
public class MoodelController : TableController<Model>
{
    public ModelController(IRepository<Model> repository) : base(repository)
    {
        Options = new TableControllerOptions { PageSize = 25 };
    }
}

설정할 수 있는 옵션은 다음과 같습니다.

  • PageSize (int기본값: 100)은 쿼리 작업이 단일 페이지에서 반환된 최대 항목 수입니다.
  • MaxTop (int기본값: 512000)은 페이징 없이 쿼리 작업에서 반환되는 최대 항목 수입니다.
  • EnableSoftDelete (bool기본값: false)는 일시 삭제를 사용하도록 설정하여 항목을 데이터베이스에서 삭제하는 대신 삭제된 항목으로 표시합니다. 일시 삭제를 사용하면 클라이언트가 오프라인 캐시를 업데이트할 수 있지만 삭제된 항목은 데이터베이스에서 별도로 제거해야 합니다.
  • UnauthorizedStatusCode(int기본값: 401 권한 없음)은 사용자가 작업을 수행할 수 없을 때 반환되는 상태 코드입니다.

액세스 권한 구성

기본적으로 사용자는 모든 레코드를 만들고 읽고 업데이트하고 삭제하는 테이블 내에서 엔터티를 원하는 모든 작업을 수행할 수 있습니다. 권한 부여를 보다 세밀하게 제어하려면 구현하는 클래스를 만듭니다 IAccessControlProvider. 세 IAccessControlProvider 가지 방법을 사용하여 권한 부여를 구현합니다.

  • GetDataView() 는 연결된 사용자가 볼 수 있는 것을 제한하는 람다를 반환합니다.
  • IsAuthorizedAsync() 는 연결된 사용자가 요청되는 특정 엔터티에 대해 작업을 수행할 수 있는지 여부를 결정합니다.
  • PreCommitHookAsync() 는 리포지토리에 기록되기 직전에 엔터티를 조정합니다.

세 가지 방법 사이에서 대부분의 액세스 제어 사례를 효과적으로 처리할 수 있습니다. 액세스해야 하는 HttpContext경우 HttpContextAccessor를 구성합니다.

예를 들어 다음에서는 사용자가 자신의 레코드만 볼 수 있는 개인 테이블을 구현합니다.

public class PrivateAccessControlProvider<T>: IAccessControlProvider<T>
    where T : ITableData
    where T : IUserId
{
    private readonly IHttpContextAccessor _accessor;

    public PrivateAccessControlProvider(IHttpContextAccessor accessor)
    {
        _accessor = accessor;
    }

    private string UserId { get => _accessor.HttpContext.User?.Identity?.Name; }

    public Expression<Func<T,bool>> GetDataView()
    {
      return (UserId == null)
        ? _ => false
        : model => model.UserId == UserId;
    }

    public Task<bool> IsAuthorizedAsync(TableOperation op, T entity, CancellationToken token = default)
    {
        if (op == TableOperation.Create || op == TableOperation.Query)
        {
            return Task.FromResult(true);
        }
        else
        {
            return Task.FromResult(entity?.UserId != null && entity?.UserId == UserId);
        }
    }

    public virtual Task PreCommitHookAsync(TableOperation operation, T entity, CancellationToken token = default)
    {
        entity.UserId == UserId;
        return Task.CompletedTask;
    }
}

메서드는 정답을 얻기 위해 추가 데이터베이스 조회를 수행해야 하는 경우에 비동기입니다. 컨트롤러에서 인터페이스를 IAccessControlProvider<T> 구현할 수 있지만 스레드로부터 안전한 방식으로 액세스 HttpContext 하려면 계속 전달 IHttpContextAccessor 해야 합니다.

이 액세스 제어 공급자를 사용하려면 다음과 같이 업데이트 TableController 합니다.

[Authorize]
[Route("tables/[controller]")]
public class ModelController : TableController<Model>
{
    public ModelsController(AppDbContext context, IHttpContextAccessor accessor) : base()
    {
        AccessControlProvider = new PrivateAccessControlProvider<Model>(accessor);
        Repository = new EntityTableRepository<Model>(context);
    }
}

인증되지 않은 액세스와 인증된 테이블에 대한 액세스를 모두 허용하려면 대신 데코레이트합니다 [AllowAnonymous][Authorize].

로깅 구성

로깅은 ASP.NET Core에 대한 일반 로깅 메커니즘을 통해 처리됩니다. 속성에 ILogger 개체를 할당합니다.Logger

[Authorize]
[Route("tables/[controller]")]
public class ModelController : TableController<Model>
{
    public ModelController(AppDbContext context, Ilogger<ModelController> logger) : base()
    {
        Repository = new EntityTableRepository<Model>(context);
        Logger = logger;
    }
}

리포지토리 변경 내용 모니터링

리포지토리가 변경되면 워크플로를 트리거하거나, 클라이언트에 응답을 기록하거나, 다음 두 가지 방법 중 하나로 다른 작업을 수행할 수 있습니다.

옵션 1: PostCommitHookAsync 구현

인터페이스는 IAccessControlProvider<T> 메서드를 PostCommitHookAsync() 제공합니다. 데이터가 리포지토리에 기록된 후 클라이언트에 데이터를 반환하기 전에 Th PostCommitHookAsync() 메서드가 호출됩니다. 클라이언트로 반환되는 데이터가 이 메서드에서 변경되지 않도록 주의해야 합니다.

public class MyAccessControlProvider<T> : AccessControlProvider<T> where T : ITableData
{
    public override async Task PostCommitHookAsync(TableOperation op, T entity, CancellationToken cancellationToken = default)
    {
        // Do any work you need to here.
        // Make sure you await any asynchronous operations.
    }
}

후크의 일부로 비동기 작업을 실행하는 경우 이 옵션을 사용합니다.

옵션 2: RepositoryUpdated 이벤트 처리기 사용

기본 클래스는 TableController<T> 메서드와 동시에 PostCommitHookAsync() 호출되는 이벤트 처리기를 포함합니다.

[Authorize]
[Route(tables/[controller])]
public class ModelController : TableController<Model>
{
    public ModelController(AppDbContext context) : base()
    {
        Repository = new EntityTableRepository<Model>(context);
        RepositoryUpdated += OnRepositoryUpdated;
    }

    internal void OnRepositoryUpdated(object sender, RepositoryUpdatedEventArgs e) 
    {
        // The RepositoryUpdatedEventArgs contains Operation, Entity, EntityName
    }
}

Azure 앱 서비스 ID 사용

ASP.NET Core 데이터 동기화 서버는 ASP.NET Core ID 또는 지원하려는 다른 인증 및 권한 부여 체계를 지원합니다. 이전 버전의 Azure Mobile Apps에서 업그레이드를 지원하기 위해 Azure 앱 서비스 ID를 구현하는 ID 공급자도 제공합니다. 애플리케이션에서 Azure 앱 서비스 ID를 구성하려면 다음을 편집합니다Program.cs.

builder.Services.AddAuthentication(AzureAppServiceAuthentication.AuthenticationScheme)
  .AddAzureAppServiceAuthentication(options => options.ForceEnable = true);

// Then later, after you have created the app
app.UseAuthentication();
app.UseAuthorization();

데이터베이스 지원

Entity Framework Core는 날짜/시간 열에 대한 값 생성을 설정하지 않습니다. (참조) 날짜/시간 값 생성). Entity Framework Core용 Azure Mobile Apps 리포지토리는 UpdatedAt 자동으로 필드를 업데이트합니다. 그러나 데이터베이스가 리포지토리 외부에서 업데이트되는 경우 업데이트할 필드와 Version 필드를 정렬 UpdatedAt 해야 합니다.

Azure SQL

각 엔터티에 대한 트리거를 만듭니다.

CREATE OR ALTER TRIGGER [dbo].[TodoItems_UpdatedAt] ON [dbo].[TodoItems]
    AFTER INSERT, UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE 
        [dbo].[TodoItems] 
    SET 
        [UpdatedAt] = GETUTCDATE() 
    WHERE 
        [Id] IN (SELECT [Id] FROM INSERTED);
END

마이그레이션을 사용하거나 데이터베이스를 만든 직후 EnsureCreated() 에 이 트리거를 설치할 수 있습니다.

Azure Cosmos DB

Azure Cosmos DB는 모든 크기 또는 규모의 고성능 애플리케이션을 위한 완전 관리형 NoSQL 데이터베이스입니다. Entity Framework Core에서 Azure Cosmos DB를 사용하는 방법에 대한 자세한 내용은 Azure Cosmos DB 공급자를 참조하세요. Azure Mobile Apps에서 Azure Cosmos DB를 사용하는 경우:

  1. 및 필드를 지정하는 복합 인덱스를 사용하여 Cosmos 컨테이너를 UpdatedAtId 설정합니다. 복합 인덱스는 Azure Portal, ARM, Bicep, Terraform 또는 코드 내에서 컨테이너에 추가할 수 있습니다. 다음은 bicep 리소스 정의의 예입니다.

    resource cosmosContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = {
        name: 'TodoItems'
        parent: cosmosDatabase
        properties: {
            resource: {
                id: 'TodoItems'
                partitionKey: {
                    paths: [
                        '/Id'
                    ]
                    kind: 'Hash'
                }
                indexingPolicy: {
                    indexingMode: 'consistent'
                    automatic: true
                    includedPaths: [
                        {
                            path: '/*'
                        }
                    ]
                    excludedPaths: [
                        {
                            path: '/"_etag"/?'
                        }
                    ]
                    compositeIndexes: [
                        [
                            {
                                path: '/UpdatedAt'
                                order: 'ascending'
                            }
                            {
                                path: '/Id'
                                order: 'ascending'
                            }
                        ]
                    ]
                }
            }
        }
    }
    

    테이블에 있는 항목의 하위 집합을 끌어오는 경우 쿼리에 관련된 모든 속성을 지정해야 합니다.

  2. 클래스에서 모델을 파생합니다.ETagEntityTableData

    public class TodoItem : ETagEntityTableData
    {
        public string Title { get; set; }
        public bool Completed { get; set; }
    }
    
  3. OnModelCreating(ModelBuilder) 메서드를 추가합니다 DbContext. Entity Framework용 Cosmos DB 드라이버는 기본적으로 모든 엔터티를 동일한 컨테이너에 배치합니다. 최소한 적절한 파티션 키를 선택하고 속성이 EntityTag 동시성 태그로 표시되어 있는지 확인해야 합니다. 예를 들어 다음 코드 조각은 Azure Mobile Apps에 TodoItem 대한 적절한 설정을 사용하여 엔터티를 자체 컨테이너에 저장합니다.

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<TodoItem>(builder =>
        {
            // Store this model in a specific container.
            builder.ToContainer("TodoItems");
            // Do not include a discriminator for the model in the partition key.
            builder.HasNoDiscriminator();
            // Set the partition key to the Id of the record.
            builder.HasPartitionKey(model => model.Id);
            // Set the concurrency tag to the EntityTag property.
            builder.Property(model => model.EntityTag).IsETagConcurrency();
        });
        base.OnModelCreating(builder);
    }
    

Azure Cosmos DB는 v5.0.11 이후 NuGet 패키지에서 Microsoft.AspNetCore.Datasync.EFCore 지원됩니다. 자세한 내용은 다음 링크를 검토하세요.

PostgreSQL

각 엔터티에 대한 트리거를 만듭니다.

CREATE OR REPLACE FUNCTION todoitems_datasync() RETURNS trigger AS $$
BEGIN
    NEW."UpdatedAt" = NOW() AT TIME ZONE 'UTC';
    NEW."Version" = convert_to(gen_random_uuid()::text, 'UTF8');
    RETURN NEW
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE TRIGGER
    todoitems_datasync
BEFORE INSERT OR UPDATE ON
    "TodoItems"
FOR EACH ROW EXECUTE PROCEDURE
    todoitems_datasync();

마이그레이션을 사용하거나 데이터베이스를 만든 직후 EnsureCreated() 에 이 트리거를 설치할 수 있습니다.

Sqlite

Warning

프로덕션 서비스에는 SqLite를 사용하지 마세요. SqLite는 프로덕션 환경에서 클라이언트 쪽 사용에만 적합합니다.

SqLite에는 밀리초 정확도를 지원하는 날짜/시간 필드가 없습니다. 따라서 테스트 외에는 적합하지 않습니다. SqLite를 사용하려면 날짜/시간 속성에 대해 각 모델에서 값 변환기 및 값 비교자를 구현해야 합니다. 값 변환기 및 비교자를 구현하는 가장 쉬운 방법은 다음의 메서드에 OnModelCreating(ModelBuilder) 있습니다 DbContext.

protected override void OnModelCreating(ModelBuilder builder)
{
    var timestampProps = builder.Model.GetEntityTypes().SelectMany(t => t.GetProperties())
        .Where(p => p.ClrType == typeof(byte[]) && p.ValueGenerated == ValueGenerated.OnAddOrUpdate);
    var converter = new ValueConverter<byte[], string>(
        v => Encoding.UTF8.GetString(v),
        v => Encoding.UTF8.GetBytes(v)
    );
    foreach (var property in timestampProps)
    {
        property.SetValueConverter(converter);
        property.SetDefaultValueSql("STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')");
    }
    base.OnModelCreating(builder);
}

데이터베이스를 초기화할 때 업데이트 트리거를 설치합니다.

internal static void InstallUpdateTriggers(DbContext context)
{
    foreach (var table in context.Model.GetEntityTypes())
    {
        var props = table.GetProperties().Where(prop => prop.ClrType == typeof(byte[]) && prop.ValueGenerated == ValueGenerated.OnAddOrUpdate);
        foreach (var property in props)
        {
            var sql = $@"
                CREATE TRIGGER s_{table.GetTableName()}_{prop.Name}_UPDATE AFTER UPDATE ON {table.GetTableName()}
                BEGIN
                    UPDATE {table.GetTableName()}
                    SET {prop.Name} = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
                    WHERE rowid = NEW.rowid;
                END
            ";
            context.Database.ExecuteSqlRaw(sql);
        }
    }
}

InstallUpdateTriggers 데이터베이스를 초기화하는 동안 메서드가 한 번만 호출되었는지 확인합니다.

public void InitializeDatabase(DbContext context)
{
    bool created = context.Database.EnsureCreated();
    if (created && context.Database.IsSqlite())
    {
        InstallUpdateTriggers(context);
    }
    context.Database.SaveChanges();
}

LiteDB

LiteDB 는 .NET C# 관리 코드로 작성된 단일 작은 DLL로 제공되는 서버리스 데이터베이스입니다. 독립 실행형 애플리케이션을 위한 간단하고 빠른 NoSQL 데이터베이스 솔루션입니다. 디스크 내 영구 스토리지에서 LiteDb를 사용하려면 다음을 수행합니다.

  1. NuGet에서 Microsoft.AspNetCore.Datasync.LiteDb 패키지를 설치합니다.

  2. 에 대한 싱글톤을 LiteDatabaseProgram.cs추가합니다.

    const connectionString = builder.Configuration.GetValue<string>("LiteDb:ConnectionString");
    builder.Services.AddSingleton<LiteDatabase>(new LiteDatabase(connectionString));
    
  3. 다음에서 모델을 파생합니다.LiteDbTableData

    public class TodoItem : LiteDbTableData
    {
        public string Title { get; set; }
        public bool Completed { get; set; }
    }
    

    LiteDb NuGet 패키지와 함께 제공되는 특성을 사용할 BsonMapper 수 있습니다.

  4. 다음을 사용하여 컨트롤러를 만듭니다.LiteDbRepository

    [Route("tables/[controller]")]
    public class TodoItemController : TableController<TodoItem>
    {
        public TodoItemController(LiteDatabase db) : base()
        {
            Repository = new LiteDbRepository<TodoItem>(db, "todoitems");
        }
    }
    

OpenAPI 지원

NSwag 또는 Swashbuckle을 사용하여 데이터 동기화 컨트롤러에서 정의한 API를 게시할 수 있습니다. 두 경우 모두 선택한 라이브러리에 대해 정상적으로 서비스를 설정하여 시작합니다.

NSwag

NSwag 통합에 대한 기본 지침을 따르고 다음과 같이 수정합니다.

  1. NSwag를 지원하기 위해 프로젝트에 패키지를 추가합니다. 다음 패키지가 필요합니다.

  2. 파일 맨 위에 다음을 추가합니다 Program.cs .

    using Microsoft.AspNetCore.Datasync.NSwag;
    
  3. 파일에 OpenAPI 정의를 생성하는 서비스를 추가합니다 Program.cs .

    builder.Services.AddOpenApiDocument(options =>
    {
        options.AddDatasyncProcessors();
    });
    
  4. 생성된 JSON 문서 및 Swagger UI를 제공하기 위해 미들웨어를 다음에서 Program.cs사용하도록 설정합니다.

    if (app.Environment.IsDevelopment())
    {
        app.UseOpenApi();
        app.UseSwaggerUI3();
    }
    

웹 서비스의 엔드포인트로 이동 /swagger 하면 API를 찾아볼 수 있습니다. 그런 다음 OpenAPI 정의를 다른 서비스(예: Azure API Management)로 가져올 수 있습니다. NSwag 구성에 대한 자세한 내용은 NSwag 및 ASP.NET Core 시작 방법을 참조하세요.

Swashbuckle

Swashbuckle 통합에 대한 기본 지침을 따르고 다음과 같이 수정합니다.

  1. 프로젝트에 패키지를 추가하여 Swashbuckle을 지원합니다. 다음 패키지가 필요합니다.

  2. 파일에 OpenAPI 정의를 생성하는 서비스를 추가합니다 Program.cs .

    builder.Services.AddSwaggerGen(options => 
    {
        options.AddDatasyncControllers();
    });
    builder.Services.AddSwaggerGenNewtonsoftSupport();
    

    참고 항목

    이 메서드는 AddDatasyncControllers() 테이블 컨트롤러를 포함하는 어셈블리에 해당하는 선택 사항을 Assembly 사용합니다. Assembly 매개 변수는 테이블 컨트롤러가 서비스와 다른 프로젝트에 있는 경우에만 필요합니다.

  3. 생성된 JSON 문서 및 Swagger UI를 제공하기 위해 미들웨어를 다음에서 Program.cs사용하도록 설정합니다.

    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.UseSwaggerUI(options => 
        {
            options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
            options.RoutePrefix = string.Empty;
        });
    }
    

이 구성을 사용하면 웹 서비스의 루트로 이동하여 API를 찾아볼 수 있습니다. 그런 다음 OpenAPI 정의를 다른 서비스(예: Azure API Management)로 가져올 수 있습니다. Swashbuckle 구성에 대한 자세한 내용은 Swashbuckle 및 ASP.NET Core 시작하기를 참조하세요.

제한 사항

서비스 라이브러리의 ASP.NET Core 버전은 목록 작업에 OData v4를 구현합니다. 서버가 이전 버전과의 호환 모드에서 실행되는 경우 부분 문자열에 대한 필터링은 지원되지 않습니다.