322 lines
8.9 KiB
Go
322 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// 💡 MASTER 16: MUSTERLÖSUNG (Microservice API)
|
|
// ============================================================================
|
|
|
|
// URLRecord speichert Metadaten zu einer gekürzten URL.
|
|
type URLRecord struct {
|
|
ID int `json:"id"`
|
|
ShortCode string `json:"short_code"`
|
|
OriginalURL string `json:"original_url"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ClickCount int64 `json:"click_count"`
|
|
}
|
|
|
|
// ClickEvent repräsentiert ein asynchron verarbeitetes Klick-Ereignis.
|
|
type ClickEvent struct {
|
|
ShortCode string `json:"short_code"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// AnalyticsStats liefert aggregierte Zugriffs-Auswertungen.
|
|
type AnalyticsStats struct {
|
|
ShortCode string `json:"short_code"`
|
|
TotalClicks int64 `json:"total_clicks"`
|
|
UniqueIPs int `json:"unique_ips"`
|
|
}
|
|
|
|
// SystemMetrics liefert den globalen Zustand des Microservices.
|
|
type SystemMetrics struct {
|
|
TotalURLs int `json:"total_urls"`
|
|
TotalClicksProcessed int64 `json:"total_clicks_processed"`
|
|
ActiveWorkers int `json:"active_workers"`
|
|
QueueLength int `json:"queue_length"`
|
|
}
|
|
|
|
// MicroserviceServer ist der hochgradig nebenläufige In-Memory Microservice.
|
|
type MicroserviceServer struct {
|
|
urlStore map[string]*URLRecord
|
|
analyticsStore map[string][]ClickEvent
|
|
storeMu sync.RWMutex
|
|
analyticsMu sync.RWMutex
|
|
|
|
clickQueue chan ClickEvent
|
|
workerWG sync.WaitGroup
|
|
totalProcessed int64
|
|
workerCount int
|
|
nextID int
|
|
}
|
|
|
|
// NewMicroserviceServer initialisiert den Server und Pufferkanäle.
|
|
func NewMicroserviceServer(queueCapacity int) *MicroserviceServer {
|
|
if queueCapacity <= 0 {
|
|
queueCapacity = 1000
|
|
}
|
|
return &MicroserviceServer{
|
|
urlStore: make(map[string]*URLRecord),
|
|
analyticsStore: make(map[string][]ClickEvent),
|
|
clickQueue: make(chan ClickEvent, queueCapacity),
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
// StartWorkerPool startet Hintergrund-Worker zur Event-Verarbeitung.
|
|
func (s *MicroserviceServer) StartWorkerPool(numWorkers int) {
|
|
s.workerCount = numWorkers
|
|
for i := 0; i < numWorkers; i++ {
|
|
s.workerWG.Add(1)
|
|
go func() {
|
|
defer s.workerWG.Done()
|
|
for event := range s.clickQueue {
|
|
// 1. In Analytics-Historie speichern
|
|
s.analyticsMu.Lock()
|
|
s.analyticsStore[event.ShortCode] = append(s.analyticsStore[event.ShortCode], event)
|
|
s.analyticsMu.Unlock()
|
|
|
|
// 2. Globalen Verarbeitungszähler erhöhen
|
|
atomic.AddInt64(&s.totalProcessed, 1)
|
|
|
|
// 3. Klickzähler des URLs atomar hochzählen
|
|
s.storeMu.RLock()
|
|
if record, exists := s.urlStore[event.ShortCode]; exists {
|
|
atomic.AddInt64(&record.ClickCount, 1)
|
|
}
|
|
s.storeMu.RUnlock()
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// StopWorkerPool schließt die Warteschlange und wartet auf alle Worker.
|
|
func (s *MicroserviceServer) StopWorkerPool() {
|
|
close(s.clickQueue)
|
|
s.workerWG.Wait()
|
|
}
|
|
|
|
// ShortenURL validiert und kürzt eine URL thread-safe.
|
|
func (s *MicroserviceServer) ShortenURL(rawURL string) (*URLRecord, error) {
|
|
trimmed := strings.TrimSpace(rawURL)
|
|
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
|
|
return nil, errors.New("invalid url format: must start with http:// or https://")
|
|
}
|
|
|
|
hash := sha256.Sum256([]byte(trimmed))
|
|
shortCode := hex.EncodeToString(hash[:])[:6]
|
|
|
|
s.storeMu.Lock()
|
|
defer s.storeMu.Unlock()
|
|
|
|
// Prüfen, ob bereits vorhanden
|
|
if existing, exists := s.urlStore[shortCode]; exists {
|
|
return existing, nil
|
|
}
|
|
|
|
record := &URLRecord{
|
|
ID: s.nextID,
|
|
ShortCode: shortCode,
|
|
OriginalURL: trimmed,
|
|
CreatedAt: time.Now(),
|
|
ClickCount: 0,
|
|
}
|
|
s.nextID++
|
|
s.urlStore[shortCode] = record
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// ResolveURL findet die Original-URL und schickt ein Klick-Event an den Workerpool.
|
|
func (s *MicroserviceServer) ResolveURL(shortCode, ip, userAgent string) (string, error) {
|
|
s.storeMu.RLock()
|
|
record, exists := s.urlStore[shortCode]
|
|
s.storeMu.RUnlock()
|
|
|
|
if !exists {
|
|
return "", errors.New("url not found")
|
|
}
|
|
|
|
event := ClickEvent{
|
|
ShortCode: shortCode,
|
|
IP: ip,
|
|
UserAgent: userAgent,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
// Nicht-blockierender Dispatch in den Event-Bus
|
|
select {
|
|
case s.clickQueue <- event:
|
|
default:
|
|
// Queue voll: Event wird geloggt/ignoriert, um Request nicht zu verlangsamen
|
|
}
|
|
|
|
return record.OriginalURL, nil
|
|
}
|
|
|
|
// GetStats berechnet Klickzahlen und Unique-IPs.
|
|
func (s *MicroserviceServer) GetStats(shortCode string) (*AnalyticsStats, error) {
|
|
s.storeMu.RLock()
|
|
_, exists := s.urlStore[shortCode]
|
|
s.storeMu.RUnlock()
|
|
|
|
if !exists {
|
|
return nil, errors.New("url not found")
|
|
}
|
|
|
|
s.analyticsMu.RLock()
|
|
events := s.analyticsStore[shortCode]
|
|
s.analyticsMu.RUnlock()
|
|
|
|
uniqueIPs := make(map[string]bool)
|
|
for _, ev := range events {
|
|
uniqueIPs[ev.IP] = true
|
|
}
|
|
|
|
return &AnalyticsStats{
|
|
ShortCode: shortCode,
|
|
TotalClicks: int64(len(events)),
|
|
UniqueIPs: len(uniqueIPs),
|
|
}, nil
|
|
}
|
|
|
|
// GetSystemMetrics liefert Gesamtmetriken des Microservices.
|
|
func (s *MicroserviceServer) GetSystemMetrics() SystemMetrics {
|
|
s.storeMu.RLock()
|
|
totalURLs := len(s.urlStore)
|
|
s.storeMu.RUnlock()
|
|
|
|
return SystemMetrics{
|
|
TotalURLs: totalURLs,
|
|
TotalClicksProcessed: atomic.LoadInt64(&s.totalProcessed),
|
|
ActiveWorkers: s.workerCount,
|
|
QueueLength: len(s.clickQueue),
|
|
}
|
|
}
|
|
|
|
// SetupMicroserviceRouter konfiguriert alle HTTP-Routen.
|
|
func SetupMicroserviceRouter(srv *MicroserviceServer) *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
|
|
// 1. POST /api/shorten
|
|
mux.HandleFunc("/api/shorten", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
|
|
record, err := srv.ShortenURL(payload.URL)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(record)
|
|
})
|
|
|
|
// 2. GET /r/{short_code}
|
|
mux.HandleFunc("/r/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
code := strings.TrimPrefix(r.URL.Path, "/r/")
|
|
code = strings.Trim(code, "/")
|
|
|
|
origURL, err := srv.ResolveURL(code, r.RemoteAddr, r.UserAgent())
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "short url not found"})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"original_url": origURL})
|
|
})
|
|
|
|
// 3. GET /api/stats/{short_code}
|
|
mux.HandleFunc("/api/stats/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
code := strings.TrimPrefix(r.URL.Path, "/api/stats/")
|
|
code = strings.Trim(code, "/")
|
|
|
|
stats, err := srv.GetStats(code)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "stats not found"})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(stats)
|
|
})
|
|
|
|
// 4. GET /api/metrics
|
|
mux.HandleFunc("/api/metrics", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
metrics := srv.GetSystemMetrics()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(metrics)
|
|
})
|
|
|
|
return mux
|
|
}
|
|
|
|
func main() {
|
|
fmt.Println("=== Master 16: Musterlösung ===")
|
|
srv := NewMicroserviceServer(1000)
|
|
srv.StartWorkerPool(4)
|
|
|
|
rec, _ := srv.ShortenURL("https://golang.org")
|
|
fmt.Printf("Kurz-URL erstellt: Code='%s' -> '%s'\n", rec.ShortCode, rec.OriginalURL)
|
|
|
|
url, _ := srv.ResolveURL(rec.ShortCode, "127.0.0.1", "Go-Client/1.0")
|
|
fmt.Printf("URL aufgelöst: %s\n", url)
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
stats, _ := srv.GetStats(rec.ShortCode)
|
|
fmt.Printf("Statistik: Clicks=%d, UniqueIPs=%d\n", stats.TotalClicks, stats.UniqueIPs)
|
|
|
|
srv.StopWorkerPool()
|
|
}
|