Спроектировать систему отображения ленты (feed) для приложения с характеристиками Instagram: бесконечная прокрутка, оптимизация изображений, lazy loading и высокая производительность на мобильных устройствах.
<?php
declare(strict_types=1);
namespace App\Service;
final readonly class ImageService
{
public function __construct(
private string $cdnBaseUrl,
private string $imageTransformSecret,
) {}
/**
* Generate signed URL for image with transformations.
* Uses CDN-side image processing (e.g., Cloudinary, Imgproxy).
*/
public function getUrl(
string $path,
int $width,
string $format = 'webp',
int $quality = 80,
): string {
$transforms = "w_{$width},f_{$format},q_{$quality}";
$signature = $this->sign("{$transforms}/{$path}");
return "{$this->cdnBaseUrl}/{$signature}/{$transforms}/{$path}";
}
/**
* Generate BlurHash placeholder during upload.
*/
public function generateBlurhash(string $imagePath): string
{
// Resize to tiny image for fast BlurHash computation
$img = imagecreatefromstring(file_get_contents($imagePath));
$small = imagecreatetruecolor(4, 4);
imagecopyresampled($small, $img, 0, 0, 0, 0, 4, 4, imagesx($img), imagesy($img));
// Extract pixel data for BlurHash
$pixels = [];
for ($y = 0; $y < 4; $y++) {
for ($x = 0; $x < 4; $x++) {
$rgb = imagecolorat($small, $x, $y);
$pixels[] = [
($rgb >> 16) & 0xFF,
($rgb >> 8) & 0xFF,
$rgb & 0xFF,
];
}
}
imagedestroy($img);
imagedestroy($small);
// In production, use kornrunner/blurhash library
return $this->encodeBlurhash($pixels, 4, 3);
}
/**
* Extract dominant color for CSS background placeholder.
*/
public function getDominantColor(string $imagePath): string
{
$img = imagecreatefromstring(file_get_contents($imagePath));
$pixel = imagecreatetruecolor(1, 1);
imagecopyresampled($pixel, $img, 0, 0, 0, 0, 1, 1, imagesx($img), imagesy($img));
$rgb = imagecolorat($pixel, 0, 0);
imagedestroy($img);
imagedestroy($pixel);
return sprintf('#%06x', $rgb);
}
private function sign(string $path): string
{
return substr(hash_hmac('sha256', $path, $this->imageTransformSecret), 0, 16);
}
private function encodeBlurhash(array $pixels, int $xComponents, int $yComponents): string
{
// Simplified — use kornrunner/blurhash in production
return 'LKO2:N%2Tw=w]~RBVZRi};RPxuwH';
}
}
package service
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
)
// ImageService generates signed URLs for CDN image transformations.
type ImageService struct {
cdnBaseURL string
imageTransformSecret string
}
// NewImageService creates an image service.
func NewImageService(cdnBaseURL, secret string) *ImageService {
return &ImageService{cdnBaseURL: cdnBaseURL, imageTransformSecret: secret}
}
// GetURL generates a signed URL for an image with transformations.
func (s *ImageService) GetURL(path string, width int, format string) string {
if format == "" {
format = "webp"
}
quality := 80
transforms := fmt.Sprintf("w_%d,f_%s,q_%d", width, format, quality)
signature := s.sign(transforms + "/" + path)
return fmt.Sprintf("%s/%s/%s/%s", s.cdnBaseURL, signature, transforms, path)
}
func (s *ImageService) sign(path string) string {
mac := hmac.New(sha256.New, []byte(s.imageTransformSecret))
mac.Write([]byte(path))
return fmt.Sprintf("%x", mac.Sum(nil))[:16]
}
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
namespace App.Services;
/// CDN settings bound from configuration — no hardcoded host or secret.
public sealed record ImageCdnOptions
{
public required string BaseUrl { get; init; }
public required string TransformSecret { get; init; }
}
public interface IImageService
{
string GetUrl(string path, int width, string format = "webp", int quality = 80);
}
/// Generates signed URLs for CDN-side image processing
/// (e.g., Cloudinary, Imgproxy).
public sealed class ImageService : IImageService
{
private const int SignatureLength = 16;
private readonly ImageCdnOptions _options;
public ImageService(IOptions<ImageCdnOptions> options) => _options = options.Value;
// Generate a signed URL for an image with transformations.
public string GetUrl(string path, int width, string format = "webp", int quality = 80)
{
var transforms = $"w_{width},f_{format},q_{quality}";
var signature = Sign($"{transforms}/{path}");
return $"{_options.BaseUrl}/{signature}/{transforms}/{path}";
}
private string Sign(string path)
{
var key = Encoding.UTF8.GetBytes(_options.TransformSecret);
var hash = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(path));
return Convert.ToHexString(hash).ToLowerInvariant()[..SignatureLength];
}
}
import hashlib
import hmac
from dataclasses import dataclass
SIGNATURE_LENGTH = 16
@dataclass(frozen=True, slots=True)
class ImageCdnSettings:
"""CDN settings loaded from configuration — no hardcoded host or secret."""
base_url: str
transform_secret: str
class ImageService:
"""Generate signed URLs for CDN-side image processing
(e.g., Cloudinary, Imgproxy).
"""
# Python has no compile-time DI container; settings are injected manually.
def __init__(self, settings: ImageCdnSettings) -> None:
self._settings = settings
def get_url(
self,
path: str,
width: int,
image_format: str = "webp",
quality: int = 80,
) -> str:
"""Generate a signed URL for an image with transformations."""
transforms = f"w_{width},f_{image_format},q_{quality}"
signature = self._sign(f"{transforms}/{path}")
return f"{self._settings.base_url}/{signature}/{transforms}/{path}"
def _sign(self, path: str) -> str:
digest = hmac.new(
self._settings.transform_secret.encode(),
path.encode(),
hashlib.sha256,
).hexdigest()
return digest[:SIGNATURE_LENGTH]
## Infinite Scroll
Стратегия загрузки
Viewport:
┌──────────────────┐
│ Post 1 (видим) │
│ Post 2 (видим) │
│ Post 3 (видим) │
├──────────────────┤ ← viewport bottom
│ Post 4 │ ← trigger zone (2 posts from bottom)
│ Post 5 │
│ [Loading...] │ ← fetch next page when trigger reaches viewport
└──────────────────┘
Feed Service с курсорной пагинацией
<?php
declare(strict_types=1);
namespace App\Service;
use Doctrine\DBAL\Connection;
final readonly class FeedService
{
public function __construct(
private Connection $db,
private RecommendationClient $recommendations,
) {}
/**
* Get feed items using cursor-based pagination.
* Cursor is the created_at timestamp of the last seen post.
*/
public function getFeed(string $userId, ?string $cursor, int $limit): FeedResult
{
$params = ['userId' => $userId, 'limit' => $limit + 1];
$sql = 'SELECT p.*, u.username, u.avatar_path
FROM posts p
JOIN users u ON p.author_id = u.id
JOIN follows f ON f.followed_id = p.author_id
WHERE f.follower_id = :userId';
if ($cursor !== null) {
$sql .= ' AND p.created_at < :cursor';
$params['cursor'] = $cursor;
}
$sql .= ' ORDER BY p.created_at DESC LIMIT :limit';
$rows = $this->db->fetchAllAssociative($sql, $params);
$hasMore = count($rows) > $limit;
$posts = array_slice($rows, 0, $limit);
$nextCursor = $hasMore ? end($posts)['created_at'] : null;
return new FeedResult(
posts: array_map($this->hydrate(...), $posts),
nextCursor: $nextCursor,
hasMore: $hasMore,
);
}
private function hydrate(array $row): Post
{
// Hydrate Post entity from DB row
return new Post(/* ... */);
}
}
final readonly class FeedResult
{
public function __construct(
/** @var array<Post> */
public array $posts,
public ?string $nextCursor,
public bool $hasMore,
) {}
}
package service
import (
"context"
"database/sql"
"time"
)
// Post represents a feed post.
type Post struct {
ID string
AuthorID string
Username string
Caption string
CreatedAt time.Time
}
// FeedResult holds a page of feed posts.
type FeedResult struct {
Posts []Post
NextCursor string
HasMore bool
}
// FeedService retrieves feed items with cursor-based pagination.
type FeedService struct {
db *sql.DB
}
// NewFeedService creates a feed service.
func NewFeedService(db *sql.DB) *FeedService {
return &FeedService{db: db}
}
// GetFeed returns feed items using cursor-based pagination.
func (s *FeedService) GetFeed(ctx context.Context, userID, cursor string, limit int) (FeedResult, error) {
query := `SELECT p.id, p.author_id, u.username, p.caption, p.created_at
FROM posts p
JOIN users u ON p.author_id = u.id
JOIN follows f ON f.followed_id = p.author_id
WHERE f.follower_id = $1`
args := []any{userID}
if cursor != "" {
query += ` AND p.created_at < $2`
args = append(args, cursor)
}
query += ` ORDER BY p.created_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1)
args = append(args, limit+1)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return FeedResult{}, fmt.Errorf("query feed: %w", err)
}
defer rows.Close()
var posts []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.AuthorID, &p.Username, &p.Caption, &p.CreatedAt); err != nil {
return FeedResult{}, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
}
hasMore := len(posts) > limit
if hasMore {
posts = posts[:limit]
}
var nextCursor string
if hasMore && len(posts) > 0 {
nextCursor = posts[len(posts)-1].CreatedAt.Format(time.RFC3339Nano)
}
return FeedResult{
Posts: posts,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
using Dapper;
using Npgsql;
namespace App.Services;
/// Holds a page of feed posts.
public sealed record FeedResult(
IReadOnlyList<Post> Posts,
string? NextCursor,
bool HasMore);
public interface IFeedService
{
Task<FeedResult> GetFeedAsync(string userId, string? cursor, int limit, CancellationToken ct = default);
}
/// Retrieves feed items with cursor-based pagination.
public sealed class FeedService : IFeedService
{
private readonly NpgsqlDataSource _db;
public FeedService(NpgsqlDataSource db) => _db = db;
// Get feed items using cursor-based pagination.
// Cursor is the created_at timestamp of the last seen post.
public async Task<FeedResult> GetFeedAsync(
string userId,
string? cursor,
int limit,
CancellationToken ct = default)
{
var sql = """
SELECT p.id, p.author_id, u.username, p.caption, p.created_at
FROM posts p
JOIN users u ON p.author_id = u.id
JOIN follows f ON f.followed_id = p.author_id
WHERE f.follower_id = @userId
""";
if (cursor is not null)
{
sql += " AND p.created_at < @cursor";
}
sql += " ORDER BY p.created_at DESC LIMIT @limit";
await using var connection = await _db.OpenConnectionAsync(ct);
// Fetch one extra row to detect whether another page exists
var rows = (await connection.QueryAsync<Post>(
new CommandDefinition(
sql,
new { userId, cursor = DateTimeOffset.TryParse(cursor, out var c) ? c : (DateTimeOffset?)null, limit = limit + 1 },
cancellationToken: ct)))
.ToList();
var hasMore = rows.Count > limit;
var posts = hasMore ? rows.Take(limit).ToArray() : rows.ToArray();
var nextCursor = hasMore && posts.Length > 0
? posts[^1].CreatedAt.ToString("O")
: null;
return new FeedResult(posts, nextCursor, hasMore);
}
}
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, Sequence
import asyncpg
@dataclass(frozen=True, slots=True)
class FeedResult:
"""Holds a page of feed posts."""
posts: Sequence[Post]
next_cursor: str | None
has_more: bool
class FeedService(Protocol):
# Python has no interfaces; typing.Protocol lets handlers depend on
# the shape of the service instead of a concrete class.
async def get_feed(
self,
user_id: str,
cursor: str | None,
limit: int,
) -> FeedResult: ...
class PostgresFeedService:
"""Retrieve feed items with cursor-based pagination."""
def __init__(self, pool: asyncpg.Pool) -> None:
self._pool = pool
async def get_feed(
self,
user_id: str,
cursor: str | None,
limit: int,
) -> FeedResult:
"""Get feed items using cursor-based pagination.
Cursor is the created_at timestamp of the last seen post.
"""
query = """
SELECT p.id, p.author_id, u.username, p.caption, p.created_at
FROM posts p
JOIN users u ON p.author_id = u.id
JOIN follows f ON f.followed_id = p.author_id
WHERE f.follower_id = $1
"""
args: list[object] = [user_id]
if cursor is not None:
query += " AND p.created_at < $2"
args.append(datetime.fromisoformat(cursor))
query += f" ORDER BY p.created_at DESC LIMIT ${len(args) + 1}"
# Fetch one extra row to detect whether another page exists
args.append(limit + 1)
rows = await self._pool.fetch(query, *args)
has_more = len(rows) > limit
posts = [self._hydrate(row) for row in rows[:limit]]
next_cursor = posts[-1].created_at.isoformat() if has_more and posts else None
return FeedResult(posts=posts, next_cursor=next_cursor, has_more=has_more)
@staticmethod
def _hydrate(row: asyncpg.Record) -> Post:
return Post(
id=row["id"],
author_id=row["author_id"],
username=row["username"],
caption=row["caption"],
created_at=row["created_at"],
)
## Virtual Scrolling
Virtual scrolling (windowing) -- рендер только видимых элементов. Критически важен для длинных лент.
Принцип работы
Без virtualization: С virtualization:
DOM: 1000 элементов DOM: ~15 элементов (видимые + буфер)
Scroll position: 5000px
┌─────────────────┐ ┌─────────────────┐
│ spacer (top) │ │ spacer: 4800px │ ← empty div
├─────────────────┤ ├─────────────────┤
│ Post 50 (видим) │ │ Post 50 (видим) │
│ Post 51 (видим) │ │ Post 51 (видим) │
│ Post 52 (видим) │ │ Post 52 (видим) │
├─────────────────┤ ├─────────────────┤
│ spacer (bottom) │ │ spacer: 95000px │ ← empty div
└─────────────────┘ └─────────────────┘
Memory: ~500MB Memory: ~15MB
Оффлайн-кеширование
API для кеширования на клиенте
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final readonly class FeedCacheController
{
public function __construct(
private FeedService $feedService,
) {}
/**
* Endpoint for sync — returns only posts newer than last sync.
* Client stores posts in IndexedDB for offline access.
*/
#[Route('/api/v1/feed/sync', methods: ['GET'])]
public function sync(Request $request): JsonResponse
{
$userId = $request->attributes->get('auth_user_id');
$lastSyncTimestamp = $request->query->get('since');
$newPosts = $this->feedService->getPostsSince($userId, $lastSyncTimestamp);
$response = new JsonResponse([
'posts' => $newPosts,
'sync_timestamp' => (new \DateTimeImmutable())->format(\DATE_ATOM),
'total_new' => count($newPosts),
]);
// ETag for conditional requests — saves bandwidth
$etag = md5(json_encode($newPosts));
$response->setEtag($etag);
if ($request->headers->get('If-None-Match') === "\"{$etag}\"") {
$response->setStatusCode(304);
$response->setContent('');
}
return $response;
}
}
package handler
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"time"
)
// FeedCacheHandler handles feed sync for offline caching.
type FeedCacheHandler struct {
feedService FeedService
}
// Sync returns only posts newer than the last sync timestamp.
func (h *FeedCacheHandler) Sync(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("auth_user_id").(string)
since := r.URL.Query().Get("since")
newPosts, err := h.feedService.GetPostsSince(r.Context(), userID, since)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
body := map[string]any{
"posts": newPosts,
"sync_timestamp": time.Now().Format(time.RFC3339),
"total_new": len(newPosts),
}
data, _ := json.Marshal(body)
etag := fmt.Sprintf(`"%x"`, md5.Sum(data))
// ETag for conditional requests — saves bandwidth
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Write(data)
}
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace App.Controllers;
[ApiController]
[Route("api/v1/feed/sync")]
public sealed class FeedCacheController : ControllerBase
{
private readonly IFeedService _feedService;
public FeedCacheController(IFeedService feedService) => _feedService = feedService;
// Endpoint for sync — returns only posts newer than the last sync.
// Client stores posts in IndexedDB for offline access.
[HttpGet]
public async Task<IActionResult> Sync(
[FromQuery] string? since,
CancellationToken ct = default)
{
var userId = HttpContext.Items["auth_user_id"] as string
?? throw new InvalidOperationException("Missing authenticated user");
var newPosts = await _feedService.GetPostsSinceAsync(userId, since, ct);
var payload = new
{
posts = newPosts,
sync_timestamp = DateTimeOffset.UtcNow.ToString("O"),
total_new = newPosts.Count,
};
// ETag for conditional requests — saves bandwidth
var etag = ComputeETag(newPosts);
if (Request.Headers.IfNoneMatch.ToString() == etag)
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.ETag = etag;
return Ok(payload);
}
private static string ComputeETag(object value)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
return $"\"{Convert.ToHexString(MD5.HashData(bytes)).ToLowerInvariant()}\"";
}
}
import hashlib
import json
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, Response, status
router = APIRouter(prefix="/api/v1/feed")
@router.get("/sync")
async def sync(
response: Response,
user_id: Annotated[str, Depends(current_user_id)],
feed_service: Annotated[FeedService, Depends(get_feed_service)],
since: str | None = None,
if_none_match: Annotated[str | None, Header()] = None,
) -> dict[str, Any] | Response:
"""Return only posts newer than the last sync timestamp.
The client stores posts in IndexedDB for offline access.
"""
new_posts = await feed_service.get_posts_since(user_id, since)
payload = {
"posts": new_posts,
"sync_timestamp": datetime.now(timezone.utc).isoformat(),
"total_new": len(new_posts),
}
# ETag for conditional requests — saves bandwidth
etag = '"{}"'.format(
hashlib.md5(json.dumps(new_posts, default=str).encode()).hexdigest()
)
if if_none_match == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED)
response.headers["ETag"] = etag
return payload
## Performance Budget
Ресурс
Бюджет
Примечание
HTML
< 14KB (initial)
Первый TCP roundtrip
CSS
< 50KB
Critical CSS inline
JS (initial)
< 150KB (gzipped)
Для первого рендера
JS (total)
< 500KB (gzipped)
Все чанки
Images (per post)
< 200KB
WebP, quality 80
Fonts
< 50KB
WOFF2, subset
Total initial load
< 300KB
Включая HTML+CSS+JS
Итоги
Концепция
Суть
Cursor pagination
Стабильная пагинация для бесконечного скролла
Image optimization
WebP, srcset, BlurHash, CDN
Virtual scrolling
Рендер только видимых элементов
Lazy loading
Загрузка изображений при приближении к viewport
Offline caching
IndexedDB + Service Worker для оффлайн-доступа
Performance budget
Жёсткие лимиты на размер ресурсов
Ключевой урок: Производительность ленты определяется не скоростью отдельного запроса, а ощущением бесконечной и мгновенной прокрутки. Это достигается предзагрузкой, виртуализацией и оптимизацией изображений.