Jak automatycznie ponawiać operacje oraz cache’ować dane z interceptorami w Autofac?

Wprowadzenie

W aplikacji mamy czasami fragmenty kodu, które dodajemy w różnych miejscach. Tak jak ostatnio opisywałem, możemy chcieć dodać cache’owanie danych na poziomie logiki biznesowej, aby zmniejszyć liczbę zapytań do bazy. W przykładzie dotyczącym CacheManagera wywołanie usługi CacheService dodałem bezpośrednio w kodzie logiki. Z jednej strony tego kodu nie ma zbyt dużo, ale z drugiej strony fajnie byłoby mieć ten kod automatycznie w każdej logice biznesowej. Szczególnie że wcześniej czy później ktoś zapomni go dodać.

Podobna sytuacja ma miejsce w przypadku ponawiania operacji. Biblioteka Polly, którą opisywałem ostatnio, jest bardzo fajna i warto, aby automatycznie ponawiała operacje na bazie w momencie, gdy serwer nie odpowiada. Również tego kodu nie chcielibyśmy pisać za każdym razem.

Problem ten możemy rozwiązać na kilka sposób. Możemy użyć na przykład szablonów T4, które wygenerują nam cały potrzebny kod. Możemy również użyć tytułowych interceptorów, które umożliwią dodanie kodu w sposób dynamiczny i automatyczny do wszystkich klas, które tego potrzebują. Zobacz, jak to zrobić w Autofacu.

Interceptor

Interceptor jest fragmentem kodu, który jest wykonywany zamiast właściwej metody, która została wywołana. Dzięki temu możemy kontrolować jej wykonywanie w sposób deklaratywny bez zmiany już istniejącego kodu. Dzięki interceptorowi możemy wykonać jakąś logikę przed wywołaniem właściwej metody lub po jej wywołaniu. Możemy nawet zablokować jej wywołanie lub wywołać metodę ponownie w momencie wystąpienia błędu.

Dzięki interceptorom możemy dodać jakąś logikę wręcz do wszystkich metod w aplikacji. Jest to fajne, ale niesie też za sobą sporo problemów. Taka magia może być przede wszystkim niezrozumiała dla nowych osób w zespole, ponieważ nie widać jej bezpośrednio w kodzie. Musimy również pamiętać, że taki dynamiczny element zawsze ma też swój narzut wydajnościowy, który czasami może być istotny.

Zobacz na kilku przykładach, jak dodać do aplikacji interceptory z wykorzystaniem kontenera Autofac.

LoggerInterceptor

Pierwszym interceptorem, jaki dodamy do testowej aplikacji, będzie LoggerInterceptor. Jego zadanie polegać będzie na zapisywaniu w pliku logu informacji o wywołaniach poszczególnych metod. Przed wywołaniem metody zapiszemy jej nazwę w logu, podobnie będzie również po wywołaniu. Ten interceptor pomoże nam później w analizie kolejnych interceptorów.

Aby Autofac wspierał używanie interceptorów w aplikacji, musimy zainstalować dodatkowy pakiet z nugeta (Autofac.Extras.DynamicProxy). W tym pakiecie znajduje się interfejs IInterceptor, który musimy zaimplementować, aby nasza klasa była interceptorem. Interfejs definiuje jedną metodę, która będzie wywoływana zamiast właściwej implementacji. Poniżej logowanie wywoływania metod przez LoggerInterceptor:

public class LoggerInterceptor : IInterceptor
{
private static NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();
public void Intercept(IInvocation invocation)
{
_logger.Info($"Before call: {invocation.TargetType.Name}.{invocation.Method.Name}");
invocation.Proceed();
_logger.Info($"After call: {invocation.TargetType.Name}.{invocation.Method.Name}");
}
}

Jak widzisz, nie ma tutaj jakiegoś skomplikowanego kodu. Za pomocą nLoga zapisujemy informacje o wywołaniach. Parametr invocation metody udostępnia nam informacje o właściwej metodzie. Wiemy, jak się ona nazywa oraz z jakiego typu pochodzi.

Kluczowym elementem interceptora jest wywołanie metody Proceed, która tak naprawdę wykonuje właściwą metodę. Gdy jej nie wywołamy,  właściwa metoda nie zostanie wykonana. W kolejnym interceptorze wykorzystamy tę możliwość.

Aby wykorzystać interceptor w aplikacji, musimy nieco zmodyfikować rejestrację typów w kontenerze. Poniżej przedstawiłem rejestrację klas logiki biznesowej z dodaniem interceptora do wywołań metod:

public class LogicModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterAssemblyTypes(typeof(ILogic<>).Assembly)
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILogic<>)))
.AsImplementedInterfaces()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(LoggerInterceptor));
}
}
view raw LogicModule.cs hosted with ❤ by GitHub

Aby skorzystać z interceptora, musimy zrobić dwie rzeczy podczas rejestracji: wywołać metodę EnableInterfaceInterceptors oraz za pomocą metody InterceptedBy przekazać typ interceptora.

Dodatkowo trzeba również zarejestrować sam interceptor w kontenerze:

public class InterceptorsModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<LoggerInterceptor>()
.SingleInstance();
}
}

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

LoggerInterceptor – działanie

Po wykonaniu powyższych kroków interceptor będzie już przechwytywał wywołania logiki biznesowej. Przykład na githubie ma również użyty ten interceptor w klasach repozytorium. Poniżej znajduje się fragment pliku logu, który zawiera wynik działania interceptora dla akcji Index kontrolera ProductsController, która wywołuje metodę GetAllActive logiki biznesowej i później niżej metodę GetAllActive z repozytorium:

2018-09-06 05:51:24.8486 INFO Before call: ProductLogic.GetAllActive
2018-09-06 05:51:24.8905 INFO Before call: ProductRepository.GetAllActive
2018-09-06 05:51:27.0856 INFO After call: ProductRepository.GetAllActive
2018-09-06 05:51:27.0856 INFO After call: ProductLogic.GetAllActive
view raw log1.txt hosted with ❤ by GitHub

Jak widać, interceptor działa i zapisuje informacje o wywołaniach. Co fajne, dodanie interceptora w ten sposób spowoduje, że będzie on działał dla wszystkich wywołań metody z logiki biznesowej. Kiedy dodamy nowe klasy do aplikacji, będą one od razu miały dodane to zachowanie – i to bez pisania za każdym razem tego samego kodu.

CacheInterceptor

Kolejnym interceptorem, który Ci przedstawię, będzie interceptor, który dodaje do aplikacji w sposób deklaratywny cache’owanie danych. We wpisie o CacheManagerze pokazałem, jak cache’ować dane, pisząc kod bezpośrednio w klasie logiki biznesowej. Tamto podejście miało ten problem, że trzeba pamiętać o kodzie związanym z cache’owaniem. Przez to czasami programista potrafi zapomnieć dodać np. usuwanie danych z cache podczas aktualizacji danych, co w efekcie powoduje wyświetlanie przez aplikację starych danych. Dlatego warto dodać do aplikacji cache’owanie z wykorzystaniem interceptora.

Poniżej znajduje się kod interceptora. Wykorzystuje on tę samą usługę, którą pokazałem Ci we wpisie o CacheManagerze. W przykładzie do tego wpisu wykorzystuję tylko cache w pamięci procesu.

public class CacheInterceptor : IInterceptor
{
private ICacheService _cacheService;
public CacheInterceptor(ICacheService cacheService)
{
_cacheService = cacheService;
}
public void Intercept(IInvocation invocation)
{
var isLogic = invocation.TargetType.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ILogic<>));
if (isLogic == false)
{
invocation.Proceed();
return;
}
var isGetById = invocation.Method.Name == nameof(ILogic<BaseModel>.GetById);
if (isGetById)
{
ProceedGetById(invocation);
return;
}
ProceedOtherMethods(invocation);
}
private void ProceedOtherMethods(IInvocation invocation)
{
var methodsToClear = new[] {nameof(ILogic<BaseModel>.Update), nameof(ILogic<BaseModel>.Delete)};
var clearCache = methodsToClear.Contains(invocation.Method.Name);
string key = "";
if (clearCache)
{
key = KeyFromObject(invocation);
}
invocation.Proceed();
if (clearCache)
{
_cacheService.Remove(key);
}
}
private void ProceedGetById(IInvocation invocation)
{
bool invoked = false;
var result = _cacheService.GetOrAdd(KeyFromParameter(invocation, invocation.Arguments[0] as int?), () =
{
invocation.Proceed();
invoked = true;
return invocation.ReturnValue;
});
if (invoked == false)
{
invocation.ReturnValue = result;
}
return;
}
private string KeyFromParameter(IInvocation invocation, int? id)
{
return $"{invocation.TargetType.FullName}-{id}";
}
private string KeyFromObject(IInvocation invocation)
{
var model = invocation.Arguments[0] as BaseModel;
if (model == null)
{
throw new ArgumentException("Wrong parameter type");
}
return $"{invocation.TargetType.FullName}-{model.Id}";
}
}

Ten interceptor jest już dużo bardziej rozbudowany niż poprzedni. Jego działanie jest podzielone na kilka kroków.

Na początku sprawdzamy, czy typ, na którym jest on wywołany, jest faktycznie logiką biznesową. W rejestracji w autofac powinien być on podpięty tylko pod nią, ale warto dodać takie zabezpieczenie. Kiedy typ nie jest logiką biznesową, interceptor wywoła właściwą metodę i nie zmieni jej w żaden sposób.

W przypadku logiki biznesowej interceptor sprawdza najpierw, czy następuje wywołanie metody GetById. Jeśli tak, to w pierwszej kolejności sprawdza, czy w cache są już zapisane dane. Gdy się tam znajdują, to wtedy interceptor zwraca te dane z metody (ustawienie właściwości ReturnValue parametru invocation). W takiej sytuacji właściwa metoda nie zostanie wywołana. W przeciwnym wypadku metoda się wykonuje i wynik jest zapisywany w cache.

Dla pozostałych metod z logiki biznesowej interceptor sprawdza, czy należy usunąć dane z cache (wywołana metoda ma odpowiednią nazwę), a jeśli tak, to usuwa dane.

CacheInterceptor – działanie

Podobnie jak wcześniej, aby interceptor zadziałał, należy go zarejestrować w kontenerze oraz dodać jego wywołanie do rejestracji klas logiki biznesowej. Kod jest bardzo podobny do tego, co było wcześniej, więc nie będę go już pokazywał we wpisie. Znajduje się on w przykładzie na githubie: https://github.com/danielplawgo/AutofacInterceptors.

Poniżej znajduje się log z działania tego interceptora. W pierwszej kolejności wyświetliłem dwa razy widok edycji produkt oraz później zapisałem dane.

Pierwsze wyświetlenie widoku edycji
2018-09-06 06:20:18.8329 INFO Before call: ProductLogic.GetById
2018-09-06 06:20:18.8329 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3] started.
2018-09-06 06:20:18.8470 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3], item NOT found in handle[0] 'handleName'.
2018-09-06 06:20:18.8470 INFO Before call: ProductRepository.GetById
2018-09-06 06:20:21.1078 INFO After call: ProductRepository.GetById
2018-09-06 06:20:21.1078 TRACE Add ['AutofacInterceptors.Logics.ProductLogic-3', exp:Default 00:00:00, lastAccess:06.09.2018 04:20:21] started.
2018-09-06 06:20:21.1218 TRACE Evict [:AutofacInterceptors.Logics.ProductLogic-3] from other handles excluding handle '0'.
2018-09-06 06:20:21.1218 INFO After call: ProductLogic.GetById
Drugie wyświetlenie widoku edycji
2018-09-06 06:20:26.5558 INFO Before call: ProductLogic.GetById
2018-09-06 06:20:26.5558 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3] started.
2018-09-06 06:20:26.5558 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3], found in handle[0] 'handleName'.
2018-09-06 06:20:26.5659 TRACE Start updating handles with ['AutofacInterceptors.Logics.ProductLogic-3', exp:Absolute 00:10:00, lastAccess:06.09.2018 04:20:26].
2018-09-06 06:20:26.5659 INFO After call: ProductLogic.GetById
Zapisanie produktu
2018-09-06 06:20:31.9539 INFO Before call: ProductLogic.GetById
2018-09-06 06:20:31.9618 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3] started.
2018-09-06 06:20:31.9618 TRACE Get [:AutofacInterceptors.Logics.ProductLogic-3], found in handle[0] 'handleName'.
2018-09-06 06:20:31.9618 TRACE Start updating handles with ['AutofacInterceptors.Logics.ProductLogic-3', exp:Absolute 00:10:00, lastAccess:06.09.2018 04:20:31].
2018-09-06 06:20:31.9618 INFO After call: ProductLogic.GetById
2018-09-06 06:20:31.9768 INFO Before call: ProductLogic.Update
2018-09-06 06:20:31.9768 INFO Before call: ProductRepository.Update
2018-09-06 06:20:32.0172 INFO After call: ProductRepository.Update
2018-09-06 06:20:32.0209 INFO Before call: ProductRepository.SaveChanges
2018-09-06 06:20:32.2310 INFO After call: ProductRepository.SaveChanges
2018-09-06 06:20:32.2310 TRACE Removing [:AutofacInterceptors.Logics.ProductLogic-3].
2018-09-06 06:20:32.2310 INFO After call: ProductLogic.Update
view raw log2.txt hosted with ❤ by GitHub

Pierwsze wyświetlenie widoku wywołało metodę z repozytorium (widać, że CacheManager nie znalazł danych w cache) i później dodało dane do cache. Drugie natomiast znalazło już dane w cache, więc metoda z repozytorium nie została pobrana.

Podczas zapisu danych widać, że na końcu CacheManager usuwa dane z cache, aby kolejne pobranie nastąpiło już z repozytorium.

PollyInterceptor

Ostatnim interceptorem, który Ci pokażę w tym wpisie, jest PollInterceptor. Za jego pomocą dodamy użycie biblioteki Polly do wywołań operacji na bazie danych. Będziemy chcieli ponowić operację na przykład w sytuacji, gdy na przykład serwer bazy danych jest niedostępny lub gdy dwie transakcje się zablokują i jedną z nich zakończy z błędem serwer bazy.

Sam interceptor wygląda prosto:

public class PollyInterceptor : IInterceptor
{
private static NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();
public void Intercept(IInvocation invocation)
{
Policy
.Handle<SqlException>()
.WaitAndRetry(new[]
{
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(20),
TimeSpan.FromSeconds(50)
}, (ex, timeSpan, retryCount, context) =>
{
_logger.Error(ex, $"Error - {invocation.TargetType.Name}.{invocation.Method.Name} - try retry (count: {retryCount})");
})
.Execute(() => invocation.Proceed());
}
}

Reguła w Polly jest zdefiniowana w ten sposób, że nastąpią trzy próby ponowienia operacji. Każda z dodatkowym opóźnieniem. W przykładzie reguła jest nieco zdefiniowana pod testy, aby można w międzyczasie wyłączyć i włączyć serwer, aby sprawdzić, czy interceptor działa.

PollInterceptor dodawany jest do wywołań klas repozytorium:

public class DataContextModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<DataContext>()
.InstancePerRequest();
builder.RegisterAssemblyTypes(typeof(Repository<>).Assembly)
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRepository<>)))
.AsImplementedInterfaces()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(LoggerInterceptor))
.InterceptedBy(typeof(PollyInterceptor));
}
}

Natomiast samo jego działanie wygląda tak (uruchomiłem aplikację z wyłączonym sql serwerem i po pierwszym błędzie uruchomiłem go):

2018-09-05 06:13:11.3312 INFO Cache manager: adding cache handles...
2018-09-05 06:13:11.3601 INFO Creating handle handleName of type CacheManager.SystemRuntimeCaching.MemoryCacheHandle`1[TCacheValue].
2018-09-05 06:13:11.4586 INFO Before call: ProductLogic.GetAllActive
2018-09-05 06:13:11.4741 INFO Before call: ProductRepository.GetAllActive
2018-09-05 06:14:10.8945 ERROR Error - ProductRepository.GetAllActive - try retry (count: 1)
2018-09-05 06:14:27.8662 INFO After call: ProductRepository.GetAllActive
2018-09-05 06:14:27.8662 INFO After call: ProductLogic.GetAllActive
view raw log3.txt hosted with ❤ by GitHub

Przykład

Na githubie znajduje się przykład do wpisu – https://github.com/danielplawgo/AutofacInterceptors. Aby go uruchomić, wystarczy w web.config ustawić poprawny connection string do bazy. W aplikacji zaimplementowany jest CRUD dla produktów (adres http://localhost:[port]/products).

Podsumowanie

Interceptor jest bardzo fajnym mechanizmem, który umożliwia nam w sposób deklaratywny (poprzez konfigurację w tym przypadku Autofaca) dodanie jakichś zachowań dla naszych klas. Dzięki temu nie musimy ręcznie w tych klasach dodawać tego kodu. Niestety ma to też swoje konsekwencje. Nowe osoby w zespole mogą mieć trochę problemów, ponieważ takie zachowanie nie jest widoczne jawnie w kodzie. Trzeba wiedzieć, że  interceptory są używane i wiedzieć, gdzie się znajdują. Dodatkowo użycie interceptorów zawsze wiąże się z narzutem wydajnościowym. Jakim? W jednym z kolejnych wpisów to sprawdzimy.

A jakie są Twoje doświadczenia z używaniem interceptorów? Używasz ich? Podoba Ci się ich idea?

1 thought on “Jak automatycznie ponawiać operacje oraz cache’ować dane z interceptorami w Autofac?

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.