Skip to content

Instantly share code, notes, and snippets.

@Dok11
Last active May 12, 2021 13:32
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Dok11/6eb097bb66280fce91f86d31fafda795 to your computer and use it in GitHub Desktop.
Save Dok11/6eb097bb66280fce91f86d31fafda795 to your computer and use it in GitHub Desktop.
Angular 11. CacheInterceptor
import { HttpRequest, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface CachedData {
time: number;
body: object & HttpResponse<any>;
}
type StorageKey = string;
@Injectable({
providedIn: 'root'
})
export class CacheInterceptorControllerService {
private canUseLocalStorage: Record<StorageKey, boolean> = {};
private hasLocalStorage: boolean;
private httpEvents: Record<StorageKey, BehaviorSubject<any>> = {};
private httpMadeStory: Record<StorageKey, boolean> = {};
private tempStorage: Record<StorageKey, string> = {};
private readonly cacheTimeOver = 86400 * 1000; // ms
private readonly localStorageItemMaxBytes = 512 * 1024; // kb
constructor() {
this.checkLocalStorage();
this.clearOldLocalStorageItems();
}
/**
* Метод определяет конфигурацию обработчика, можно ли использовать localStorage браузера
*/
public setCanUseLocalStorage(storageKey: StorageKey, isCan: boolean): void {
this.canUseLocalStorage[storageKey] = isCan;
}
/**
* Метод возвращает информацию о том, был ли уже выполнен заданный запрос в пределах
* текущей сессии
*/
public checkIsHttpMade(storageKey: StorageKey): boolean {
return !!this.httpMadeStory[storageKey];
}
/**
* Метод очищает из памяти выполненных запросов нужный по ключу
* @param storageKeys - Символьные коды storageKey или регулярных выражений
*/
public cleanHttpMadeStoryByKeys(storageKeys: (StorageKey | RegExp)[]): void {
storageKeys.forEach(storageKey => {
if (typeof storageKey === 'string') {
if (this.httpMadeStory && this.httpMadeStory[storageKey]) {
this.httpMadeStory[storageKey] = false;
}
} else {
Object.keys(this.httpMadeStory).forEach(key => {
if (storageKey.test(key)) {
this.httpMadeStory[key] = false;
}
});
}
});
}
/**
* Метод формирует ключ кеша на основе http-запроса
* @param req - Объект с описанием body http-запроса
* @returns Ключ строкой
*/
public getRequestKey(req: HttpRequest<any>): string {
return req.body
? JSON.stringify(req.body)
: req.urlWithParams || req.url;
}
/**
* Метод сохраняет данные в localStorage или временном хранилище с текущим временем
* @param storageKey - Ключ записи
* @param body - Сохраняемые данные
*/
public saveData(storageKey: StorageKey, body: any): void {
const time = Date.now();
const data: CachedData = { time, body };
const cachedData = JSON.stringify(data);
if (this.canUseLocalStorage[storageKey]) {
this.saveDataInLocalStorage(storageKey, cachedData);
} else {
this.tempStorage[storageKey] = cachedData;
}
this.httpEvents[storageKey].next(body);
}
/**
* Метод определяет, что заданный запрос уже был выполнен в пределах одной сессии
*/
public setHttpStoryMade(storageKey: StorageKey): void {
this.httpMadeStory[storageKey] = true;
}
/**
* Метод удаляет информацию о созданном запросе
*/
public unsetHttpStoryMade(storageKey: StorageKey): void {
delete this.httpMadeStory[storageKey];
}
/**
* Метод сохраняет ссылку на HTTP-запрос к данным
*/
public saveHttpEvent(storageKey: StorageKey, defaultValue?: CachedData['body']): void {
this.httpEvents[storageKey] = new BehaviorSubject<any>(defaultValue);
}
/**
* Метод удаляет подписку на HTTP-запрос
*/
public removeHttpEvent(storageKey: StorageKey, reason: any = 'Error'): void {
if (!this.httpEvents[storageKey]) { return; }
console.log('Http Event was stopped by', reason);
delete this.httpEvents[storageKey];
}
/**
* Метод возвращает ссылку на HTTP-запрос к данным
*/
public getHttpEvent(storageKey: StorageKey): BehaviorSubject<any> {
return this.httpEvents[storageKey];
}
/**
* Метод возвращает сохраненное значение для заданного ключа
* @param storageKey - Ключ записи
*/
public getData(storageKey: StorageKey): CachedData['body'] {
if (this.canUseLocalStorage[storageKey]) {
const cachedData = this.getDataFromLocalStorage(storageKey);
if (cachedData && cachedData.body) {
return cachedData.body;
}
} else {
const json = this.tempStorage[storageKey];
if (json) {
const cachedData = JSON.parse(this.tempStorage[storageKey]) as CachedData;
if (cachedData && cachedData.body) {
return cachedData.body;
}
}
}
}
/**
* Метод удаляет из localStorage старые данные (старше суток)
*/
private clearOldLocalStorageItems(): void {
if (!this.hasLocalStorage) {
return;
}
const oldCacheTimestamp = Date.now() - this.cacheTimeOver;
for (const storageKey in localStorage) {
if (!localStorage[storageKey]) {
continue;
}
try {
const data = JSON.parse(localStorage[storageKey]);
const isCacheTooOld = data && data.time && data.time < oldCacheTimestamp;
if (isCacheTooOld) {
localStorage.removeItem(storageKey);
}
} catch (e) {
}
}
}
/**
* Метод проверяет, доступен ли на клиенте localStorage и сохраняет
*/
private checkLocalStorage(): void {
try {
const test = 't';
localStorage.setItem(test, test);
localStorage.removeItem(test);
this.hasLocalStorage = true;
} catch (e) {
this.hasLocalStorage = false;
}
}
/**
* Метод сохраняет данные в localStorage
* @param storageKey - Ключ записи
* @param cachedData - Сохраняемые значения
*/
private saveDataInLocalStorage(storageKey: StorageKey, cachedData: any): void {
if (!this.hasLocalStorage || !cachedData) { return; }
if (cachedData.length < this.localStorageItemMaxBytes) {
localStorage.setItem(storageKey, cachedData);
} else {
delete this.httpMadeStory[storageKey];
}
}
/**
* Метод возвращает сохраненное в localStorage значение для заданного ключа
* @param storageKey - Ключ записи
*/
private getDataFromLocalStorage(storageKey: StorageKey): CachedData {
if (!this.hasLocalStorage) { return; }
const cachedDataJson = localStorage.getItem(storageKey);
if (cachedDataJson) {
return JSON.parse(cachedDataJson) as CachedData;
}
}
}
import {
HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { EMPTY, merge, Observable, of } from 'rxjs';
import { catchError, filter, tap } from 'rxjs/operators';
import { BackendQuery } from '../../../interfaces/backend-query';
import { CacheInterceptorControllerService } from './cache-interceptor-controller.service';
@Injectable({
providedIn: 'root'
})
export class CacheInterceptor implements HttpInterceptor {
constructor(
private controller: CacheInterceptorControllerService,
) {}
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.isCanCache(req)) {
const query = req.body as BackendQuery;
const storageKey = this.controller.getRequestKey(req);
const canUseLocalStorage = !query || query.canUseLocalStorage !== false;
this.controller.setCanUseLocalStorage(storageKey, canUseLocalStorage);
const cachedData = this.controller.getData(storageKey);
const isCachedDataValid = !!cachedData && cachedData.status === 200;
if (this.controller.checkIsHttpMade(storageKey)) {
// Если запрос уже был выполнен, можно вернуть ссылку на него
return this.controller.getHttpEvent(storageKey).asObservable();
}
// Сохраняем подписку на HttpEvent
this.controller.setHttpStoryMade(storageKey);
this.controller.saveHttpEvent(storageKey, cachedData);
// Возвращаем подписку из двух событий
return merge(
// 1. Локальные данные из localStorage, если не игнорируем первый кеш
!this.isIgnoreFirstCache(req)
? this.controller.getHttpEvent(storageKey).asObservable()
: EMPTY,
// 2. И настоящий http-запрос
next.handle(req).pipe(
catchError(error => {
this.controller.removeHttpEvent(storageKey, error);
this.controller.unsetHttpStoryMade(storageKey);
return isCachedDataValid ? of(new HttpResponse<any>(cachedData)) : EMPTY;
}),
filter(data => data && 'body' in data),
tap(data => this.controller.saveData(storageKey, data)),
),
);
}
return next.handle(req);
}
private isCanCache(req: HttpRequest<any>): boolean {
const query = req.body as BackendQuery;
const disableCacheForGetRequest = req.params.get('canCache') === 'false'
|| req.url.indexOf('canCache=false') > 0;
/**
* Кеширование AJAX-запросов включено,
* если явно не задано выключение кеша для GET-запросов к /api/ или любых POST-запросов
* или, если это запрос json файлов меню или локализаций
*/
return (req.body && req.method === 'POST' && query.canCache === true)
|| (req.method === 'GET' && req.url.indexOf('/api/v') === 0 && !disableCacheForGetRequest)
|| (req.url && req.url === '/api/v1/menu.json')
|| (req.url && req.url === './api/i18n/ru.json')
|| (req.url && req.url === './api/i18n/en.json');
}
private isIgnoreFirstCache(req: HttpRequest<any>): boolean {
// Модуль локализаций по take(1) прерывает поток, поэтому ему не отдаем первый кеш
return !!req.url.match(/i18n\/\w{2,5}\.json/);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment