Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

KF Project | Optimizely CMS Integration

Thời gian: 04/2025 – 03/2026
Vai trò: Fullstack Developer (Backend focus)
Công ty: [Thông tin công ty]


Tổng quan dự án

Bối cảnh

Khách hàng cần một hệ thống CMS cho phép content editors quản lý các địa điểm kinh doanh (stores, offices, warehouses) với khả năng hiển thị trên bản đồ tương tác. Yêu cầu tích hợp Google Maps trực tiếp vào CMS để editors có thể:

  • Chọn vị trí trên bản đồ khi nhập địa chỉ
  • Xem trước locations trên map trước khi publish
  • Quản lý multiple locations với clustering
  • Tích hợp vào các trang landing page của từng khu vực

Yêu cầu chức năng

Functional Requirements

  1. Location Management

    • CRUD operations cho locations trong CMS
    • Import/export bulk locations từ CSV/Excel
    • Validation địa chỉ và tọa độ
  2. Map Integration

    • Hiển thị Google Maps trong CMS admin
    • Interactive marker placement
    • Clustering cho nhiều locations gần nhau
    • Custom marker styling theo category
  3. Frontend Display

    • Store locator page cho end users
    • Search & filter locations (by radius, category)
    • Direction integration (Google Directions API)
    • Responsive design cho mobile
  4. Caching & Performance

    • Cache map tiles và location data
    • Lazy loading cho markers
    • CDN integration cho static assets

Non-Functional Requirements

  • Performance: Page load < 2s, map render < 500ms
  • Scalability: Support 10,000+ locations
  • Availability: 99.9% uptime
  • SEO: Server-side rendering cho store locator pages

Kiến trúc & Công nghệ

Technology Stack

┌─────────────────────────────────────────────────────────────┐
│                      Frontend (React)                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │ Google Maps │  │  React      │  │  CSS Modules /      │  │
│  │ JavaScript  │  │  Components │  │  Styled Components  │  │
│  │  API        │  │             │  │                     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                            ↕ HTTP/HTTPS
┌─────────────────────────────────────────────────────────────┐
│                 Backend (.NET + Optimizely CMS)              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  Optimizely │  │  REST API   │  │  Location Service   │  │
│  │  CMS        │  │  Controllers│  │                     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
│                            ↕                                  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  SQL Server │  │  Redis      │  │  Azure Blob Storage │  │
│  │  (Content)  │  │  (Cache)    │  │  (Static assets)    │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Components chi tiết

1. Optimizely CMS Backend

// Location Block - Custom Content Type
[ContentType(GUID = "xxx", Name = "LocationBlock")]
public class LocationBlock : BlockData
{
    [Display(Name = "Location Name")]
    public virtual string LocationName { get; set; }
    
    [Display(Name = "Address")]
    public virtual string Address { get; set; }
    
    [Display(Name = "Latitude")]
    public virtual double Latitude { get; set; }
    
    [Display(Name = "Longitude")]
    public virtual double Longitude { get; set; }
    
    [Display(Name = "Category")]
    public virtual string Category { get; set; }
    
    [Display(Name = "Opening Hours")]
    public virtual string OpeningHours { get; set; }
}

// Location Service
public interface ILocationService
{
    Task<LocationDto> GetLocationAsync(int locationId);
    Task<IEnumerable<LocationDto>> SearchLocationsAsync(SearchCriteria criteria);
    Task<LocationDto> CreateLocationAsync(LocationCreateRequest request);
    Task UpdateLocationAsync(int locationId, LocationUpdateRequest request);
    Task DeleteLocationAsync(int locationId);
    Task<IEnumerable<LocationDto>> ImportLocationsAsync(Stream csvStream);
}

2. REST API Controllers

[ApiController]
[Route("api/[controller]")]
public class LocationsController : ControllerBase
{
    private readonly ILocationService _locationService;
    private readonly IContentRepository _contentRepository;
    
    public LocationsController(
        ILocationService locationService,
        IContentRepository contentRepository)
    {
        _locationService = locationService;
        _contentRepository = contentRepository;
    }
    
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(LocationDto), 200)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<LocationDto>> GetLocation(int id)
    {
        var location = await _locationService.GetLocationAsync(id);
        if (location == null) return NotFound();
        return Ok(location);
    }
    
    [HttpGet("search")]
    [ProducesResponseType(typeof(IEnumerable<LocationDto>), 200)]
    public async Task<ActionResult<IEnumerable<LocationDto>>> SearchLocations(
        [FromQuery] SearchCriteria criteria)
    {
        var locations = await _locationService.SearchLocationsAsync(criteria);
        return Ok(locations);
    }
    
    [HttpPost]
    [ProducesResponseType(typeof(LocationDto), 201)]
    [ProducesResponseType(400)]
    public async Task<ActionResult<LocationDto>> CreateLocation(
        [FromBody] LocationCreateRequest request)
    {
        var location = await _locationService.CreateLocationAsync(request);
        return CreatedAtAction(nameof(GetLocation), new { id = location.Id }, location);
    }
}

3. Google Maps Integration

// React Component - Location Map
import { GoogleMap, useJsApiLoader, Marker, InfoWindow } from '@react-google-maps/api';

const LocationMap = ({ locations, onLocationSelect }) => {
  const { isLoaded } = useJsApiLoader({
    googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_API_KEY
  });
  
  const [selectedLocation, setSelectedLocation] = useState(null);
  const [mapCenter, setMapCenter] = useState({ lat: 21.0285, lng: 105.8542 });
  
  const onMapClick = useCallback((e) => {
    const newLocation = {
      lat: e.latLng.lat(),
      lng: e.latLng.lng()
    };
    onLocationSelect(newLocation);
  }, [onLocationSelect]);
  
  if (!isLoaded) return <div>Loading map...</div>;
  
  return (
    <GoogleMap
      center={mapCenter}
      zoom={12}
      onClick={onMapClick}
      options={{
        disableDefaultUI: false,
        clickableIcons: true,
        scrollwheel: true
      }}
    >
      {locations.map((location, index) => (
        <Marker
          key={location.id}
          position={{ lat: location.latitude, lng: location.longitude }}
          onClick={() => setSelectedLocation(location)}
          icon={{
            url: `/markers/${location.category}.png`,
            scaledSize: new google.maps.Size(30, 30)
          }}
        />
      ))}
      
      {selectedLocation && (
        <InfoWindow
          position={{ 
            lat: selectedLocation.latitude, 
            lng: selectedLocation.longitude 
          }}
          onCloseClick={() => setSelectedLocation(null)}
        >
          <div>
            <h3>{selectedLocation.name}</h3>
            <p>{selectedLocation.address}</p>
          </div>
        </InfoWindow>
      )}
    </GoogleMap>
  );
};

Giải pháp kỹ thuật chi tiết

1. Location Data Model

public class LocationDto
{
    public int Id { get; set; }
    public string LocationName { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string Country { get; set; }
    public string PostalCode { get; set; }
    
    // Geolocation
    public double Latitude { get; set; }
    public double Longitude { get; set; }
    public string GeoHash { get; set; } // For spatial queries
    
    // Metadata
    public string Category { get; set; } // Retail, Office, Warehouse
    public string Phone { get; set; }
    public string Email { get; set; }
    public string Website { get; set; }
    
    // Business Hours
    public Dictionary<DayOfWeek, BusinessHours> OpeningHours { get; set; }
    
    // Status
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}

public class BusinessHours
{
    public TimeSpan OpenTime { get; set; }
    public TimeSpan CloseTime { get; set; }
    public bool IsClosed { get; set; }
}

2. Geospatial Queries với SQL Server

public class LocationRepository : ILocationRepository
{
    private readonly ApplicationDbContext _context;
    
    public async Task<IEnumerable<LocationDto>> SearchByRadiusAsync(
        double latitude, 
        double longitude, 
        double radiusKm)
    {
        // Sử dụng SQL Server Spatial types
        var userLocation = new Point(longitude, latitude) { SRID = 4326 };
        
        var query = from loc in _context.Locations
                    let locationPoint = SqlFunctions.PointFromText(
                        $"POINT({loc.Longitude} {loc.Latitude})", 4326)
                    where locationPoint.STDistance(userLocation) <= radiusKm * 1000
                    orderby locationPoint.STDistance(userLocation)
                    select new LocationDto
                    {
                        Id = loc.Id,
                        LocationName = loc.LocationName,
                        Latitude = loc.Latitude,
                        Longitude = loc.Longitude,
                        Distance = locationPoint.STDistance(userLocation) / 1000 // km
                    };
        
        return await query.ToListAsync();
    }
    
    public async Task<IEnumerable<LocationDto>> SearchByBoundingBoxAsync(
        double minLat, double maxLat, 
        double minLng, double maxLng)
    {
        return await _context.Locations
            .Where(loc => 
                loc.Latitude >= minLat && loc.Latitude <= maxLat &&
                loc.Longitude >= minLng && loc.Longitude <= maxLng)
            .Select(loc => new LocationDto
            {
                Id = loc.Id,
                LocationName = loc.LocationName,
                Latitude = loc.Latitude,
                Longitude = loc.Longitude
            })
            .ToListAsync();
    }
}

3. Caching Strategy

public class CachedLocationService : ILocationService
{
    private readonly ILocationService _innerService;
    private readonly IDistributedCache _cache;
    private readonly ILogger<CachedLocationService> _logger;
    
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
    private static readonly string CacheKeyPrefix = "location:";
    
    public CachedLocationService(
        ILocationService innerService,
        IDistributedCache cache,
        ILogger<CachedLocationService> logger)
    {
        _innerService = innerService;
        _cache = cache;
        _logger = logger;
    }
    
    public async Task<LocationDto> GetLocationAsync(int locationId)
    {
        var cacheKey = $"{CacheKeyPrefix}{locationId}";
        
        // Try get from cache
        var cachedLocation = await _cache.GetStringAsync(cacheKey);
        if (!string.IsNullOrEmpty(cachedLocation))
        {
            _logger.LogDebug("Cache hit for location {LocationId}", locationId);
            return JsonSerializer.Deserialize<LocationDto>(cachedLocation);
        }
        
        // Cache miss - get from database
        _logger.LogDebug("Cache miss for location {LocationId}", locationId);
        var location = await _innerService.GetLocationAsync(locationId);
        
        if (location != null)
        {
            // Store in cache
            var serialized = JsonSerializer.Serialize(location);
            await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = CacheDuration,
                SlidingExpiration = TimeSpan.FromMinutes(10)
            });
        }
        
        return location;
    }
    
    public async Task<IEnumerable<LocationDto>> SearchLocationsAsync(SearchCriteria criteria)
    {
        // Cache key based on search criteria hash
        var criteriaHash = ComputeHash(criteria);
        var cacheKey = $"{CacheKeyPrefix}search:{criteriaHash}";
        
        var cached = await _cache.GetStringAsync(cacheKey);
        if (!string.IsNullOrEmpty(cached))
        {
            return JsonSerializer.Deserialize<IEnumerable<LocationDto>>(cached);
        }
        
        var results = await _innerService.SearchLocationsAsync(criteria);
        
        // Only cache if result count is reasonable
        if (results.Count() <= 100)
        {
            var serialized = JsonSerializer.Serialize(results);
            await _cache.SetStringAsync(cacheKey, serialized, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
            });
        }
        
        return results;
    }
    
    private string ComputeHash(SearchCriteria criteria)
    {
        using var sha256 = SHA256.Create();
        var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(criteria));
        var hash = sha256.ComputeHash(bytes);
        return Convert.ToBase64String(hash);
    }
}

4. Bulk Import với Background Processing

public class LocationImportService : ILocationImportService
{
    private readonly ILocationService _locationService;
    private readonly IBackgroundJobClient _jobClient;
    private readonly IBlobStorageService _blobStorage;
    
    public async Task<ImportJobDto> StartImportAsync(Stream csvStream, string userId)
    {
        // Upload CSV to blob storage
        var blobName = $"imports/{Guid.NewGuid()}.csv";
        await _blobStorage.UploadAsync(blobName, csvStream);
        
        // Create import job
        var jobId = Guid.NewGuid().ToString();
        var job = new ImportJob
        {
            Id = jobId,
            BlobName = blobName,
            UserId = userId,
            Status = ImportJobStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
        
        // Queue background job
        await _jobClient.Enqueue<ILocationProcessor>(processor => 
            processor.ProcessImportAsync(jobId, blobName));
        
        return new ImportJobDto
        {
            JobId = jobId,
            Status = ImportJobStatus.Pending,
            EstimatedCompletionTime = DateTime.UtcNow.AddMinutes(5)
        };
    }
}

public interface ILocationProcessor
{
    Task ProcessImportAsync(string jobId, string blobName);
}

public class LocationProcessor : ILocationProcessor
{
    private readonly ILocationService _locationService;
    private readonly IBlobStorageService _blobStorage;
    private readonly IEmailService _emailService;
    
    public async Task ProcessImportAsync(string jobId, string blobName)
    {
        try
        {
            // Download CSV from blob
            var csvStream = await _blobStorage.DownloadAsync(blobName);
            
            // Parse CSV
            var locations = ParseCsv(csvStream);
            
            // Validate locations
            var validationResults = await ValidateLocationsAsync(locations);
            
            // Import valid locations
            var importedCount = 0;
            foreach (var location in locations.Where(l => l.IsValid))
            {
                await _locationService.CreateLocationAsync(location.ToRequest());
                importedCount++;
            }
            
            // Update job status
            await UpdateJobStatusAsync(jobId, ImportJobStatus.Completed, importedCount);
            
            // Send completion email
            await _emailService.SendImportCompletionEmailAsync(jobId, importedCount);
        }
        catch (Exception ex)
        {
            await UpdateJobStatusAsync(jobId, ImportJobStatus.Failed, 0, ex.Message);
            throw;
        }
    }
    
    private List<LocationImportDto> ParseCsv(Stream csvStream)
    {
        using var reader = new StreamReader(csvStream);
        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
        return csv.GetRecords<LocationImportDto>().ToList();
    }
}

Thách thức & Giải pháp

Challenge 1: Đồng bộ dữ liệu giữa CMS và Map

Vấn đề: Khi nhiều editors cùng chỉnh sửa locations, dữ liệu có thể bị conflict.

Giải pháp:

public class OptimisticConcurrencyHandler
{
    public async Task<LocationDto> UpdateLocationAsync(
        int locationId, 
        LocationUpdateRequest request,
        string etag)
    {
        var location = await _context.Locations.FindAsync(locationId);
        if (location == null) return null;
        
        // Check ETag for concurrency
        var currentEtag = ComputeEtag(location);
        if (currentEtag != etag)
        {
            throw new ConcurrencyException(
                "Location was modified by another user. Please refresh and try again.");
        }
        
        // Apply updates
        location.Address = request.Address;
        location.Latitude = request.Latitude;
        location.Longitude = request.Longitude;
        location.UpdatedAt = DateTime.UtcNow;
        
        await _context.SaveChangesAsync();
        
        return MapToDto(location);
    }
    
    private string ComputeEtag(Location location)
    {
        var hash = SHA256.HashData(
            Encoding.UTF8.GetBytes($"{location.Id}:{location.UpdatedAt:O}"));
        return $"\"{Convert.ToBase64String(hash)}\"";
    }
}

Challenge 2: Performance với 10,000+ markers

Vấn đề: Render 10,000 markers trên map gây chậm trang.

Giải pháp:

  1. Server-side clustering: Group nearby locations trên server
  2. Viewport-based loading: Chỉ load markers trong viewport
  3. Lazy loading: Load thêm khi zoom in
// Viewport-based API
const loadMarkers = async (bounds, zoom) => {
  const response = await fetch(
    `/api/locations/search?minLat=${bounds.sw.lat}&maxLat=${bounds.ne.lat}` +
    `&minLng=${bounds.sw.lng}&maxLng=${bounds.ne.lng}&zoom=${zoom}`
  );
  return await response.json();
};

// Debounced map movement
const onMapMoveEnd = useCallback(debounce((map) => {
  const bounds = map.getBounds();
  const zoom = map.getZoom();
  loadMarkers(bounds, zoom).then(setMarkers);
}, 300), []);

Challenge 3: Geocoding API rate limits

Vấn đề: Google Geocoding API có giới hạn requests.

Giải pháp:

public class GeocodingService : IGeocodingService
{
    private readonly IGeocodingCache _cache;
    private readonly RateLimiter _rateLimiter;
    
    public async Task<GeocodeResult> GeocodeAsync(string address)
    {
        // Check cache first
        var cached = await _cache.GetGeocodeResultAsync(address);
        if (cached != null) return cached;
        
        // Rate limiting
        await _rateLimiter.WaitAsync();
        
        // Call Google Geocoding API
        var result = await _googleGeocodingClient.GeocodeAsync(address);
        
        // Cache result
        await _cache.SetGeocodeResultAsync(address, result, TimeSpan.FromDays(30));
        
        return result;
    }
}

public class RateLimiter
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);
    private readonly TimeSpan _delay = TimeSpan.FromMilliseconds(200); // 5 req/s
    
    public async Task WaitAsync()
    {
        await _semaphore.WaitAsync();
        try
        {
            await Task.Delay(_delay);
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Kết quả & Impact

Metrics

MetricBeforeAfterImprovement
Page load time4.5s1.8s60% faster
Map render time2.1s0.4s81% faster
API response time (p95)850ms120ms86% faster
Cache hit rate-94%-
Import processing time15 min3 min80% faster

Business Impact

  • Content editors tiết kiệm 70% thời gian khi quản lý locations
  • User engagement tăng 35% với interactive store locator
  • Giảm 90% support tickets liên quan đến location data errors

Bài học kinh nghiệm

Technical Learnings

  1. Caching is critical: Luôn cache geospatial queries và map tiles
  2. Lazy loading matters: Không load tất cả markers cùng lúc
  3. Background jobs: Offload heavy processing khỏi request pipeline
  4. Rate limiting: Protect external API calls với rate limiters

Soft Skills

  1. Communication: Regular sync với stakeholders để understand requirements
  2. Documentation: Viết docs cho APIs và CMS integration
  3. Code review: Pair programming với junior developers

Câu hỏi phỏng vấn

Q1: Tại sao chọn Optimizely CMS thay vì custom solution?

A:

  • Time-to-market: Optimizely cung cấp sẵn content modeling, admin UI, workflow
  • Scalability: Built-in caching, CDN integration, multi-language support
  • Maintainability: Content editors quen với CMS UI, không cần training nhiều
  • Trade-off: Learning curve với proprietary API, nhưng worth it cho long-term

Q2: Bạn xử lý concurrency trong CMS như thế nào?

A:

  • Optimistic concurrency với ETags
  • Khi user save, check ETag match với current version
  • Nếu mismatch → show conflict dialog, let user decide (overwrite or refresh)
  • Version history: Optimizely tự động lưu versions, có thể rollback

Q3: Làm sao để test Google Maps integration?

A:

// Unit test với mock Google Maps API
public class GoogleMapsServiceTests
{
    [Fact]
    public async Task Geocode_ValidAddress_ReturnsCoordinates()
    {
        // Arrange
        var mockClient = new Mock<IGeocodingClient>();
        mockClient.Setup(c => c.GeocodeAsync("Hanoi, Vietnam"))
            .ReturnsAsync(new GeocodeResult { Latitude = 21.0285, Longitude = 105.8542 });
        
        var service = new GoogleMapsService(mockClient.Object);
        
        // Act
        var result = await service.GeocodeAsync("Hanoi, Vietnam");
        
        // Assert
        Assert.Equal(21.0285, result.Latitude, 4);
        Assert.Equal(105.8542, result.Longitude, 4);
    }
    
    // Integration test với Google Maps API sandbox
    [Fact]
    public async Task Geocode_IntegrationTest_RealApi()
    {
        var apiKey = Environment.GetEnvironmentVariable("GOOGLE_MAPS_TEST_API_KEY");
        var client = new GoogleGeocodingClient(apiKey);
        
        var result = await client.GeocodeAsync("Hanoi, Vietnam");
        
        Assert.NotNull(result);
        Assert.InRange(result.Latitude, 20.5, 21.5);
    }
}

Q4: Bạn optimize database queries cho geospatial data như thế nào?

A:

  1. Spatial indexes: Tạo index trên columns (Latitude, Longitude)
  2. Bounding box first: Filter nhanh với bounding box trước khi tính distance
  3. Covering indexes: Include các columns thường select
  4. Query optimization:
-- Tạo spatial index
CREATE INDEX IX_Locations_Location 
ON Locations (Latitude, Longitude) 
INCLUDE (LocationName, Category);

-- Query optimized
SELECT TOP 100 
    LocationName, Latitude, Longitude,
    Geography::Point(Latitude, Longitude, 4326).STDistance(
        Geography::Point(@userLat, @userLng, 4326)
    ) AS Distance
FROM Locations
WHERE Latitude BETWEEN @minLat AND @maxLat
  AND Longitude BETWEEN @minLng AND @maxLng
ORDER BY Distance;

Q5: Nếu phải redesign lại system, bạn sẽ thay đổi gì?

A:

  • Move to microservices: Tách Location Service ra khỏi CMS monolith
  • Event sourcing: Track location changes với event stream
  • GraphQL API: Cho frontend flexibility hơn REST
  • Real-time updates: SignalR cho live location updates
  • Better monitoring: Application Insights cho detailed telemetry

← Professional Experience | Xem dự án tiếp theo →