project setup with core files including configuration, package management, and basic structure. Added .gitignore, README, and various TypeScript types for CMS components. Implemented initial components and layouts for the application.
This commit is contained in:
82
middlelayer/utils/REDIS_SETUP.md
Normal file
82
middlelayer/utils/REDIS_SETUP.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Redis Cache Setup
|
||||
|
||||
## Lokale Installation
|
||||
|
||||
### macOS (mit Homebrew)
|
||||
```bash
|
||||
brew install redis
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker run -d -p 6379:6379 --name redis redis:latest
|
||||
```
|
||||
|
||||
### Linux (Ubuntu/Debian)
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install redis-server
|
||||
sudo systemctl start redis
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Redis aktivieren
|
||||
REDIS_ENABLED=true
|
||||
|
||||
# Redis-Verbindung (optional, Defaults: localhost:6379)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD= # Optional, nur wenn gesetzt
|
||||
```
|
||||
|
||||
### Beispiel .env Datei
|
||||
```env
|
||||
REDIS_ENABLED=true
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Redis aktivieren
|
||||
```bash
|
||||
REDIS_ENABLED=true npm run mock:server
|
||||
```
|
||||
|
||||
### Ohne Redis (In-Memory Fallback)
|
||||
```bash
|
||||
# REDIS_ENABLED nicht setzen oder auf false
|
||||
npm run mock:server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Automatischer Fallback**: Wenn Redis nicht verfügbar ist, wird automatisch In-Memory Cache verwendet
|
||||
- ✅ **Retry-Logik**: Bei Verbindungsfehlern wird automatisch auf In-Memory umgeschaltet
|
||||
- ✅ **Metrics**: Cache-Hits/Misses werden weiterhin getrackt
|
||||
- ✅ **Logging**: Alle Redis-Operationen werden geloggt
|
||||
|
||||
## Testen
|
||||
|
||||
```bash
|
||||
# Redis-Verbindung testen
|
||||
redis-cli ping
|
||||
# Sollte "PONG" zurückgeben
|
||||
|
||||
# Cache-Keys anzeigen
|
||||
redis-cli keys "*"
|
||||
|
||||
# Cache leeren
|
||||
redis-cli FLUSHALL
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Die Cache-Metriken sind weiterhin verfügbar unter:
|
||||
- `http://localhost:9090/metrics` - Prometheus Metrics
|
||||
- Logs zeigen Redis-Status und Fallback-Verhalten
|
||||
|
||||
82
middlelayer/utils/cache.ts
Normal file
82
middlelayer/utils/cache.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { cacheConfig } from "../config/cache.js";
|
||||
import { cacheHits, cacheMisses } from "../monitoring/metrics.js";
|
||||
import { logCacheHit, logCacheMiss } from "../monitoring/logger.js";
|
||||
import { RedisCache } from "./redisCache.js";
|
||||
|
||||
/**
|
||||
* Cache-Interface für Abstraktion
|
||||
*/
|
||||
export interface CacheInterface<T> {
|
||||
set(key: string, data: T, ttl?: number): void | Promise<void>;
|
||||
get(key: string): T | null | Promise<T | null>;
|
||||
clear(): void | Promise<void>;
|
||||
delete(key: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-Memory Cache (Fallback wenn Redis nicht verfügbar)
|
||||
*/
|
||||
class InMemoryCache<T> implements CacheInterface<T> {
|
||||
private cache = new Map<string, { data: T; expiresAt: number }>();
|
||||
private defaultTTL: number;
|
||||
private cacheType: string;
|
||||
|
||||
constructor(defaultTTL: number, cacheType: string) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.cacheType = cacheType;
|
||||
}
|
||||
|
||||
async set(key: string, data: T, ttl?: number): Promise<void> {
|
||||
const expiresAt = Date.now() + (ttl || this.defaultTTL);
|
||||
this.cache.set(key, { data, expiresAt });
|
||||
}
|
||||
|
||||
async get(key: string): Promise<T | null> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
cacheMisses.inc({ cache_type: this.cacheType });
|
||||
logCacheMiss(key, this.cacheType);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
cacheMisses.inc({ cache_type: this.cacheType });
|
||||
logCacheMiss(key, this.cacheType);
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheHits.inc({ cache_type: this.cacheType });
|
||||
logCacheHit(key, this.cacheType);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-Instanzen
|
||||
* Verwendet Redis wenn aktiviert, sonst In-Memory
|
||||
*/
|
||||
const useRedis = process.env.REDIS_ENABLED === "true";
|
||||
|
||||
export const cache = {
|
||||
pages: useRedis
|
||||
? new RedisCache<any>(cacheConfig.pages.ttl, "pages")
|
||||
: new InMemoryCache<any>(cacheConfig.pages.ttl, "pages"),
|
||||
pageSeo: useRedis
|
||||
? new RedisCache<any>(cacheConfig.pageSeo.ttl, "pageSeo")
|
||||
: new InMemoryCache<any>(cacheConfig.pageSeo.ttl, "pageSeo"),
|
||||
navigation: useRedis
|
||||
? new RedisCache<any>(cacheConfig.navigation.ttl, "navigation")
|
||||
: new InMemoryCache<any>(cacheConfig.navigation.ttl, "navigation"),
|
||||
products: useRedis
|
||||
? new RedisCache<any>(cacheConfig.products.ttl, "products")
|
||||
: new InMemoryCache<any>(cacheConfig.products.ttl, "products"),
|
||||
};
|
||||
33
middlelayer/utils/cacheKeys.ts
Normal file
33
middlelayer/utils/cacheKeys.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Zentralisierte Cache-Key-Generierung
|
||||
* Stellt sicher, dass Cache-Keys konsistent und wartbar sind
|
||||
*/
|
||||
export class CacheKeyBuilder {
|
||||
static page(slug: string, locale?: string): string {
|
||||
return `page:${slug}:${locale || "default"}`;
|
||||
}
|
||||
|
||||
static pages(locale?: string): string {
|
||||
return `pages:all:${locale || "default"}`;
|
||||
}
|
||||
|
||||
static pageSeo(locale?: string): string {
|
||||
return `pageSeo:${locale || "default"}`;
|
||||
}
|
||||
|
||||
static navigation(locale?: string): string {
|
||||
return `navigation:${locale || "default"}`;
|
||||
}
|
||||
|
||||
static product(id: string): string {
|
||||
return `product:${id}`;
|
||||
}
|
||||
|
||||
static products(limit?: number): string {
|
||||
return `products:${limit || "default"}`;
|
||||
}
|
||||
|
||||
static translations(locale: string, namespace?: string): string {
|
||||
return `translations:${locale}${namespace ? `:${namespace}` : ""}`;
|
||||
}
|
||||
}
|
||||
80
middlelayer/utils/dataServiceHelpers.ts
Normal file
80
middlelayer/utils/dataServiceHelpers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
dataServiceCalls,
|
||||
dataServiceDuration,
|
||||
} from "../monitoring/metrics.js";
|
||||
import { logger } from "../monitoring/logger.js";
|
||||
import { AdapterError } from "./errors.js";
|
||||
import type { CacheInterface } from "./cache.js";
|
||||
|
||||
/**
|
||||
* Helper für DataService-Methoden mit Cache und Metrics
|
||||
*/
|
||||
export class DataServiceHelpers {
|
||||
/**
|
||||
* Führt eine DataService-Operation mit Cache und Metrics aus
|
||||
*/
|
||||
static async withCacheAndMetrics<T>(
|
||||
method: string,
|
||||
cache: CacheInterface<T>,
|
||||
cacheKey: string,
|
||||
operation: () => Promise<T>,
|
||||
errorMessage: string,
|
||||
context?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Prüfe Cache
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
dataServiceCalls.inc({ method, status: "success" });
|
||||
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Führe Operation aus
|
||||
const result = await operation();
|
||||
|
||||
// Speichere im Cache (wenn Ergebnis vorhanden)
|
||||
if (result) {
|
||||
await cache.set(cacheKey, result);
|
||||
}
|
||||
|
||||
// Metrics
|
||||
dataServiceCalls.inc({ method, status: "success" });
|
||||
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Error Metrics
|
||||
dataServiceCalls.inc({ method, status: "error" });
|
||||
dataServiceDuration.observe({ method }, (Date.now() - startTime) / 1000);
|
||||
|
||||
logger.error(`Error in ${method}`, { ...context, error });
|
||||
throw new AdapterError(errorMessage, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine DataService-Operation nur mit Cache aus (ohne Metrics)
|
||||
*/
|
||||
static async withCache<T>(
|
||||
cache: CacheInterface<T>,
|
||||
cacheKey: string,
|
||||
operation: () => Promise<T>,
|
||||
errorMessage: string
|
||||
): Promise<T> {
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
await cache.set(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new AdapterError(errorMessage, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
middlelayer/utils/dataloaders.ts
Normal file
89
middlelayer/utils/dataloaders.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import DataLoader from "dataloader";
|
||||
import type { Product, Page } from "../types/index.js";
|
||||
import type { User } from "../types/user.js";
|
||||
import { dataService } from "../dataService.js";
|
||||
import { userService } from "../auth/userService.js";
|
||||
|
||||
/**
|
||||
* Dataloader für Batch-Loading von Produkten
|
||||
* Verhindert N+1 Queries, wenn mehrere Produkte in einer Query abgefragt werden
|
||||
*/
|
||||
export const createProductLoader = () => {
|
||||
return new DataLoader<string, Product | null>(
|
||||
async (ids: readonly string[]) => {
|
||||
// Batch-Loading: Lade alle Produkte in einem Batch
|
||||
const products = await Promise.all(
|
||||
ids.map((id) => dataService.getProduct(id))
|
||||
);
|
||||
|
||||
// Stelle sicher, dass die Reihenfolge mit den IDs übereinstimmt
|
||||
return products;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dataloader für Batch-Loading von Seiten
|
||||
* Unterstützt Locale im Key-Format: "slug:locale" oder "slug"
|
||||
*/
|
||||
export const createPageLoader = () => {
|
||||
return new DataLoader<string, Page | null>(
|
||||
async (keys: readonly string[]) => {
|
||||
const pages = await Promise.all(
|
||||
keys.map((key) => {
|
||||
// Parse Key: Format kann "slug:locale" oder "slug" sein
|
||||
const parts = key.split(":");
|
||||
const slug = parts[0];
|
||||
const locale = parts.length > 1 ? parts[1] : undefined;
|
||||
return dataService.getPage(slug, locale);
|
||||
})
|
||||
);
|
||||
return pages;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dataloader für Batch-Loading von Usern
|
||||
*/
|
||||
export const createUserLoader = () => {
|
||||
return new DataLoader<string, User | null>(
|
||||
async (userIds: readonly string[]) => {
|
||||
const users = await Promise.all(
|
||||
userIds.map((id) => userService.getUserById(id))
|
||||
);
|
||||
return users;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Context für GraphQL Resolver
|
||||
* Enthält Dataloader-Instanzen und User-Informationen
|
||||
*
|
||||
* Der Context wird für jeden GraphQL Request erstellt und enthält:
|
||||
* - `user`: Aktueller authentifizierter User (oder null)
|
||||
* - `loaders`: Dataloader-Instanzen für Batch-Loading (verhindert N+1 Queries)
|
||||
*/
|
||||
export interface GraphQLContext {
|
||||
user: User | null;
|
||||
loaders: {
|
||||
product: DataLoader<string, Product | null>;
|
||||
page: DataLoader<string, Page | null>;
|
||||
user: DataLoader<string, User | null>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Context mit Dataloadern und User
|
||||
*/
|
||||
export const createContext = (user: User | null = null): GraphQLContext => {
|
||||
return {
|
||||
user,
|
||||
loaders: {
|
||||
product: createProductLoader(),
|
||||
page: createPageLoader(),
|
||||
user: createUserLoader(),
|
||||
},
|
||||
};
|
||||
};
|
||||
28
middlelayer/utils/errors.ts
Normal file
28
middlelayer/utils/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Custom Error-Klassen für den Middlelayer
|
||||
*/
|
||||
export class DataServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly originalError?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DataServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AdapterError extends DataServiceError {
|
||||
constructor(message: string, originalError?: unknown) {
|
||||
super(message, 'ADAPTER_ERROR', originalError);
|
||||
this.name = 'AdapterError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DataServiceError {
|
||||
constructor(resource: string, identifier: string) {
|
||||
super(`${resource} mit ID '${identifier}' nicht gefunden`, 'NOT_FOUND');
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
175
middlelayer/utils/redisCache.ts
Normal file
175
middlelayer/utils/redisCache.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import Redis from "ioredis";
|
||||
import { cacheHits, cacheMisses } from "../monitoring/metrics.js";
|
||||
import { logCacheHit, logCacheMiss } from "../monitoring/logger.js";
|
||||
import { logger } from "../monitoring/logger.js";
|
||||
|
||||
/**
|
||||
* Redis-basierter Cache mit Fallback auf In-Memory
|
||||
*/
|
||||
export class RedisCache<T> {
|
||||
private redis: Redis | null = null;
|
||||
private memoryCache: Map<string, { data: T; expiresAt: number }> = new Map();
|
||||
private defaultTTL: number;
|
||||
private cacheType: string;
|
||||
private useRedis: boolean;
|
||||
|
||||
constructor(defaultTTL: number, cacheType: string) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.cacheType = cacheType;
|
||||
this.useRedis = process.env.REDIS_ENABLED === "true";
|
||||
|
||||
if (this.useRedis) {
|
||||
this.initRedis();
|
||||
} else {
|
||||
logger.info(
|
||||
`Redis Cache deaktiviert für ${cacheType}, verwende In-Memory Cache`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async initRedis() {
|
||||
try {
|
||||
this.redis = new Redis({
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: parseInt(process.env.REDIS_PORT || "6379"),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
retryStrategy: (times) => {
|
||||
// Bei Fehler: Fallback auf In-Memory nach 3 Versuchen
|
||||
if (times > 3) {
|
||||
logger.warn(
|
||||
"Redis-Verbindung fehlgeschlagen, verwende In-Memory Cache"
|
||||
);
|
||||
this.useRedis = false;
|
||||
return null; // Stoppe Retry
|
||||
}
|
||||
return Math.min(times * 50, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
this.redis.on("error", (error) => {
|
||||
logger.error("Redis-Fehler", { error, cacheType: this.cacheType });
|
||||
// Fallback auf In-Memory bei Fehler
|
||||
this.useRedis = false;
|
||||
});
|
||||
|
||||
this.redis.on("connect", () => {
|
||||
logger.info(`Redis verbunden für ${this.cacheType}`);
|
||||
});
|
||||
|
||||
// Test-Verbindung
|
||||
await this.redis.ping();
|
||||
logger.info(`Redis Cache aktiviert für ${this.cacheType}`);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
"Redis-Initialisierung fehlgeschlagen, verwende In-Memory Cache",
|
||||
{
|
||||
error,
|
||||
cacheType: this.cacheType,
|
||||
}
|
||||
);
|
||||
this.useRedis = false;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, data: T, ttl?: number): Promise<void> {
|
||||
const ttlMs = ttl || this.defaultTTL;
|
||||
const serialized = JSON.stringify(data);
|
||||
|
||||
if (this.useRedis && this.redis) {
|
||||
try {
|
||||
// Redis TTL in Sekunden
|
||||
const ttlSeconds = Math.ceil(ttlMs / 1000);
|
||||
await this.redis.setex(key, ttlSeconds, serialized);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn("Redis set fehlgeschlagen, verwende In-Memory", {
|
||||
error,
|
||||
key,
|
||||
});
|
||||
this.useRedis = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: In-Memory
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
this.memoryCache.set(key, { data, expiresAt });
|
||||
}
|
||||
|
||||
async get(key: string): Promise<T | null> {
|
||||
if (this.useRedis && this.redis) {
|
||||
try {
|
||||
const value = await this.redis.get(key);
|
||||
if (value) {
|
||||
// Cache Hit
|
||||
cacheHits.inc({ cache_type: this.cacheType });
|
||||
logCacheHit(key, this.cacheType);
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
// Cache Miss
|
||||
cacheMisses.inc({ cache_type: this.cacheType });
|
||||
logCacheMiss(key, this.cacheType);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn("Redis get fehlgeschlagen, verwende In-Memory", {
|
||||
error,
|
||||
key,
|
||||
});
|
||||
this.useRedis = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: In-Memory
|
||||
const entry = this.memoryCache.get(key);
|
||||
if (!entry) {
|
||||
cacheMisses.inc({ cache_type: this.cacheType });
|
||||
logCacheMiss(key, this.cacheType);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.memoryCache.delete(key);
|
||||
cacheMisses.inc({ cache_type: this.cacheType });
|
||||
logCacheMiss(key, this.cacheType);
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheHits.inc({ cache_type: this.cacheType });
|
||||
logCacheHit(key, this.cacheType);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
try {
|
||||
// Lösche alle Keys mit unserem Prefix
|
||||
const keys = await this.redis.keys(`${this.cacheType}:*`);
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Redis clear fehlgeschlagen", { error });
|
||||
}
|
||||
}
|
||||
this.memoryCache.clear();
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
if (this.useRedis && this.redis) {
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn("Redis delete fehlgeschlagen", { error, key });
|
||||
}
|
||||
}
|
||||
this.memoryCache.delete(key);
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
this.redis = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user