ASP.NET Core - 2. 라우팅(Routing)
URL routing의 기본적인 기능은 요청 URL에 따라 그에 맞는 처리를 실행하여 응답을 생성하는 것입니다. 이제 예제를 통해 Routing에 관한 전반적인 내용을 살펴보도록 하겠습니다.
1. 시작하기
예제는
해당 글에서 사용된 예제를 그대로 사용합니다. Project를 열고 Middleware folder에 아래와 같이 Fruit.cs file을 추가합니다.
namespace MyWebApp.Middleware
{
public class Fruit
{
private RequestDelegate? next;
public Fruit()
{ }
public Fruit(RequestDelegate nextDelegate)
{
next = nextDelegate;
}
public async Task Invoke(HttpContext context)
{
string[] parts = context.Request.Path.ToString().Split("/", StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && parts[0] == "fruit")
{
string fruit = parts[1];
int? price = null;
switch (fruit.ToLower())
{
case "watermelon":
price = 1_000;
break;
case "grape":
price = 2_000;
break;
case "orange":
price = 1_500;
break;
}
if (price.HasValue)
{
await context.Response.WriteAsync($"fruit: {fruit}, cost: {price}");
return;
}
}
if (next != null)
{
await next(context);
}
}
}
}
위 Middleware component는 요청 URL을 확인하여 URL이 2개의 부분으로 구성되어 있고 fruit으로 시작하는 경우에 별도의 응답을 생성하도록 되어 있습니다. 요청 URL이 해당 조건에 부합하는 경우 switch문을 사용하여 price값을 지정하지만 그렇지 않다면 요청은 Pipeline의 다음으로 넘어가게 됩니다.
이어서 같은 위치에 Animal.cs file도 추가합니다.
namespace MyWebApp.Middleware
{
public class Animal
{
private RequestDelegate? next;
public Animal()
{ }
public Animal(RequestDelegate nextDelegate)
{
next = nextDelegate;
}
public async Task Invoke(HttpContext context)
{
string[] parts = context.Request.Path.ToString().Split("/", StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && parts[0] == "animal")
{
string animal = parts[1];
string? sound = null;
switch (animal.ToLower())
{
case "cat":
sound = "meow";
break;
case "dog":
sound = "bowwow";
break;
case "grape":
context.Response.Redirect($"/fruit/{animal}");
return;
}
if (sound != null)
{
await context.Response.WriteAsync($"{animal}의 울음소리는?");
return;
}
}
if (next != null)
{
await next(context);
}
}
}
}
Animal은 위에서 추가한 Fruit과 거의 동일한 처리방식을 가지고 있습니다. 요청 URL이 animal로 시작하는 경우 switch구문에 따라 울음소리를 나타내도록 되어 있는데 다만 grape가 오게 되는 경우 /fruit로 URL을 redirect 하도록 하고 있습니다.
이제 Program.cs를 수정하여 기존의 Middleware를 모두 지우고 위에서 추가한 Middleware를 다시 추가합니다.
using MyWebApp.Middleware;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseMiddleware<Fruit>();
app.UseMiddleware<Animal>();
app.Run(async (context) =>
{
await context.Response.WriteAsync("Middelware Terminal");
});
app.Run();
Project를 실행하여 /fruit/watermelon과 /animal/cat으로 URL을 요청하여 해당 응답이 잘 이루어지는지를 확인합니다.
(1) URL Routing
ASP.NET의 각 Middleware는 Pipeline에 전달된 요청에 따라 어떤 동작을 실행할지를 결정합니다. 어떤 Middleware는 특정한 Header나 혹은 query string을 검색하여 지정한 동작을 수행하는 경우도 있지만 대부분의 경우 요청된 URL을 Matching 하여 필요한 동작을 구현하는 것입니다.
각 Middleware는 요청이 Pipeline을 따라 작동하는 것과 동일한 일련의 과정을 반복해야 하는데 이것은 위에서 추가한 Middleware를 통해서 URL을 분리하고 분리된 영역의 값을 다시 확인하는 과정으로서 확인할 수 있습니다.
다만 2개의 Middleware component가 대부분의 중복된 code를 통해 구현되고 있으므로 그다지 효율적이지도 않을 뿐만 아니라 목표로 하는 URL이 Code안에 숨겨져 있는 탓에 유지 관리하기도 어렵습니다. 예를 들어 Animal에서 grape로의 URL요청을 받게 되는 경우 fruit으로 Redirect를 수행하게 되고 요청은 다시 Fruit component에 의해 처리될 것입니다. 이러한 관계 때문에 만약 Fruit component에 /food라는 URL처리를 추가해야 한다면 Animal에도 동일한 처리가 적용되어야 하는 등의 문제가 생길 수 있는 것입니다.
URL routing은 요청 URL과 일치되는 Middleware를 추가하여 endpoint라는 component가 Response에 집중하게 함으로써 이러한 문제를 해결하도록 합니다. endpoint와 그들이 필요한 URL사이의 mapping은 route로 표현되며 routing middleware는 URL을 처리하고 일련의 route를 검사하며 요청을 처리할 endpoint를 찾는 소위 routing이라고 하는 동작을 수행하게 됩니다.
(2) Routing Middleware와 Endpoint정의 추가하기
routing middleware는 UseRouting과 UseEndpoints라고 하는 분리된 2개의 Method를 통해 추가됩니다. UseRouting은 Pileline을 통해 요청을 처리하기 위한 Middleware를 추가하며 UseEndpoints는 URL을 Endpoint에 일치시키는 route를 정의하기 위해 사용됩니다. 요청된 URL과의 비교를 수행하는 pattern을 통해 일치된 URL은 각 route를 통해 하나의 URL Pattern과 하나의 endpoint사이에 관계를 생성하게 됩니다.
app.UseMiddleware<Fruit>();
app.UseMiddleware<Animal>();
app.UseRouting();
app.UseEndpoints(endpoints => {
endpoints.MapGet("routing", async context => {
await context.Response.WriteAsync("Request Was Routed");
});
});
app.Run(async (context) =>
{
await context.Response.WriteAsync("Middelware Termianl");
});
UseRouting() Method는 매개변수가 따로 존재하지 않으며 UseEndpoints() Method는 IEndpointRouteBuilder객체를 수용하는 함수를 전달받아 확장 method를 통해 route를 생성하는 데 사용합니다. 아래 표는 이런 경우에 사용 가능한 Method를 나열한 것입니다.
MapGet(pattern, endpoint) | 지정한 URL Pattern과 일치하는 HTTP GET요청을 endpoint로 route합니다. |
MapPost(pattern, endpoint) | 지정한 URL Pattern과 일치하는 HTTP POST요청을 endpoint로 route합니다. |
MapPut(pattern, endpoint) | 지정한 URL Pattern과 일치하는 HTTP PUT요청을 endpoint로 route합니다. |
MapDelete(pattern, endpoint) | 지정한 URL Pattern과 알치하는 HTTP DELETE요청을 endpoint로 route합니다. |
MapMethods(pattern, method, endpoint) | URL Pattern과 HTTP 요청 method를 지정하여 해당하는 요청을 endpoint로 route합니다. |
Map(pattern, endpoint) | 지정한 URL Pattern과 일치하는 모든 HTTP요청을 endpoint로 route합니다. |
예제에서 endpoint는 통상 Middleware에서 사용하는 delegate과 같은 RequestDelegate를 통해 정의되었으므로 HTTP Context객체를 전달받고 응답을 생성하는 데 사용되는 비동기 method가 될 수 있습니다.
Project를 실행하여 /routing으로 요청을 보내 새롭게 추가한 Route middleware가 제대로 동작하는지 확인합니다.
요청한 /routing는 Middleware에서 지정한 'routing'과 일치하여 동작할 것입니다. 이때 지정한 URL Pattern에서 '/'문자는 경로에 해당하지 않으므로 '/'문자가 없음에 주의합니다. 요청 URL 경로와 지정한 URL Pattern이 일치한다면 요청은 endpoint로 전달되고 위 화면과 같은 응답을 생성하게 됩니다.
route가 URL과 일치하게 되면 routing middleware는 pipeline을 단락 시키게 되므로 생성된 응답은 그저 route의 endpoint에 의해 만들어진 것에 불과합니다. 즉, 요청은 다른 endpoint나 pipeline다음에 존재하는 middleware로 요청을 전달하지 않습니다.
하지만 요청 URL이 URL Pattern과 일치하지 않는다면 요청은 자연스럽게 다음 Pipeline에 존재하는 middleware component로 전달될 것입니다. 따라서 만약 요청을 /route와 같이 지정한다면 이 요청은 위에서 지정한 routing과 URL이 일치하지 않게 되므로 요청을 처리하기 위해 다음에 존재하는 Terminal Middleware로 요청을 전달함으로써 아래와 같은 결과를 보게 됩니다.
Endpoint는 요청으로의 접근을 제공하는 HttpContext객체를 전달받고 HttpRequest와 HttpResponse객체를 통해 응답을 생성하는 Middleware component(이전 포스팅에서 생성해본)와 동일한 방법으로 응답을 생성합니다. 다시 말해 모든 Middleware component가 endpoint로 사용될 수 있습니다.
아래 예제는 위에서 추가한 Fruit과 Animal이라는 2개의 Middleware를 Endpoint로서 추가한 것을 보여주고 있습니다.
//app.UseMiddleware<Fruit>();
//app.UseMiddleware<Animal>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("routing", async context =>
{
await context.Response.WriteAsync("Request Was Routed");
});
endpoints.MapGet("fruit/orange", new Fruit().Invoke);
endpoints.MapGet("animal/cat", new Animal().Invoke);
});
하지만 Middleware component를 이렇게 사용한다는 것은 맞지 않을 수 있는데 왜냐하면 endpoint로서 Invoke method를 실행해야 하므로 class의 instance생성을 필요로 하기 때문입니다. route에서 사용된 URL Pattern은 그저 본래 Middleware component에서 사용하는 URL의 일부만을 명시하고 있지만 그래도 endpoint를 이해하는 데는 도움이 될 수 있습니다.
(3) Pipeline 구성 간소화
위에서 routing은 표준적인 Pipeline기능에 기반하여 일반적인 Middleware component를 통해 구현된다는 점을 강조하고자 UseRouting과 UseEndpoints Method에 대한 사용을 언급했었습니다. 그러나 ASP.NET Core application의 구성을 간소화하기 위한 하나의 일환으로 Microsoft는 request pipeline에 자동적으로 UseRouting와 UseEndpoints Method를 적용하였습니다. 따라서 위에서 언급했던 MapGet Method를 WebApplication의 CreateBuilder() Method를 통해 반환된 WebApplication객체에서 다음과 같이 직접적으로 사용할 수 있습니다.
var app = builder.Build();
app.MapGet("routing", async context => {
await context.Response.WriteAsync("Request Was Routed");
});
app.MapGet("fruit/orange", new Fruit().Invoke);
app.MapGet("animal/cat", new Animal().Invoke);
//app.Run(async (context) =>
//{
// await context.Response.WriteAsync("Middelware Termianl");
//});
app.Run();
WebApplication class는 IEndpointRouteBuilder interface를 구현하므로 endpoint는 더욱더 간소화된 형태로 만들어질 수 있습니다. 물론 routing middleware는 여전히 요청을 일치시키고 route를 선택하는 역할을 수행하고 있습니다.
이전 예제에서 Pipeline에 명시적으로 추가한 Routing Middleware는 요청에 일치하는 route가 없을 경우에만 Pipeline을 통해 요청을 전달하였는데 위에서 처럼 요청을 직접적으로 정의함으로써 이러한 동작이 변경될 수 있기 때문에 요청은 항상 Termianl Middleware로 전달될 수 있으므로 예제에서는 일단 Terminal Middleware를 주석 처리하였습니다. 중요한 것은 간소화된 Pipeline의 구성이 Program.cs에서 code의 양을 줄여줄 뿐만 아니라 요청이 처리되는 방식을 바꿀 수 있다는 것입니다.
(4) URL Pattern의 이해
위 예제에서는 Routing Middleware에서 route를 선택하기 위해 먼저 URL을 처리한 다음 다시 Fruit이나 Animal Class에서 필요한 값을 추출하고 있습니다. 사실 이러한 과정이 그다지 효휼적이지는 않은데 이 문제를 개선하려면 URL Pattern이 사용되는 방식에 대해 조금 더 이해할 필요가 있습니다. 요청이 도달하면 우선은 Routing Middleware에서 URL을 처리하여 URL 경로로부터 segment를 추출하게 됩니다. segment는 /문자로 분리된 각 영역을 말합니다.
요청을 route 하기 위해 URL Pattern에서 추출된 segment를 요청 segment와 비교하여 일치 여부를 확인하게 되고 path가 segment수와 같고 각각의 segment가 URL Pattern과 같다면 요청이 해당 endpoint로 route 됩니다.
/fruit | segment수가 너무 적음 |
/fruit/grape/orange | segment수가 너무 많음 |
/music/orange | 첫번째 segment가 fruit이 아님 |
/fruit/orange | 일치함 |
(5) URL Pattern에서 segment variables사용
위 예제에서 사용한 URL Pattern은 literal segment(또는 static segment)라고 하며 특정한 값을 가진 요청만이 일치할 수 있습니다. 따라서 첫 번째 segment로 fruit라는 값을 가지며 두 번째 segment가 orange나 grape라는 값을 가지는 요청에만 route가 가능하게 됩니다.
route parameter로 알려진 segment variables는 pattern segment과 일치할 수 있는 path segment의 범위를 확장하여 routing의 유연성을 더 높여줄 수 있는데 segment variables는 각 segment에 대응하는 별칭을 부여하고 각 별칭을 중괄호({})로 감싸서 표현합니다. 이때 action, area, controller, handler, page와 같은 단어는 별칭으로 사용할 수 없습니다.
var app = builder.Build();
app.MapGet("{first}/{second}/{third}", async context => {
await context.Response.WriteAsync("Request Was Routed\n");
foreach (var value in context.Request.RouteValues)
{
await context.Response.WriteAsync($"{value.Key}: {value.Value}\n");
}
});
app.MapGet("fruit/orange", new Fruit().Invoke);
위 예제의 {first}/{second}/{third}라는 URL Pattern은 어떤 값을 가졌는가 와는 상관없이 3개의 segment를 가진 path의 URL과 일치하게 됩니다. 요청이 처리될 때 routing middleware는 URL Path segment와 관련된 content를 포함하는 endpoint를 제공하게 되는데 이 content는 RouteValuesDictionary객체를 반환하는 HttpRequest.RouteValues 속성을 통해 값을 가져올 수 있습니다.
참고로 RouteValuesDictionary에서 사용하는 각 속성은 아래와 같습니다.
[key] | class는 key를 통해 값을 가져올 수 있도록 하는 indexer를 정의하고 있습니다. |
keys | 이 속성은 segment 변수의 별칭들에 대한 collection을 반환합니다. |
values | 이 속성은 segment 변수의 값에 대한 collection을 반환합니다. |
count | 이 속성은 segment의 수를 반환합니다. |
ContainsKey(key) | route data가 지정한 key의 값을 포함하고 있다면 true를 반환하는 method입니다. |
또한 RouteValuesDictionary는 foreach구문을 통해 KeyValuePair<string, object>배열을 생성하여 열거할 수 있으며 각각은 segment 변수의 이름과 요청 URL로부터 추출된 값에 해당합니다.
first, second, third라는 이름의 segment변수를 통해서는 URL로부터 추출된 값을 직접적으로 확인할 수 있습니다. project를 실행하여 /abc/def/ghi로 URL을 요청하고 아래와 같은 결과가 나오는지 확인합니다.
(6) Route 선택
요청이 처리될 때 Middleware는 요청과 일치하는 모든 route를 찾은 뒤 각각의 route에 점수를 부여하고 이 중에서 가장 낮은 점수의 route를 선택하게 됩니다. 점수 부여 방식이 복잡하지만 요청에 가장 구체적인 route를 선택하도록 하는 것입니다. 다시 말해 literal segment는 segment variable보다 우선시 되며 조건이 있는 segment variable는 없는 segment variable보다 더 우선시 됩니다.
만약 동일한 점수의 route가 2개 이상이 발견된다면 즉, 요청을 route 하기에 적합한 것이 다수 존재하는 경우에는 예외가 발생할 수 있습니다.
● Endpoint로 Middleware 재구성하기
Endpoint는 일반적으로 모든 segment variable을 열거하는 대신 특정한 segment 변수를 routing middleware에 의존하여 제공합니다. 특정 값을 제공하기 위해 URL pattern에 의존함으로써 예제에서의 Fruit과 Animal class를 route data에 대해 의존성을 갖게 함으로써 재구성할 수 있습니다.
namespace MyWebApp.Middleware
{
public class Animal
{
public static async Task Endpoint(HttpContext context)
{
string? sound = null;
string? animal = context.Request.RouteValues["animal"] as string;
switch ((animal ?? String.Empty).ToLower())
{
case "cat":
sound = "meow";
break;
case "dog":
sound = "bowwow";
break;
case "grape":
context.Response.Redirect($"/fruit/{animal}");
return;
}
if (sound != null)
await context.Response.WriteAsync(sound);
else
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
}
Middleware component는 endpoint로도 사용될 수 있지만 일단 routing middleware에서 제공되는 data에 의존성이 존재하게 되면 반대로는 불가능합니다. 예제에서는 animal이라는 이름의 segment variable값을 가져오기 위해 route data를 RouteValuesDictionary class에 정의된 indexer를 통하여 사용하였습니다.
indexer는 as keyword를 통해 string으로 변환된 객체를 반환하게 됩니다. 예제는 이러한 방식을 통하여 routing middleware가 endpoint를 대신하여 처리하는 pipeline로 요청을 전달하는 구문을 제거하게 되었습니다.
또한 segment variable를 사용하게 되면 요청에서 지원되지 않는 값을 가지고 endpoint로 route 될 수 있으므로 이러한 상황이 발생하는 경우 404 상태 값을 반환하는 처리를 추가하였습니다.
결과적으로 예제는 생성자를 제거하고 Invoke라는 instance method가 route에서 endpoint가 사용되는 방식과 더 잘 맞는 static method인 Enpoint() method로 교체하게 되었습니다. 다른 Fruit class도 Animal class와 같은 방식을 적용하여 아래와 같이 작성합니다.
namespace MyWebApp.Middleware
{
public class Fruit
{
public static async Task Endpoint(HttpContext context)
{
string? fruit = context.Request.RouteValues["fruit"] as string;
int? price = null;
switch ((fruit ?? string.Empty).ToLower())
{
case "watermelon":
price = 1_000;
break;
case "grape":
price = 2_000;
break;
case "orange":
price = 1_500;
break;
}
if (price.HasValue)
await context.Response.WriteAsync($"fruit: {fruit}, cost: {price}");
else
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
}
static method로의 변화를 통해 Program.cs에서는 아래와 같이 endpoint가 사용될 수 있도록 정리되어야 합니다.
app.MapGet("fruit/{fruit}", Fruit.Endpoint);
app.MapGet("animal/{animal}", Animal.Endpoint);
새로운 route는 2개의 segment를 가진 path와 일치하는데 첫 번째는 fruit혹은 animal이 되며 두 번째 segment가 fruit혹은 animal이라는 segment variable에 할당되고 endpoint가 URL을 직접적으로 처리하는 대신 URL의 full set을 지원할 수 있도록 합니다.
이러한 변화는 2가지의 문제를 해결해 줄 수 있습니다. 우선 여러 component가 아닌 routing middleware에서만 URL이 처리될 수 있으므로 효율성이 증대되며 URL Pattern을 통해 어떤 요청이 일치될 수 있는가를 보여주기 때문에 각각의 endpoint가 지원하는 URL을 쉽게 구분할 수 있습니다.
(7) Route로 부터 URL 생성하기
위 마지막 예제에서 남은 문제는 Animal이 여전히 Fruit에서 지원하는 URL에 대해 근본적인 의존성을 가진다는 것입니다. 이러한 의존성을 제거하기 위해 routing system은 segment variable에서 주어진 data값에 의해 URL이 생성될 수 있는 기능을 지원합니다. 이러한 기능을 이용하기 위한 첫 번째 과정으로 생성될 URL의 목표가 될 수 있는 route에 필요한 이름을 할당하는 것입니다.
app.MapGet("fruit/{fruit}", Fruit.Endpoint).WithMetadata(new RouteNameMetadata("fruit"));
app.MapGet("animal/{animal}", Animal.Endpoint);
WithMetadata method는 MapGet method의 결과에서 사용되며 route에 metadata를 할당합니다. URL을 생성하기 위해 metadata에서 필요한 것은 단지 새로운 RouteNameMetadata객체를 전달함으로써 할당되는 이름뿐이며 생성자 매개변수에서 이름을 특정하여 route에서 참조하는 데 사용될 수 있도록 합니다.
예제에서는 fruit이라는 이름을 통하여 route에 할당되도록 하였습니다.
route에서 이름을 지정하면 예상한 것 외에 다른 route를 대상으로 하는 link를 생성하지 않습니다. 그러나 누락되는 상황이 발생하는 경우 routing system은 가장 적합한 route 찾기를 시도할 것입니다.
아래 예제는 Animal class를 수정하여 /fruit에 대한 직접적인 의존성을 제거하고 URL을 생성하는 routing기능에 의존하도록 바꾼 것입니다.
switch ((animal ?? String.Empty).ToLower())
{
case "cat":
sound = "meow";
break;
case "dog":
sound = "bowwow";
break;
case "grape":
LinkGenerator? generator = context.RequestServices.GetService<LinkGenerator>();
string? url = generator?.GetPathByRouteValues(context, "fruit", new { fruit = animal });
if (url != null)
context.Response.Redirect(url);
return;
}
URL은 LinkGenerator class를 통해 생성하였습니다. LinkGenerator는 새로운 instance를 생성할 수 없고 의존성 주입을 통해 instance를 가져와야 하며 LinkGenerator class의 GetPathByRouteValues() method를 통해 redirection에 사용될 URL을 생성하고 있습니다. GetPathByRouteValues method에서는 3개의 매개변수가 사용되고 있는데 첫 번째로는 endpoint의 HttpContext 객체가 되며 두 번째는 link를 생성하는 데 사용하게 될 route의 이름, 그리고 마지막 세 번째는 segment 변수의 값을 제공하는 데 사용하게 될 객체가 됩니다. 예제에서 GetPathByRouteValues method는 fruit로 route 하게 될 URL을 반환합니다.
project를 실행하여 /animal/grape로 요청을 수행하게 되면 해당 요청은 animal endpoint로 route 되어 redirect를 위한 URL을 생성하게 되고 webbrowser는 해당 URL로 곧장 redirect를 진행할 것입니다.
이러한 처리방식은 명명된 route의 URL pattern을 자연스럽게 반영하여 endpoint를 바꾸지 않고도 필요한 URL을 생성할 수 있도록 합니다. 따라서 극단적으로 아래와 같이 usertype이라는 전혀 새로운 이름을 할당한다고 하더라도 endpoint의 수정 없이 같은 endpoint를 target으로 한 URL이 생성될 수 있는 것입니다.
app.MapGet("userType/{fruit}", Fruit.Endpoint).WithMetadata(new RouteNameMetadata("fruit"));
app.MapGet("animal/{animal}", Animal.Endpoint);
URL Routing System은 또한 Areas라는 기능을 지원하고 있습니다. ASP.NET에서 Areas는 자신만의 Controller, View 등을 가진 채로 Application안에서 완전히 다른 영역으로 route가 분리될 수 있는 기능을 말합니다. 그러나 Areas기능은 Routing을 복잡하게 만드는 경향 때문에 잘 사용되지 않는 기능이며 하나의 Application안에서 Areas를 통해 Route영역을 분리하기보다는 이러한 기능이 필요한 경우 별도의 Project를 생성하여 문제를 해결하기를 권장합니다.
2. URL Matching 관리
지금까지는 URL Routing에 관한 기본적인 기능을 살펴보았는데 어떤 Application의 경우에는 URL을 필요한 곳으로 정확히 Route 하기 위해 route에 의해 일치되는 URL의 범위를 제한하거나 증가시키는 등의 더 많은 작업이 요구되는 경우도 있습니다. 이에 따라 matching process를 조정하기 위해 URL Paatern을 바꿀 수 있는 다른 방법에 대해 알아보고자 합니다.
(1) 단일 URL segment로부터 여러 값을 Matching 하기
대부분의 segment변수들은 URL 경로의 segment와 정확히 일치하지만 routing middleware는 불필요한 문자를 삭제하면서 단일 segment를 변수에 일치시킴으로써 더 복잡한 matching을 수행할 수 있습니다. 아래 예제는 URL segment의 일부만을 변수에 일치시키는 route를 정의한 것입니다.
using Microsoft.Extensions.Options;
using MyWebApp.Middleware;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("files/{filename}.{extension}", async context =>
{
await context.Response.WriteAsync("Request Was Routed\n");
foreach (var value in context.Request.RouteValues)
{
await context.Response.WriteAsync($"{value.Key}: {value.Value}\n");
}
});
app.MapGet("fruit/{fruit}", Fruit.Endpoint).WithMetadata(new RouteNameMetadata("fruit"));
app.MapGet("animal/{animal}", Animal.Endpoint);
app.Run();
예제를 통해 URL segment는 정적 문자열을 사용해 변수를 분리시킴으로써 필요한 다수의 값을 가져올 수 있습니다. 정적 분리는 routing middleware가 하나의 변수가 끝나는 것과 다음이 시작되는 것을 알도록 하기 위한 것입니다. 예제에서 pattern은 filename과 extenstion이라는 이름의 segment변수와 일치하며 이들은 마침표를 통해 분리하였습니다. project를 실행하여 /files/aaa.txt와 같은 URL을 요청하여 아래 결과가 나오는지를 확인합니다.
위 예제를 자세하 보면 오른쪽에서 왼쪽으로 일치되는 순서의 변수를 포함하는 pattern segment를 보여주고 있습니다. endpoint는 특정 키값의 순서에 의존하지 않기 때문에 대부분의 경우 이러한 현상은 중요하지 않을 수 있지만 다소 복합한 URL Pattern의 경우 생각과는 다르게 처리될 수 있음을 암시하기도 합니다.
사실 maching process는 너무 어려워서 예상치 못한 matching 오류가 발생할 수 있는데 특정한 문제는 ASP.NET Core의 각 release에 따라 문제를 해결하기 위해 matching process를 조정하면서 바뀔 수 있습니다. 그러나 matching process를 조정하는 이러한 과정에서도 새로운 issue사항이 종종 나타나기도 합니다. 현재도 발생하는 여러 문제가 다음 release에서 수정될 수 있지만 가능한 한 URL segment를 간단하게 만드는 것이 좋습니다.
(2) segment 변수의 기본값 사용
Pattern은 URL이 segment에 해당하는 값을 포함하고 있지 않은 경우 사용될 수 있는 기본값을 정의하여 route가 일치할 수 있는 URL의 범위를 넓힐 수 있습니다.
app.MapGet("fruit/{fruit}", Fruit.Endpoint).WithMetadata(new RouteNameMetadata("fruit"));
app.MapGet("animal/{animal=cat}", Animal.Endpoint);
기본값은 =문자를 통해 할당되고 사용될 수 있으며 예제와 같이 기본값을 정의함으로써 /animal 만으로 이루어진 URL은 곧 animal/cat으로 route 될 수 있습니다.
(3) URL Pattern에서 선택적 segment의 사용
기본값을 사용하면 본래보다 더 적은 URL과 일치될 수 있으나 endpoint입장에서는 항상 기본값이 사용될 거라고 장담할 수 없으므로 segment가 생략된 URL을 처리하기 위해 선택적 segment의 사용을 통하여 자체적인 응답을 정의하기도 합니다. 아래 예제는 이러한 방식으로 수정된 animal endpoint를 보여주고 있습니다.
string? sound = null;
string? animal = context.Request.RouteValues["animal"] as string ?? "cat";
switch ((animal).ToLower())
{
case "cat":
sound = "meow";
break;
case "dog":
sound = "bowwow";
break;
case "grape":
LinkGenerator? generator = context.RequestServices.GetService<LinkGenerator>();
string? url = generator?.GetPathByRouteValues(context, "fruit", new { fruit = animal });
if (url != null)
context.Response.Redirect(url);
return;
}
예제는 가능한 animal이 존재하지 않는 경우 cat으로 대신할 수 있도록 처리하였으며 이에 따라 위에서 구현된 기본값을 사용하는 부분도 변수가 null이 될 수 있도록? 문자를 사용해 아래와 같이 변경할 수 있습니다.
app.MapGet("animal/{animal?}", Animal.Endpoint);
project를 실행하여 /animal로 URL을 요청하여 아래와 같은 결과가 나오는지를 확인합니다.
(4) catchall Segment 변수
위에서 사용한 기본값은 pattern을 본래보다 더 적은 URL에 일치시킬 수 있도록 합니다. 이와 반대로 catchall segment는 오히려 pattern보다 더 많은 segment를 가진 URL을 일치시키기 위한 것입니다. cathall segment는 아래 예제처럼 변수의 앞에 *문자를 사용하여 표현됩니다.
app.MapGet("{first}/{second}/{*third}", async context =>
{
await context.Response.WriteAsync("Request Was Routed\n");
foreach (var value in context.Request.RouteValues)
{
await context.Response.WriteAsync($"{value.Key}: {value.Value}\n");
}
});
예제에서 pattern은 2개의 segment와 하나의 catchall을 포함하고 있으므로 2개 혹은 그 이상의 segment를 가진 모든 URL과 일치될 것입니다. URL pattern에서 사용 가능한 segment 수에는 제한이 없으며 추가되는 모든 segment는 third라는 segment변수에 할당될 것입니다.
project를 실행하여 /a/b/d/d/e/f와 같은 URL을 요청하여 아래와 같은 결과가 표시되는지를 확인합니다.
(5) 제한조건 사용하기
위에서 알아본 기본값, 선택적 segment, catchall 등은 모두 pattern이 일치할 수 있는 URL의 범위를 증가시키는 것이 주된 역할입니다. 그러나 이와 반대로 matching을 제한할 수도 있는데 endpoint가 특정한 segment만을 처리하거나 서로 밀접하게 관련된 URL을 서로 다른 endpoint에 대해서 구별하기 위해서라면 이와 같은 제한 설정을 사용해야 합니다.
제한 설정은 :문자를 segment변수 이름 다음에 사용해야 하며 그 뒤에 제한하고자 하는 type을 설정합니다.
app.MapGet("{first:int}/{second:bool}", async context =>
예제에 따라 첫 번째 segment 변수는 int형으로 취급될 수 있는 값에서만 일치되며 두 번째 segment 변수는 bool형으로 취급될 수 있는 값에서만 일치하게 됩니다.
몇몇 제한조건은 지역화에 따라 달라질 수 있는 형식의 유형과 일치될 수 있습니다. routing middleware는 지역화된 형식을 따로 처리하는 것 없이 값 자체로서만 일치합니다.
아래 표는 기본적으로 사용할 수 있는 기타 제약조건의 형식을 나열한 것입니다.
alpha | a~z까지의 문자와 일치되며 대소문자를 구분하지 않습니다. |
bool | true와 false에서만 일치되며 대소문자를 구분하지 않습니다. |
datetime | 비지역화된 불변의 문화권형식으로 표현된 DateTime값과 일치합니다. |
decimal | 비지역화된 불변의 문화권형식으로 표현된 decimal값과 일치합니다. |
double | 비지역화된 불변의 문화권형식으로 표현된 double값과 일치합니다. |
file | '파일명.확장자'형식의 segment와 일치합니다. |
float | 비지역화된 불변의 문화권형식으로 표현된 float값과 일치합니다. |
guid | guid형식의 값과 일치합니다. |
int | int형식의 값과 일치합니다. |
length(len) | 지정된 수의 문자를 가진 segment와 일치합니다. |
length(min, max) | 최소, 최대사이에 들어갈 수 있는 문자를 가진 segment와 일치합니다. |
long | long형식의 값과 일치합니다. |
max(val) | 지정된 값보다 더 작거나 같은 값을 가진 int형식의 segment와 일치합니다. |
maxlength(len) | 지정된 값보다 저 작거나 같은 값을 가진 길이의 segment와 일치합니다. |
min(val) | 지정된 값보다 더 많거나 같은 값을 가진 int형식의 segment와 일치합니다. |
minlength(len) | 지정된 값보다 더 많거나 같은 값을 가진 길이의 segment와 일치합니다. |
nonfile | 파일이름으로 표현될 수 없는 다시말해 file 제약조건에 일치하지 않는 segment와 일치합니다. |
range(min, max) | 지정된 값사이에 포함될 수 있는 int형식의 segment와 일치합니다. |
regex(expression) | 지정한 정규식에 해당하는 segment와 일치합니다. |
project를 실행하여 /100/true와 같은 URL로 요청해 아래와 같은 결과가 나오는지를 확인합니다.
물론 해당 형식으로 지정되지 않은 예를 들어 /100/aaa와 같은 URL은 필요한 segment의 수에는 일치하지만 제약조건에 일치하지 않는 값을 가진 경우이기 때문에 어떠한 route와도 일치하지 않게 되어 아래와 같은 결과를 표시하게 됩니다.
제한조건은 필요하다면 아래와 같은 여러 개의 조건들을 결합할 수도 있습니다.
app.MapGet("{first:alpha:length(3)}/{second:bool}", async context =>
위와 같이 결합된 제한조건에서는 URL segment가 제한조건을 모두 만족시키는 경우에만 일치됩니다.
● regex사용
regex 제한조건을 사용하면 정규식을 통해 제한조건을 설정할 수 있습니다. 정규식은 어떤 형태로든지 필요한 만큼 생성할 수 있는데 예를 들어 다음과 같이 animal route에 올 수 있는 segment값으로 cat과 dog만을 지정할 수도 있습니다.
app.MapGet("animal/{animal:regex(^cat|dog$)}", Animal.Endpoint);
따라서 위 route의 경우 animal/cat 혹은 animal/dog에서만 일치될 것입니다.
(6) Fallback Route 정의하기
Fallback의 역할은 요청이 어떠한 route와도 일치하지 않는 경우 해당 요청을 직접 route 하는 것입니다. 이것으로 요청이 더 이상 요청 pipeline을 따라 전달되지 않도록 하고 자신이 직접 요청을 처리하게 됨으로써 route system이 어떠한 경우에도 응답을 생성할 수 있음을 보장할 수 있습니다.
app.MapFallback(async context =>
{
await context.Response.WriteAsync("fallback endpoint입니다.");
});
app.Run();
FaillBack method는 모든 요청에 대응하는 마지막 수단으로써 사용될 route를 생성하여 요청을 처리하게 되는데 아래 표는 이때 사용되는 method를 나열한 것입니다.(이 외에 fallback route를 생성하는 다른 Method도 존재하는데 이 부분에 관해서는 추후에 알아볼 것입니다.)
MapFallback(endpoint) | endpoint로 요청을 route할 fallback을 생성합니다. |
MapFallbackToFile(path) | file로 요청을 route하는 fallback을 생성합니다. |
어떠한 일반 route와도 일치하지 않는 것을 포함해서 모든 요청은 위에서 추가한 route에서 처리하게 될 것입니다. 따라서 project를 시작하면 곧장 아래와 같은 결과를 보게 됩니다.
본래 / 로이 URL요청은 일치되는 route가 존재하지 않았기 때문에 통상 오류가 표시되었지만 지금은 fallback route에서 요청을 처리하게 되었으므로 다른 결과가 나타나게 됩니다.
3. 더 나은 routing 기능
이제까지 살펴본 routing기능은 특히 MVC Framework와 같은 고수준 기능을 통해 접근되기 때문에 대부분의 project에서 필요로 하는 요구사항을 해결할 수 있습니다. 하지만 일반적이지 않은 상황 역시 얼마든지 발생할 수 있으므로 이에 대한 대응에 필요한 routing기능들도 존재합니다.
(1) 사용자 제한조건 생성하기
이전에 언급했던 제한조건만으로는 충분하지 않은 경우 IRouteConstraint interface를 구현함으로써 자신만의 제한조건을 직접 설정할 수 있습니다. 이를 위해 project에 Infrastructure라는 folder를 추가하고 해당 folder에 다시 Constraint.cs file을 생성한 뒤 아래와 같이 file을 구현합니다.
namespace MyWebApp.Infrastructure
{
public class Constraint : IRouteConstraint
{
private static string[] countries = { "cat", "dog" };
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
{
string segmentValue = values[routeKey] as string ?? String.Empty;
return Array.IndexOf(countries, segmentValue.ToLower()) > -1;
}
}
}
IRouteConstraint interface는 Match method를 정의하고 있는데 이 method는 요청이 route와 일치될 수 있는지의 여부를 제한조건에서 결정하기 위해 호출됩니다. Match method에서 paramter로 제공되는 HttpContext는 요청에 대한 객체로 route, segment 명, URL로 부터 추출된 segment 변수와 요청이 들어오는 URL 또는 나가는 URL을 확인할지의 여부를 제공하며 method실행결과 요청이 제약조건에 만족한다면 true를 그렇지 않으면 false를 반환합니다. 예제는 segment의 변수와 비교하기 위한 cat, dog라는 일련의 문자열을 정의하고 있고 segment가 이 값들 중 하나가 된다면 제약조건에 만족하게 될 것입니다. 이렇게 만들어진 사용자 지정 제약조건은 아래와 같이 options pattern을 통해 설정할 수 있습니다.
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RouteOptions>(opts => {
opts.ConstraintMap.Add("animalName",
typeof(Constraint));
});
var app = builder.Build();
app.MapGet("animal/{animal:animalName}", Animal.Endpoint);
options pattern은 ConstraintMap 속성을 정의하고 있는 RouteOptions class에 적용되는데 이때 제약조건은 URL pattern에 적용할 key를 통해 등록됩니다. 예제에서 Constraint class로 등록할 key로 animalName이 사용되었으며 해당 key는 예제의 animal:animalName처럼 route의 제약조건을 설정할 때 사용할 수 있습니다. 따라서 요청은 첫 번째 segment가 animal이며 다음 segment가 cat이나 dog 중 하나인 경우에 route와 일치될 것입니다.
(2) route의 모호함 제거하기
요청을 route 하려고 할 때 routing middleware는 각각의 route에 점수를 할당합니다. 좀 더 구체적인 route에 더 높은 우선권을 부여하는 방식으로 route의 선택 처리는 단순하게 이루어지지만 이것에 관해 충분히 생각하지 않고 Application에서 처리될 URL을 완벽히 test하지 않는다면 엉뚱한 결과를 마주하게 될 수 있습니다.
만약 2개 이상의 route가 같은 점수를 가지게 된다면 routing system은 특정 route를 선택할 수 없게 되고 route가 모호하다는 예외를 발생시키게 됩니다. 이런 경우 대부분 가장 좋은 처리방법은 모호한 route에 literal segment나 제약조건을 추가하여 구체성을 더 높이는 것입니다. 물론 그렇게 하는 것이 불가능한 상황이 있을 수 있으나 어찌 되었건 routing이 의도적으로 작동하도록 하기 위해서는 몇 가지 추가적인 작업들이 필요합니다.
var app = builder.Build();
app.Map("{any:int}", async context =>
{
await context.Response.WriteAsync("int=> endpoint");
});
app.Map("{any:double}", async context =>
{
await context.Response.WriteAsync("double=> endpoint");
});
app.MapFallback(async context =>
{
await context.Response.WriteAsync("fallback endpoint");
});
app.Run();
위 예제에서는 2개의 새로운 route를 추가하였는데 첫 번째 route는 segment가 int형으로 파단되는 경우, 두 번째 route는 segment가 double형으로 오는 경우에 대한 route입니다. 그러나 이 route는 double형으로 오는 경우에만 하나의 route로 구분될 수 있으나 segment가 int와 double 두 개의 type으로 parsing이 가능한 경우에는 2개의 route에 일치될 수 있기 때문에 모호함에 빠질 수 있습니다. 예를 들어
project를 실행하여 /12.3으로 URL을 요청하게 되면 아래와 같은 결과를 볼 수 있지만
/12로만 URL을 요청하면 예외가 발생하게 됩니다.
즉 12라는 값은 int로도 double로도 처리될 수 있기 때문에 routing system이 요청을 처리하기 위한 routing을 식별하지 못한 것입니다.
이런 상황에서는 서로 일치되는 route끼리 상대적인 순서를 부여함으로써 route의 선호도를 지정할 수 있습니다.
app.Map("{any:int}", async context =>
{
await context.Response.WriteAsync("int=> endpoint");
}).Add(b => ((RouteEndpointBuilder)b).Order = 1);
app.Map("{any:double}", async context =>
{
await context.Response.WriteAsync("double=> endpoint");
}).Add(b => ((RouteEndpointBuilder)b).Order = 2);
예제는 각 route에 RouteEndpointBuilder로 casting 하여 Order속성의 값을 설정하는 Add method를 추가하였습니다. 이때 우선권은 가능한 가장 낮은 값을 통해서 부여하였는데 routing system이 요청을 처리할 수 있는 2개 이상의 route를 발견할 때 이 우선권을 통해 낮은 값의 route를 선택하게 됩니다.
project를 시작하고 다시 /12로 URL을 요청하면 이번에는 예외 대신 아래와 같은 결과를 볼 수 있게 됩니다.
(3) Middleware Component에서 endpoint로의 접근
이전에도 살펴본 바와 같이 모든 middleware가 응답을 생성하지는 않습니다. 몇몇 Component는 session middleware와 같이 요청 pipeline이후에 사용될 기능을 제공하거나 status code middleware와 같은 방식으로 응답성을 향상합니다.
일반 요청 pipeline의 한 가지 한계점은 pipeline에서 시작하는 middleware component는 이후에 어떤 component에서 응답을 생성할지 알 수 없다는 것입니다.
그러나 routing middleware는 약간 다르게 동작하는데 이전에 언급한 것처럼 routing은 UseRouting와 UseEndpoints method를 명시적으로 호출하거나 ASP.NET Core platform에 의존하여 시작 중에 호출함으로써 설정됩니다. 비록 routing은 UseEndpoints method에서 등록되지만 route의 선택은 UseRouting method에서 결정되며 endpoint는 UseEndpoints method에서 응답을 생성하기 위해 실행됩니다. UseRouting과 UseEndpoints method사이의 요청 pipeline에 추가된 어떠한 middleware component라도 응답이 생성되기 전에 선택된 endpoint를 알 수 있으며 그에 따라 동작을 변경합니다
var app = builder.Build();
app.Use(async (context, next) =>
{
Endpoint? end = context.GetEndpoint();
if (end != null)
await context.Response.WriteAsync($"{end.DisplayName} Selected \n");
else
await context.Response.WriteAsync("No Endpoint Selected \n");
await next();
});
app.Map("{any:int}", async context =>
{
await context.Response.WriteAsync("int=> endpoint");
}).WithDisplayName("Int Endpoint").Add(b => ((RouteEndpointBuilder)b).Order = 1);
app.Map("{any:double}", async context =>
{
await context.Response.WriteAsync("double=> endpoint");
}).WithDisplayName("Double Endpoint").Add(b => ((RouteEndpointBuilder)b).Order = 2);
예제에서는 요청을 처리하기 위해 선택된 route의 응답에 다른 message를 추가하는 middleware component를 생성한 것입니다.
GetEndPoint와 반대로 SetEndPoint라는 method도 존재합니다. 이 method는 routing middleware에 선택한 endpoint가 응답이 생성되기 이전에 바뀔 수 있도록 합니다. 이 method는 신중하 사용되어야 하며 정상적인 Path process를 방해할 필요가 있는 경우에만 사용되어야 합니다.
HttpContext class의 GetEndpoint 확장 method는 요청을 처리하기 위해 선택된 endpoint를 반환하며 해당 Endpoint class에서는 다음과 같은 속성을 정의하고 있습니다.
DisplayName | 이 속성은 route가 생성될때 WithDisplayName method를 통해 설정된 endpoint의 DisplayName값을 반환합니다. |
Metadata | endpoint와 관련된 metadata의 collection을 반환합니다. |
RequestDelegate | 응답을 생성하는데 사용될 delegate를 반환합니다. |
특히 WithDisplayName method를 예제에서는 routing middleware가 선택한 endpoint를 더 쉽게 식별하도록 하기 위해 route에 이름을 부여하는 데 사용하였고 위와 같이 추가된 middleware component는 응답에 message를 추가하여 선택된 endpoint가 무엇인지를 알려주도록 하였습니다.
project를 실행하여 /12와 같은 URL을 요청하여 요청 pipeline에 routing middleware를 추가하는 2개의 method 중 선택된 endpoint가 무엇인지를 나타내는 middleware component의 결과를 확인합니다.