Используя ASP.NET Web API, мой ExecutionContext не течет в асинхронных действиях
Мне трудно понять механику ExecutionContext.
Из того, что я читал в Интернете, контекстно-зависимые элементы, такие как безопасность (Thread Principal), культура и т.д., должны проходить через асинхронные потоки в пределах рабочей единицы выполнения.
Я встречаюсь с очень запутанными и потенциально опасными ошибками. Я замечаю свою нить CurrentPrincipal теряется в процессе асинхронного выполнения.
Вот пример сценария веб-API ASP.NET:
Сначала настройте простую конфигурацию Web API с двумя обработчиками делегирования для целей тестирования.
Все, что они делают, это выписывать отладочную информацию и передавать запрос/ответ, за исключением первого "DummyHandler", который устанавливает директиву потоков, а также часть данных, которые должны быть разделены по контексту (идентификатор корреляции запросов).
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.MessageHandlers.Add(new DummyHandler());
config.MessageHandlers.Add(new AnotherDummyHandler());
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
}
public class DummyHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallContext.LogicalSetData("rcid", request.GetCorrelationId());
Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));
Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));
return base.SendAsync(request, cancellationToken)
.ContinueWith(task =>
{
Debug.WriteLine("Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine("User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine("RCID: {0}", CallContext.LogicalGetData("rcid"));
return task.Result;
});
}
}
public class AnotherDummyHandler : MessageProcessingHandler
{
protected override HttpRequestMessage ProcessRequest(HttpRequestMessage request, CancellationToken cancellationToken)
{
Debug.WriteLine(" Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine(" User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine(" RCID: {0}", CallContext.LogicalGetData("rcid"));
return request;
}
protected override HttpResponseMessage ProcessResponse(HttpResponseMessage response, CancellationToken cancellationToken)
{
Debug.WriteLine(" Another Dummy Handler Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine(" User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine(" RCID: {0}", CallContext.LogicalGetData("rcid"));
return response;
}
}
Прост достаточно. Затем добавьте один ApiController для обработки HTTP POST, как если бы вы загружали файлы.
public class UploadController : ApiController
{
public async Task<HttpResponseMessage> PostFile()
{
Debug.WriteLine(" Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine(" User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine(" RCID: {0}", CallContext.LogicalGetData("rcid"));
if (!Request.Content.IsMimeMultipartContent())
{
throw new HttpResponseException(HttpStatusCode.UnsupportedMediaType);
}
try
{
await Request.Content.ReadAsMultipartAsync(
new MultipartFormDataStreamProvider(
HttpRuntime.AppDomainAppPath + @"upload\temp"));
Debug.WriteLine(" Thread: {0}", Thread.CurrentThread.ManagedThreadId);
Debug.WriteLine(" User: {0}", (Object)Thread.CurrentPrincipal.Identity.Name);
Debug.WriteLine(" RCID: {0}", CallContext.LogicalGetData("rcid"));
return new HttpResponseMessage(HttpStatusCode.Created);
}
catch (Exception e)
{
return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, e);
}
}
}
После запуска теста с Fiddler, это результат, который я получаю:
Dummy Handler Thread: 63
User: dgdev
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Another Dummy Handler Thread: 63
User: dgdev
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Thread: 63
User: dgdev
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Thread: 77
User: <<< PRINCIPAL IS LOST AFTER ASYNC
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Another Dummy Handler Thread: 63
User: <<< PRINCIPAL IS STILL LOST
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Dummy Handler Thread: 65
User: dgdev <<< PRINCIPAL IS BACK?!?
RCID: 6d542847-4ceb-4511-85e5-d1b5bf3be476
Чтобы сделать вещи более запутанными, когда я добавляю следующие строки async:
await Request.Content.ReadAsMultipartAsync(
new MultipartFormDataStreamProvider(..same as before..))
.ConfigureAwait(false); <<<<<<
Теперь я получаю этот вывод:
Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Another Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Thread: 65
User: dgdev <<< PRINCIPAL IS HERE!
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Another Dummy Handler Thread: 65
User: <<< PRINCIPAL IS LOST
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Dummy Handler Thread: 40
User: dgdev
RCID: 8d944500-cb52-4362-8537-dab405fa12a2
Дело здесь в этом. Код, следующий за async my, фактически вызывает мою бизнес-логику или просто требует, чтобы контекст безопасности был правильно установлен. Существует проблема с потенциальной целостностью.
Может ли кто-нибудь помочь пролить свет, что происходит?
Спасибо заранее.
Ответы
Ответ 1
У меня нет всех ответов, но я могу помочь заполнить некоторые пробелы и угадать проблему.
По умолчанию поток ASP.NET SynchronizationContext
будет протекать, но то, как он передает идентификатор, немного странно. Он фактически течет HttpContext.Current.User
, а затем устанавливает для него Thread.CurrentPrincipal
. Поэтому, если вы просто установите Thread.CurrentPrincipal
, вы не увидите, что он работает правильно.
На самом деле вы увидите следующее поведение:
- С момента установки
Thread.CurrentPrincipal
в потоке этот поток будет иметь тот же самый главный, пока он не войдет в контекст ASP.NET.
- Когда какой-либо поток входит в контекст ASP.NET,
Thread.CurrentPrincipal
очищается (потому что он установлен в HttpContext.Current.User
).
- Когда поток используется вне контекста ASP.NET, он просто сохраняет все
Thread.CurrentPrincipal
, которые были установлены на нем.
Применяя это к исходному коду и выводить:
- Первые 3 все синхронно сообщаются из потока 63 после того, как его
CurrentPrincipal
был явно установлен, поэтому все они имеют ожидаемое значение.
- Thread 77 используется для возобновления метода
async
, тем самым введя контекст ASP.NET и очистив любой CurrentPrincipal
, который он мог иметь.
- Резьба 63 используется для
ProcessResponse
. Он возвращается в контекст ASP.NET, очищая его Thread.CurrentPrincipal
.
- Тема 65 интересна. Он работает за пределами контекста ASP.NET(в
ContinueWith
без планировщика), поэтому он просто сохраняет все CurrentPrincipal
, которые имели место раньше. Я предполагаю, что его CurrentPrincipal
просто оставлен после более раннего тестового прогона.
Обновленный код изменяет PostFile
, чтобы запустить свою вторую часть за пределами контекста ASP.NET. Таким образом, он поднимает поток 65, который просто имеет набор CurrentPrincipal
. Поскольку он вне контекста ASP.NET, CurrentPrincipal
не очищается.
Итак, мне кажется, что ExecutionContext
течет отлично. Я уверен, что Microsoft протестировала ExecutionContext
поток из wazoo; в противном случае каждое приложение ASP.NET в мире будет иметь серьезную проблему безопасности. Важно отметить, что в этом коде Thread.CurrentPrincipal
просто ссылаются на текущие претензии пользователя и не представляют фактического олицетворения.
Если мои догадки верны, то исправление довольно просто: в SendAsync
измените эту строку:
Thread.CurrentPrincipal = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));
:
HttpContext.Current.User = new ClaimsPrincipal(new ClaimsPrincipal(new ClaimsIdentity(new[]{ new Claim("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "dgdev") }, "myauthisthebest")));
Thread.CurrentPrincipal = HttpContext.Current.User;
Ответ 2
Я понимаю, что повторный вход в контекст синхронизации ASP.NET приведет к тому, что для Thread.CurrentPrincipal будет установлено значение HttpContext.Current.User. Но я все еще не вижу поведение, которое я ожидал. Я не ожидал, что при каждом ожидаемом вызове цепочки будет установлен Thread.CurrentPrincipal = HttpContext.Current.User. Я вижу, что это даже выходит за пределы обработчика событий async void, в котором я запустил цепочку async/await. Это поведение других людей? Я ожидал, что вызовы в цепочке будут использовать их захваченный контекст для продолжения, но они демонстрируют реентерабельное поведение.
Я не использую .ContinueAwait(false) ни в одном из ожидаемых звонков. У нас есть targetFramework = "4.6.1" в файле web.config, который, помимо прочего, устанавливает UseTaskFriendlySynchronizationContext = true, среди прочего. Сторонний API-клиент вызывает реентерабельное поведение в нижней части цепочки async/await.