🌐 REST и RESTful API

Шпаргалка для Java/Kotlin разработчика

🎯 Основные принципы REST

Принцип Описание Пример
Stateless Каждый запрос содержит всю необходимую информацию JWT токен в заголовке
Client-Server Разделение ответственности клиента и сервера Frontend ↔ Backend API
Cacheable Ответы могут кэшироваться Cache-Control: max-age=3600
Uniform Interface Единообразный интерфейс HTTP методы + URI
Layered System Архитектура может иметь промежуточные слои Load Balancer → API Gateway → Service
Code on Demand Опционально: сервер может отправлять код JavaScript в ответе

📋 HTTP методы и их использование

Основные методы

GET     /api/users          # Получить список пользователей
GET     /api/users/123      # Получить конкретного пользователя
POST    /api/users          # Создать нового пользователя
PUT     /api/users/123      # Полностью обновить пользователя
PATCH   /api/users/123      # Частично обновить пользователя
DELETE  /api/users/123      # Удалить пользователя

Идемпотентность методов

Метод Идемпотентный Безопасный Описание
GET Только чтение данных
PUT Повторный вызов даёт тот же результат
PATCH Зависит от реализации
POST Каждый вызов может создать новый ресурс
DELETE Повторное удаление безопасно
HEAD Как GET, но без тела ответа
OPTIONS Получение доступных методов

🔗 Правильное построение URI

✅ Хорошие практики

GET     /api/v1/users                    # Коллекция
GET     /api/v1/users/123                # Конкретный ресурс
GET     /api/v1/users/123/orders         # Вложенная коллекция
POST    /api/v1/users/123/orders         # Создание в вложенной коллекции
GET     /api/v1/orders?userId=123        # Фильтрация через query params

❌ Плохие практики

GET     /api/getUsers                    # Глагол в URI
POST    /api/user                        # Единственное число для коллекции
GET     /api/users/delete/123            # Действие в URI вместо HTTP метода
GET     /api/v1/users/123/delete         # Глагол вместо DELETE метода

Соглашения по именованию

  • Существительные, не глаголы: /users, не /getUsers
  • Множественное число для коллекций: /users, не /user
  • Строчные буквы с дефисами: /user-profiles, не /userProfiles
  • Версионирование: /api/v1/users

📊 HTTP статус коды

2xx - Успешные

Код Название Когда использовать
200 OK GET, PUT, PATCH успешны
201 Created POST создал ресурс
202 Accepted Запрос принят на обработку
204 No Content DELETE, PUT без возврата данных

4xx - Ошибки клиента

Код Название Когда использовать
400 Bad Request Некорректные данные запроса
401 Unauthorized Не авторизован
403 Forbidden Нет прав доступа
404 Not Found Ресурс не найден
409 Conflict Конфликт при создании/обновлении
422 Unprocessable Entity Валидация не прошла
429 Too Many Requests Rate limiting

5xx - Ошибки сервера

Код Название Когда использовать
500 Internal Server Error Общая ошибка сервера
502 Bad Gateway Ошибка upstream сервиса
503 Service Unavailable Сервис временно недоступен

🔧 Spring Boot реализация

Контроллер с правильными аннотациями

@RestController
@RequestMapping("/api/v1/users")
@Validated
public class UserController {

    @GetMapping
    public ResponseEntity<Page<UserDto>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String search) {
        
        Page<UserDto> users = userService.findUsers(page, size, search);
        return ResponseEntity.ok(users);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        UserDto user = userService.findById(id);
        return ResponseEntity.ok(user);
    }

    @PostMapping
    public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDto createdUser = userService.create(request);
        URI location = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(createdUser.getId())
            .toUri();
        return ResponseEntity.created(location).body(createdUser);
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserDto> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request) {
        UserDto updatedUser = userService.update(id, request);
        return ResponseEntity.ok(updatedUser);
    }

    @PatchMapping("/{id}")
    public ResponseEntity<UserDto> patchUser(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        UserDto patchedUser = userService.patch(id, updates);
        return ResponseEntity.ok(patchedUser);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Глобальная обработка ошибок

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "RESOURCE_NOT_FOUND",
            ex.getMessage(),
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                FieldError::getDefaultMessage
            ));
        
        ErrorResponse error = new ErrorResponse(
            "VALIDATION_FAILED",
            "Validation failed",
            Instant.now(),
            errors
        );
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
        ErrorResponse error = new ErrorResponse(
            "RESOURCE_CONFLICT",
            "Resource already exists",
            Instant.now()
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
}

📄 Стандартизация ответов

Единый формат ответа

@Data
@AllArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private T data;
    private String message;
    private Instant timestamp;
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, null, Instant.now());
    }
    
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, null, message, Instant.now());
    }
}

Пагинация

@Data
public class PageResponse<T> {
    private List<T> content;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;
    private boolean hasNext;
    private boolean hasPrevious;
    
    public static <T> PageResponse<T> of(Page<T> page) {
        PageResponse<T> response = new PageResponse<>();
        response.content = page.getContent();
        response.page = page.getNumber();
        response.size = page.getSize();
        response.totalElements = page.getTotalElements();
        response.totalPages = page.getTotalPages();
        response.hasNext = page.hasNext();
        response.hasPrevious = page.hasPrevious();
        return response;
    }
}

🔒 Безопасность и заголовки

Важные заголовки

@Configuration
public class SecurityHeadersConfig {
    
    @Bean
    public FilterRegistrationBean<SecurityHeadersFilter> securityHeadersFilter() {
        FilterRegistrationBean<SecurityHeadersFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new SecurityHeadersFilter());
        registration.addUrlPatterns("/api/*");
        return registration;
    }
}

public class SecurityHeadersFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        // CORS
        httpResponse.setHeader("Access-Control-Allow-Origin", "*");
        httpResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
        
        // Security
        httpResponse.setHeader("X-Content-Type-Options", "nosniff");
        httpResponse.setHeader("X-Frame-Options", "DENY");
        httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
        
        // Cache
        httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        
        chain.doFilter(request, response);
    }
}

📊 Мониторинг и логирование

Логирование запросов

@Component
@Slf4j
public class RequestLoggingFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        long startTime = System.currentTimeMillis();
        
        try {
            chain.doFilter(request, response);
        } finally {
            long duration = System.currentTimeMillis() - startTime;
            
            log.info("HTTP Request: {} {} - Status: {} - Duration: {}ms",
                httpRequest.getMethod(),
                httpRequest.getRequestURI(),
                httpResponse.getStatus(),
                duration
            );
        }
    }
}

Метрики с Micrometer

@Component
public class ApiMetrics {
    
    private final Counter requestCounter;
    private final Timer requestTimer;
    
    public ApiMetrics(MeterRegistry meterRegistry) {
        this.requestCounter = Counter.builder("api_requests_total")
            .description("Total API requests")
            .register(meterRegistry);
            
        this.requestTimer = Timer.builder("api_request_duration")
            .description("API request duration")
            .register(meterRegistry);
    }
    
    @EventListener
    public void handleRequestCompleted(RequestCompletedEvent event) {
        requestCounter.increment(
            "method", event.getMethod(),
            "status", String.valueOf(event.getStatus())
        );
        requestTimer.record(event.getDuration(), TimeUnit.MILLISECONDS);
    }
}

🚀 Полезные советы

✅ Лучшие практики

  • Версионирование API: используйте /api/v1/ в URL или заголовок Accept: application/vnd.api+json;version=1
  • Rate Limiting: ограничивайте количество запросов от клиента
  • Документация: используйте OpenAPI/Swagger для автогенерации документации
  • Валидация: всегда валидируйте входные данные
  • Логирование: логируйте все запросы и ошибки
  • Мониторинг: отслеживайте метрики производительности

🔍 OpenAPI документация

@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("User Management API")
                .version("v1.0")
                .description("API for managing users"))
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")));
    }
}

📱 Content Negotiation

@GetMapping(value = "/users/{id}", produces = {
    MediaType.APPLICATION_JSON_VALUE,
    MediaType.APPLICATION_XML_VALUE
})
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
    UserDto user = userService.findById(id);
    return ResponseEntity.ok(user);
}

🚀 gRPC

Шпаргалка для Java/Kotlin разработчика

🎯 Основные преимущества gRPC

Преимущество Описание vs REST
Производительность Бинарный протокол, сжатие В 7-10 раз быстрее JSON
Type Safety Строгая типизация через Protocol Buffers Нет проблем с контрактами API
Streaming Поддержка потоковых данных REST требует WebSockets
Code Generation Автогенерация клиентов и серверов Ручное написание клиентов
HTTP/2 Мультиплексирование, Server Push HTTP/1.1 по умолчанию
Deadline/Timeout Встроенная поддержка таймаутов Нужно реализовывать отдельно

📋 Типы gRPC вызовов

1. Unary RPC (обычный запрос-ответ)

service UserService {
  rpc GetUser(GetUserRequest) returns (UserResponse);
}

2. Server Streaming (сервер отправляет поток)

service UserService {
  rpc GetUsers(GetUsersRequest) returns (stream UserResponse);
}

3. Client Streaming (клиент отправляет поток)

service UserService {
  rpc CreateUsers(stream CreateUserRequest) returns (CreateUsersResponse);
}

4. Bidirectional Streaming (двусторонний поток)

service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

📄 Protocol Buffers (proto3)

Основные типы данных

syntax = "proto3";

package com.example.grpc;

option java_package = "com.example.grpc";
option java_outer_classname = "UserProto";

// Базовые типы
message User {
  int64 id = 1;                    // int64, int32, uint32, uint64
  string name = 2;                 // string
  string email = 3;                // string
  bool is_active = 4;              // bool
  double balance = 5;              // double, float
  bytes avatar = 6;                // bytes
  
  // Enum
  Status status = 7;
  
  // Вложенные объекты
  Address address = 8;
  
  // Массивы (repeated)
  repeated string tags = 9;
  repeated Order orders = 10;
  
  // Map
  map<string, string> metadata = 11;
  
  // Optional (proto3)
  optional string middle_name = 12;
  
  // Timestamp
  google.protobuf.Timestamp created_at = 13;
  google.protobuf.Timestamp updated_at = 14;
}

enum Status {
  UNKNOWN = 0;    // Первое значение всегда 0
  ACTIVE = 1;
  INACTIVE = 2;
  SUSPENDED = 3;
}

message Address {
  string street = 1;
  string city = 2;
  string country = 3;
  string postal_code = 4;
}

message Order {
  int64 id = 1;
  string product_name = 2;
  double amount = 3;
}

Импорты и well-known types

import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/wrappers.proto";

message UserResponse {
  User user = 1;
  google.protobuf.Timestamp response_time = 2;
  
  // Nullable значения
  google.protobuf.StringValue nickname = 3;  // Может быть null
  google.protobuf.Int64Value score = 4;      // Может быть null
}

message DeleteUserRequest {
  int64 user_id = 1;
}

message DeleteUserResponse {
  // Пустой ответ
  google.protobuf.Empty empty = 1;
  // или просто не указывать поля
}

🛠 Настройка в Spring Boot

build.gradle (Kotlin DSL)

plugins {
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
    id("com.google.protobuf") version "0.9.4"
    kotlin("jvm") version "1.9.20"
    kotlin("plugin.spring") version "1.9.20"
}

dependencies {
    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-web")
    
    // gRPC Spring Boot Starter
    implementation("net.devh:grpc-spring-boot-starter:2.15.0.RELEASE")
    
    // Protocol Buffers
    implementation("com.google.protobuf:protobuf-java:3.25.1")
    implementation("io.grpc:grpc-stub:1.58.0")
    implementation("io.grpc:grpc-protobuf:1.58.0")
    
    // Для Java 9+
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    
    // Monitoring
    implementation("io.micrometer:micrometer-registry-prometheus")
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.25.1"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:1.58.0"
        }
    }
    generateProtoTasks {
        all().forEach { task ->
            task.plugins {
                id("grpc")
            }
        }
    }
}

application.yml

grpc:
  server:
    port: 9090
    enable-reflection: true  # Для grpcurl и Postman
  client:
    user-service:
      address: 'static://localhost:9091'
      negotiation-type: plaintext
      max-inbound-message-size: 4MB
      max-outbound-message-size: 4MB

spring:
  application:
    name: grpc-service

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

🔧 Реализация сервера (Spring Boot)

gRPC Service

import net.devh.boot.grpc.server.service.GrpcService
import io.grpc.Status
import io.grpc.StatusException

@GrpcService
class UserGrpcService(
    private val userService: UserService
) : UserServiceGrpc.UserServiceImplBase() {

    override fun getUser(request: GetUserRequest, responseObserver: StreamObserver<UserResponse>) {
        try {
            val user = userService.findById(request.userId)
            val response = UserResponse.newBuilder()
                .setUser(user.toProto())
                .setResponseTime(Timestamp.newBuilder()
                    .setSeconds(Instant.now().epochSecond)
                    .build())
                .build()
            
            responseObserver.onNext(response)
            responseObserver.onCompleted()
        } catch (e: UserNotFoundException) {
            responseObserver.onError(
                Status.NOT_FOUND
                    .withDescription("User not found: ${request.userId}")
                    .withCause(e)
                    .asException()
            )
        } catch (e: Exception) {
            responseObserver.onError(
                Status.INTERNAL
                    .withDescription("Internal server error")
                    .withCause(e)
                    .asException()
            )
        }
    }

    override fun getUsers(request: GetUsersRequest, responseObserver: StreamObserver<UserResponse>) {
        try {
            userService.findAll(request.pageSize, request.pageToken)
                .forEach { user ->
                    val response = UserResponse.newBuilder()
                        .setUser(user.toProto())
                        .build()
                    responseObserver.onNext(response)
                }
            responseObserver.onCompleted()
        } catch (e: Exception) {
            responseObserver.onError(
                Status.INTERNAL
                    .withDescription("Failed to fetch users")
                    .asException()
            )
        }
    }

    override fun createUsers(responseObserver: StreamObserver<CreateUsersResponse>): StreamObserver<CreateUserRequest> {
        return object : StreamObserver<CreateUserRequest> {
            private val createdUsers = mutableListOf<User>()

            override fun onNext(request: CreateUserRequest) {
                try {
                    val user = userService.create(request.toDomain())
                    createdUsers.add(user)
                } catch (e: Exception) {
                    responseObserver.onError(
                        Status.INVALID_ARGUMENT
                            .withDescription("Invalid user data: ${e.message}")
                            .asException()
                    )
                }
            }

            override fun onError(t: Throwable) {
                responseObserver.onError(
                    Status.INTERNAL
                        .withDescription("Client stream error")
                        .withCause(t)
                        .asException()
                )
            }

            override fun onCompleted() {
                val response = CreateUsersResponse.newBuilder()
                    .addAllUsers(createdUsers.map { it.toProto() })
                    .setTotalCreated(createdUsers.size)
                    .build()
                
                responseObserver.onNext(response)
                responseObserver.onCompleted()
            }
        }
    }
}

Маппинг между Domain и Proto

fun User.toProto(): UserProto.User = UserProto.User.newBuilder()
    .setId(this.id)
    .setName(this.name)
    .setEmail(this.email)
    .setIsActive(this.isActive)
    .setBalance(this.balance)
    .setStatus(this.status.toProto())
    .setAddress(this.address.toProto())
    .addAllTags(this.tags)
    .putAllMetadata(this.metadata)
    .setCreatedAt(this.createdAt.toProtoTimestamp())
    .build()

fun CreateUserRequest.toDomain(): CreateUserCommand = CreateUserCommand(
    name = this.name,
    email = this.email,
    address = this.address.toDomain()
)

fun Instant.toProtoTimestamp(): Timestamp = Timestamp.newBuilder()
    .setSeconds(this.epochSecond)
    .setNanos(this.nano)
    .build()

📞 Реализация клиента

gRPC Client Configuration

@Configuration
class GrpcClientConfig {

    @Bean
    fun userServiceStub(@GrpcClient("user-service") channel: Channel): UserServiceGrpc.UserServiceBlockingStub {
        return UserServiceGrpc.newBlockingStub(channel)
    }

    @Bean
    fun userServiceAsyncStub(@GrpcClient("user-service") channel: Channel): UserServiceGrpc.UserServiceStub {
        return UserServiceGrpc.newStub(channel)
    }
}

Синхронный клиент

@Service
class UserGrpcClient(
    private val userServiceStub: UserServiceGrpc.UserServiceBlockingStub
) {

    fun getUser(userId: Long): UserResponse {
        val request = GetUserRequest.newBuilder()
            .setUserId(userId)
            .build()

        return try {
            userServiceStub
                .withDeadlineAfter(5, TimeUnit.SECONDS)
                .getUser(request)
        } catch (e: StatusRuntimeException) {
            when (e.status.code) {
                Status.Code.NOT_FOUND -> throw UserNotFoundException("User not found: $userId")
                Status.Code.DEADLINE_EXCEEDED -> throw ServiceTimeoutException("Request timeout")
                else -> throw ServiceException("gRPC call failed: ${e.status.description}")
            }
        }
    }

    fun getUsersStream(pageSize: Int): List<UserResponse> {
        val request = GetUsersRequest.newBuilder()
            .setPageSize(pageSize)
            .build()

        val users = mutableListOf<UserResponse>()
        val iterator = userServiceStub
            .withDeadlineAfter(30, TimeUnit.SECONDS)
            .getUsers(request)

        while (iterator.hasNext()) {
            users.add(iterator.next())
        }
        return users
    }
}

Асинхронный клиент

@Service
class UserGrpcAsyncClient(
    private val userServiceStub: UserServiceGrpc.UserServiceStub
) {

    fun getUserAsync(userId: Long): CompletableFuture<UserResponse> {
        val request = GetUserRequest.newBuilder()
            .setUserId(userId)
            .build()

        val future = CompletableFuture<UserResponse>()
        
        userServiceStub
            .withDeadlineAfter(5, TimeUnit.SECONDS)
            .getUser(request, object : StreamObserver<UserResponse> {
                override fun onNext(response: UserResponse) {
                    future.complete(response)
                }

                override fun onError(t: Throwable) {
                    future.completeExceptionally(t)
                }

                override fun onCompleted() {
                    // Response уже получен в onNext
                }
            })

        return future
    }

    fun createUsersStream(users: List<CreateUserRequest>): CompletableFuture<CreateUsersResponse> {
        val future = CompletableFuture<CreateUsersResponse>()
        
        val requestObserver = userServiceStub
            .withDeadlineAfter(60, TimeUnit.SECONDS)
            .createUsers(object : StreamObserver<CreateUsersResponse> {
                override fun onNext(response: CreateUsersResponse) {
                    future.complete(response)
                }

                override fun onError(t: Throwable) {
                    future.completeExceptionally(t)
                }

                override fun onCompleted() {}
            })

        // Отправляем все запросы
        users.forEach { requestObserver.onNext(it) }
        requestObserver.onCompleted()

        return future
    }
}

🔧 Интерсепторы и мидлвар

Server Interceptor

@Component
class LoggingServerInterceptor : ServerInterceptor {
    
    private val logger = LoggerFactory.getLogger(LoggingServerInterceptor::class.java)

    override fun <ReqT, RespT> interceptCall(
        call: ServerCall<ReqT, RespT>,
        headers: Metadata,
        next: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        
        val startTime = System.currentTimeMillis()
        val methodName = call.methodDescriptor.fullMethodName
        
        logger.info("gRPC call started: $methodName")

        return object : ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
            next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
                override fun close(status: Status, trailers: Metadata) {
                    val duration = System.currentTimeMillis() - startTime
                    logger.info("gRPC call completed: $methodName - Status: ${status.code} - Duration: ${duration}ms")
                    super.close(status, trailers)
                }
            }, headers)
        ) {
            override fun onMessage(message: ReqT) {
                logger.debug("gRPC request: $methodName - Message: $message")
                super.onMessage(message)
            }
        }
    }
}

Client Interceptor

@Component
class AuthClientInterceptor(
    private val jwtTokenProvider: JwtTokenProvider
) : ClientInterceptor {

    override fun <ReqT, RespT> interceptCall(
        method: MethodDescriptor<ReqT, RespT>,
        callOptions: CallOptions,
        next: Channel
    ): ClientCall<ReqT, RespT> {
        
        return object : ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
            next.newCall(method, callOptions)
        ) {
            override fun start(responseListener: Listener<RespT>, headers: Metadata) {
                // Добавляем JWT токен
                val token = jwtTokenProvider.getToken()
                headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer $token")
                
                // Добавляем correlation ID
                val correlationId = MDC.get("correlationId") ?: UUID.randomUUID().toString()
                headers.put(Metadata.Key.of("x-correlation-id", Metadata.ASCII_STRING_MARSHALLER), correlationId)
                
                super.start(responseListener, headers)
            }
        }
    }
}

📊 Мониторинг и метрики

Micrometer метрики

@Component
class GrpcMetrics(
    private val meterRegistry: MeterRegistry
) {
    
    private val grpcRequestsCounter = Counter.builder("grpc_requests_total")
        .description("Total gRPC requests")
        .register(meterRegistry)
    
    private val grpcRequestsTimer = Timer.builder("grpc_requests_duration_seconds")
        .description("gRPC request duration")
        .register(meterRegistry)
    
    fun recordRequest(method: String, status: String, duration: Duration) {
        grpcRequestsCounter.increment(
            Tags.of(
                Tag.of("method", method),
                Tag.of("status", status)
            )
        )
        
        grpcRequestsTimer.record(duration)
    }
}

@Component
class MetricsServerInterceptor(
    private val grpcMetrics: GrpcMetrics
) : ServerInterceptor {
    
    override fun <ReqT, RespT> interceptCall(
        call: ServerCall<ReqT, RespT>,
        headers: Metadata,
        next: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        
        val startTime = Instant.now()
        val methodName = call.methodDescriptor.fullMethodName
        
        return next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
            override fun close(status: Status, trailers: Metadata) {
                val duration = Duration.between(startTime, Instant.now())
                grpcMetrics.recordRequest(methodName, status.code.name, duration)
                super.close(status, trailers)
            }
        }, headers)
    }
}

🧪 Тестирование

Unit тесты с MockServer

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserGrpcServiceTest {

    private lateinit var server: Server
    private lateinit var channel: ManagedChannel
    private lateinit var stub: UserServiceGrpc.UserServiceBlockingStub

    @MockBean
    private lateinit var userService: UserService

    @BeforeAll
    fun setUp() {
        val serviceImpl = UserGrpcService(userService)
        
        server = ServerBuilder.forPort(0)
            .addService(serviceImpl)
            .build()
            .start()

        channel = ManagedChannelBuilder.forAddress("localhost", server.port)
            .usePlaintext()
            .build()

        stub = UserServiceGrpc.newBlockingStub(channel)
    }

    @AfterAll
    fun tearDown() {
        channel.shutdown()
        server.shutdown()
    }

    @Test
    fun `should return user when user exists`() {
        // Given
        val userId = 1L
        val user = User(id = userId, name = "John Doe", email = "john@example.com")
        given(userService.findById(userId)).willReturn(user)

        // When
        val request = GetUserRequest.newBuilder().setUserId(userId).build()
        val response = stub.getUser(request)

        // Then
        assertThat(response.user.id).isEqualTo(userId)
        assertThat(response.user.name).isEqualTo("John Doe")
        assertThat(response.user.email).isEqualTo("john@example.com")
    }

    @Test
    fun `should throw NOT_FOUND when user does not exist`() {
        // Given
        val userId = 999L
        given(userService.findById(userId)).willThrow(UserNotFoundException("User not found"))

        // When & Then
        val request = GetUserRequest.newBuilder().setUserId(userId).build()
        
        val exception = assertThrows<StatusRuntimeException> {
            stub.getUser(request)
        }
        
        assertThat(exception.status.code).isEqualTo(Status.Code.NOT_FOUND)
    }
}

Integration тесты с @GrpcTest

@GrpcTest(UserGrpcService::class)
class UserGrpcServiceIntegrationTest {

    @MockBean
    private lateinit var userService: UserService

    @Autowired
    private lateinit var userServiceStub: UserServiceGrpc.UserServiceBlockingStub

    @Test
    fun `should handle streaming requests`() {
        // Given
        val users = listOf(
            User(1, "User 1", "user1@example.com"),
            User(2, "User 2", "user2@example.com")
        )
        given(userService.findAll(any(), any())).willReturn(users)

        // When
        val request = GetUsersRequest.newBuilder().setPageSize(10).build()
        val responses = mutableListOf<UserResponse>()
        
        val iterator = userServiceStub.getUsers(request)
        while (iterator.hasNext()) {
            responses.add(iterator.next())
        }

        // Then
        assertThat(responses).hasSize(2)
        assertThat(responses[0].user.name).isEqualTo("User 1")
        assertThat(responses[1].user.name).isEqualTo("User 2")
    }
}

🚀 Полезные команды и инструменты

grpcurl (тестирование из командной строки)

# Список сервисов
grpcurl -plaintext localhost:9090 list

# Список методов сервиса
grpcurl -plaintext localhost:9090 list com.example.UserService

# Описание метода
grpcurl -plaintext localhost:9090 describe com.example.UserService.GetUser

# Вызов метода
grpcurl -plaintext -d '{"user_id": 1}' localhost:9090 com.example.UserService/GetUser

# Streaming
grpcurl -plaintext -d '{"page_size": 5}' localhost:9090 com.example.UserService/GetUsers

Postman

  1. Создать новый gRPC request
  2. Указать server URL: localhost:9090
  3. Импортировать .proto файлы
  4. Выбрать метод и отправить запрос

Evans (интерактивный gRPC клиент)

# Установка
go install github.com/ktr0731/evans@latest

# Подключение
evans --host localhost --port 9090 --reflection

# Использование
show package
show service
call GetUser

💡 Лучшие практики

✅ Do's

  • Используйте streaming для больших объемов данных
  • Версионирование через package в proto файлах
  • Deadline/Timeout для всех вызовов
  • Retry policy с экспоненциальным backoff
  • Circuit Breaker для внешних сервисов
  • Мониторинг latency и error rate
  • Логирование всех запросов и ошибок
  • TLS в продакшене

❌ Don'ts

  • Не используйте большие сообщения (>4MB)
  • Не забывайте про onError/onCompleted в streaming
  • Не блокируйте UI потоки длительными gRPC вызовами
  • Не игнорируйте статус коды ошибок
  • Не используйте plaintext в продакшене

🔒 Безопасность

// TLS конфигурация
@Configuration
class GrpcSecurityConfig {
    
    @Bean
    fun grpcServerCustomizer(): NettyChannelBuilderCustomizer {
        return NettyChannelBuilderCustomizer { builder ->
            builder.sslContext(
                GrpcSslContexts.forClient()
                    .trustManager(InsecureTrustManagerFactory.INSTANCE)
                    .build()
            )
        }
    }
}

🧼 SOAP

Краткая шпаргалка для Java/Kotlin разработчика

🎯 Основы SOAP

Что такое SOAP?

SOAP (Simple Object Access Protocol) — протокол обмена структурированной информацией в распределённых системах. Использует XML для форматирования сообщений и может работать поверх различных транспортных протоколов (HTTP, SMTP, TCP).

Ключевые концепции

| Компонент | Описание | Назначение | |---

📚 Теория SOAP

Стили SOAP

1. Document/Literal (рекомендуется)

  • XML Schema валидация
  • Loose coupling
  • Лучшая интероперабельность
  • Современный подход

2. RPC/Encoded (deprecated)

  • Tight coupling
  • Проблемы с интероперабельностью
  • Не используется в новых проектах

WS-* Standards (Web Services Standards)

Стандарт Назначение Пример использования
WS-Security Безопасность сообщений Username/Password, X.509, SAML
WS-ReliableMessaging Гарантированная доставка At-least-once, exactly-once
WS-Transaction Распределённые транзакции 2PC (Two-Phase Commit)
WS-Policy Политики безопасности Требования к шифрованию
WS-Addressing Маршрутизация сообщений Endpoint references

WSDL Structure

WSDL = Types + Messages + PortTypes + Bindings + Services

Types:       XSD схемы данных
Messages:    Абстрактные сообщения
PortTypes:   Абстрактные операции (интерфейс)
Bindings:    Привязка к протоколу (SOAP/HTTP)
Services:    Конкретные endpoints

Жизненный цикл SOAP запроса

  1. Client создаёт SOAP Envelope с данными
  2. Serialization объектов в XML
  3. Transport по HTTP/HTTPS
  4. Server парсит SOAP сообщение
  5. Validation против XSD схемы
  6. Business Logic обработка запроса
  7. Response создание SOAP ответа
  8. Deserialization в объекты клиента

-----------|----------|------------| | SOAP Envelope | Корневой элемент сообщения | Определяет структуру сообщения | | SOAP Header | Метаданные сообщения | Аутентификация, routing, транзакции | | SOAP Body | Основное содержимое | Данные запроса/ответа | | SOAP Fault | Информация об ошибке | Стандартизированная обработка ошибок | | WSDL | Описание веб-сервиса | Контракт API (методы, типы, endpoints) |

SOAP vs REST vs gRPC

Критерий SOAP REST gRPC
Формат XML JSON/XML Binary (Protobuf)
Размер сообщения Большой (~3-5x) Средний Маленький
Стандарты WS-*, строгие HTTP, гибкие HTTP/2, простые
Типизация Строгая (XSD) Слабая Строгая (Proto)
Кэширование Сложное (POST) Простое (GET) Нет
Транспорт HTTP, SMTP, TCP HTTP HTTP/2
Безопасность WS-Security HTTPS + OAuth TLS + Interceptors

Когда использовать SOAP?

Подходит для:

  • Интеграции с legacy системами
  • Строгих контрактов (банки, страхование)
  • Транзакционных операций
  • Корпоративных систем с WS-Security
  • Надёжной доставки сообщений

Не подходит для:

  • REST API для веб/мобильных приложений
  • Высоконагруженных систем
  • Микросервисной архитектуры
  • Простых CRUD операций

📋 Структура SOAP сообщения

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Header>
        <!-- Метаданные, аутентификация -->
    </soap:Header>
    <soap:Body>
        <tns:GetUserRequest xmlns:tns="http://example.com/userservice">
            <tns:userId>123</tns:userId>
        </tns:GetUserRequest>
    </soap:Body>
</soap:Envelope>

SOAP Fault

<soap:Fault>
    <faultcode>soap:Client</faultcode>
    <faultstring>User not found</faultstring>
    <detail>
        <errorCode>USER_NOT_FOUND</errorCode>
    </detail>
</soap:Fault>

🛠 Spring Boot Setup

dependencies (build.gradle)

implementation("org.springframework.boot:spring-boot-starter-web-services")
implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.0")
implementation("org.glassfish.jaxb:jaxb-runtime:4.0.3")

Configuration

@Configuration
@EnableWs
class WebServiceConfig {

    @Bean(name = ["users"])
    fun defaultWsdl11Definition(usersSchema: XsdSchema): DefaultWsdl11Definition {
        val wsdl = DefaultWsdl11Definition()
        wsdl.setPortTypeName("UsersPort")
        wsdl.setLocationUri("/ws")
        wsdl.setTargetNamespace("http://example.com/userservice")
        wsdl.setSchema(usersSchema)
        return wsdl
    }

    @Bean
    fun usersSchema(): XsdSchema = SimpleXsdSchema(ClassPathResource("users.xsd"))

    @Bean
    fun servletRegistrationBean(): ServletRegistrationBean<MessageDispatcherServlet> {
        return ServletRegistrationBean(MessageDispatcherServlet(), "/ws/*")
    }
}

📄 XSD Schema (users.xsd)

<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
           targetNamespace="http://example.com/userservice">

    <xs:element name="getUserRequest">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="userId" type="xs:long"/>
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:element name="getUserResponse">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="user" type="tns:user"/>
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:complexType name="user">
        <xs:sequence>
            <xs:element name="id" type="xs:long"/>
            <xs:element name="name" type="xs:string"/>
            <xs:element name="email" type="xs:string"/>
        </xs:sequence>
    </xs:complexType>
</xs:schema>

🔧 SOAP Endpoint

@Endpoint
class UserEndpoint(private val userService: UserService) {

    @PayloadRoot(namespace = "http://example.com/userservice", localPart = "getUserRequest")
    @ResponsePayload
    fun getUser(@RequestPayload request: GetUserRequest): GetUserResponse {
        val user = userService.findById(request.userId)
            ?: throw UserNotFoundException("User not found")

        val response = GetUserResponse()
        response.user = user.toSoapUser()
        return response
    }

    @SoapFault(faultCode = FaultCode.CLIENT)
    class UserNotFoundException(message: String) : Exception(message)
}

📞 SOAP Client

Configuration

@Configuration
class SoapClientConfig {

    @Bean
    fun webServiceTemplate(): WebServiceTemplate {
        val template = WebServiceTemplate()
        template.defaultUri = "http://localhost:8080/ws"
        template.marshaller = marshaller()
        template.unmarshaller = marshaller()
        return template
    }

    @Bean
    fun marshaller(): Jaxb2Marshaller {
        val marshaller = Jaxb2Marshaller()
        marshaller.setContextPath("com.example.soap.generated")
        return marshaller
    }
}

Client Service

@Service
class UserSoapClient(private val webServiceTemplate: WebServiceTemplate) {

    fun getUser(userId: Long): User? {
        val request = GetUserRequest().apply { this.userId = userId }

        return try {
            val response = webServiceTemplate.marshalSendAndReceive(request) as GetUserResponse
            response.user.toDomainUser()
        } catch (e: SoapFaultException) {
            if (e.faultCode == "USER_NOT_FOUND") null
            else throw ServiceException("SOAP call failed")
        }
    }
}

🧪 Testing

@WebServiceServerTest
class UserEndpointTest {

    @Autowired
    private lateinit var mockWebServiceClient: MockWebServiceClient

    @MockBean
    private lateinit var userService: UserService

    @Test
    fun `should return user`() {
        given(userService.findById(1L)).willReturn(testUser)

        val request = """
            <getUserRequest xmlns="http://example.com/userservice">
                <userId>1</userId>
            </getUserRequest>
        """.trimIndent()

        mockWebServiceClient
            .sendRequest(RequestCreators.withPayload(StringSource(request)))
            .andExpect(ResponseMatchers.noFault())
    }
}

🔍 Полезные команды

curl

curl -X POST http://localhost:8080/ws \
  -H "Content-Type: text/xml" \
  -d '<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <getUserRequest xmlns="http://example.com/userservice">
      <userId>1</userId>
    </getUserRequest>
  </soap:Body>
</soap:Envelope>'

WSDL доступ

http://localhost:8080/ws/users.wsdl

💡 Практические советы

✅ Best Practices

Архитектурные:

  • Используйте Document/Literal стиль
  • Проектируйте coarse-grained операции
  • Избегайте stateful сервисов
  • Версионируйте через namespace

Производительность:

  • Используйте connection pooling
  • Кэшируйте WSDL и схемы
  • Настройте reasonable timeouts (5-30с)
  • Мониторьте размер XML сообщений

Безопасность:

  • HTTPS обязательно в продакшене
  • WS-Security для enterprise интеграций
  • Валидируйте все входные данные
  • Логируйте security events

❌ Anti-patterns

Технические:

  • RPC/Encoded стиль (deprecated)
  • Игнорирование SOAP Fault
  • Отсутствие XSD валидации
  • Больше 1MB в одном сообщении

Архитектурные:

  • Chatty API (много мелких вызовов)
  • Tight coupling через shared schemas
  • Session state в SOAP сервисах
  • Смешивание business logic с endpoint

🔧 Отладка и мониторинг

Логирование:

  • Включите SOAP message logging
  • Логируйте performance metrics
  • Отслеживайте SOAP Faults
  • Корреляционные ID для трейсинга

Инструменты:

  • SoapUI — тестирование и отладка
  • Postman — простые SOAP запросы
  • Wireshark — анализ сетевого трафика
  • Spring Boot Actuator — метрики и health checks

🔮 GraphQL

Шпаргалка для Java/Kotlin разработчика

🎯 Основы GraphQL

Что такое GraphQL?

GraphQL — язык запросов и среда выполнения для API. Позволяет клиентам запрашивать точно те данные, которые им нужны, в одном запросе.

Ключевые концепции

Компонент Описание Назначение
Schema Описание API Определяет типы, поля, операции
Query Чтение данных SELECT в SQL
Mutation Изменение данных INSERT/UPDATE/DELETE в SQL
Subscription Real-time данные WebSocket подписки
Resolver Логика получения данных Связь schema с бизнес-логикой
DataLoader Оптимизация N+1 Batch loading, кэширование

GraphQL vs REST vs gRPC

Критерий GraphQL REST gRPC
Запросы Один endpoint, гибкие запросы Множество endpoints Предопределённые методы
Over/Under fetching Нет Часто Нет (строгая типизация)
Кэширование Сложное Простое (HTTP) Нет
Real-time Subscriptions WebSocket/SSE Streaming
Типизация Строгая (Schema) Слабая Строгая (Proto)
Инструменты GraphiQL, Apollo Swagger, Postman grpcurl, Evans
Сложность Высокая Низкая Средняя

Когда использовать GraphQL?

Подходит для:

  • Frontend с разными требованиями к данным
  • Mobile приложений (экономия трафика)
  • Микрофронтендов
  • Rapid prototyping
  • Агрегации данных из множества источников

Не подходит для:

  • Простых CRUD операций
  • File uploads/downloads
  • Систем реального времени с высокой нагрузкой
  • Кэширования на CDN уровне

📚 Теория GraphQL

Типы операций

1. Query (чтение)

query GetUser($id: ID!) {
  user(id: $id) {
    name
    email
    posts {
      title
      createdAt
    }
  }
}

2. Mutation (изменение)

mutation CreateUser($input: UserInput!) {
  createUser(input: $input) {
    id
    name
    email
  }
}

3. Subscription (подписка)

subscription OnCommentAdded($postId: ID!) {
  commentAdded(postId: $postId) {
    id
    content
    author {
      name
    }
  }
}

Скалярные типы

Тип Описание Java/Kotlin
String UTF-8 строка String
Int 32-bit integer Int/Integer
Float Double precision Double
Boolean true/false Boolean
ID Уникальный идентификатор String/Long

Проблемы GraphQL

1. N+1 Problem

Query: users { posts { title } }
SQL: SELECT * FROM users        (1 запрос)
     SELECT * FROM posts WHERE user_id = 1  (N запросов)

Решение: DataLoader

2. Query Complexity

  • Глубокие вложенные запросы
  • Циклические зависимости Решение: Query depth limiting, complexity analysis

3. Security

  • Denial of Service через сложные запросы
  • Информационные утечки через introspection Решение: Query whitelisting, depth limiting

🛠 Spring Boot Setup

Dependencies (build.gradle)

implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")

// Для subscriptions
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-websocket")

Configuration

@Configuration
class GraphQLConfig {

    @Bean
    fun dataFetcherExceptionResolver(): DataFetcherExceptionResolver {
        return DataFetcherExceptionResolverAdapter { ex, env ->
            when (ex) {
                is UserNotFoundException -> GraphqlErrorBuilder.newError()
                    .message(ex.message)
                    .errorType(ErrorType.NOT_FOUND)
                    .location(env.field.sourceLocation)
                    .build()
                else -> null
            }
        }
    }

    @Bean
    fun queryComplexityInstrumentation(): Instrumentation {
        return MaxQueryComplexityInstrumentation(100)
    }
}

📄 Schema Definition (schema.graphqls)

# Типы
type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
    createdAt: DateTime!
}

type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
    createdAt: DateTime!
}

type Comment {
    id: ID!
    content: String!
    author: User!
    post: Post!
    createdAt: DateTime!
}

# Input типы
input UserInput {
    name: String!
    email: String!
}

input PostInput {
    title: String!
    content: String!
    authorId: ID!
}

# Скалярные типы
scalar DateTime

# Операции
type Query {
    user(id: ID!): User
    users(first: Int, after: String): UserConnection!
    post(id: ID!): Post
    posts: [Post!]!
}

type Mutation {
    createUser(input: UserInput!): User!
    updateUser(id: ID!, input: UserInput!): User!
    deleteUser(id: ID!): Boolean!
    
    createPost(input: PostInput!): Post!
}

type Subscription {
    postAdded: Post!
    commentAdded(postId: ID!): Comment!
}

# Pagination
type UserConnection {
    edges: [UserEdge!]!
    pageInfo: PageInfo!
}

type UserEdge {
    node: User!
    cursor: String!
}

type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
}

🔧 Resolvers

Query Controller

@Controller
class UserController(
    private val userService: UserService,
    private val userDataLoader: DataLoader<Long, User>
) {

    @QueryMapping
    fun user(@Argument id: Long): User? {
        return userService.findById(id)
    }

    @QueryMapping
    fun users(
        @Argument first: Int?,
        @Argument after: String?
    ): Connection<User> {
        return userService.findAll(first ?: 10, after)
    }

    @SchemaMapping(typeName = "Post", field = "author")
    fun getAuthor(post: Post, env: DataFetchingEnvironment): CompletableFuture<User> {
        return userDataLoader.load(post.authorId, env)
    }
}

Mutation Controller

@Controller
class UserMutationController(
    private val userService: UserService
) {

    @MutationMapping
    fun createUser(@Argument input: UserInput): User {
        return userService.create(input.name, input.email)
    }

    @MutationMapping
    fun updateUser(@Argument id: Long, @Argument input: UserInput): User {
        return userService.update(id, input.name, input.email)
    }

    @MutationMapping
    fun deleteUser(@Argument id: Long): Boolean {
        userService.delete(id)
        return true
    }
}

Subscription Controller

@Controller
class SubscriptionController {

    @SubscriptionMapping
    fun postAdded(): Flux<Post> {
        return PostEventPublisher.getPostStream()
    }

    @SubscriptionMapping
    fun commentAdded(@Argument postId: Long): Flux<Comment> {
        return CommentEventPublisher.getCommentStream()
            .filter { it.postId == postId }
    }
}

🚀 DataLoader (N+1 решение)

DataLoader Configuration

@Configuration
class DataLoaderConfig {

    @Bean
    fun userDataLoader(userService: UserService): DataLoader<Long, User> {
        return DataLoader.newMappedDataLoader { userIds ->
            CompletableFuture.supplyAsync {
                userService.findByIds(userIds.toList())
                    .associateBy { it.id }
            }
        }
    }

    @Bean
    fun postDataLoader(postService: PostService): DataLoader<Long, List<Post>> {
        return DataLoader.newMappedDataLoader { userIds ->
            CompletableFuture.supplyAsync {
                postService.findByUserIds(userIds.toList())
                    .groupBy { it.authorId }
            }
        }
    }
}

DataLoader Registry

@Component
class DataLoaderRegistryFactory(
    private val userDataLoader: DataLoader<Long, User>,
    private val postDataLoader: DataLoader<Long, List<Post>>
) {

    fun create(): DataLoaderRegistry {
        return DataLoaderRegistry.newRegistry()
            .register("userDataLoader", userDataLoader)
            .register("postDataLoader", postDataLoader)
            .build()
    }
}

🧪 Testing

Query Test

@GraphQlTest(UserController::class)
class UserControllerTest {

    @Autowired
    private lateinit var graphQlTester: GraphQlTester

    @MockBean
    private lateinit var userService: UserService

    @Test
    fun `should return user by id`() {
        // Given
        val user = User(1L, "John Doe", "john@example.com")
        given(userService.findById(1L)).willReturn(user)

        // When & Then
        graphQlTester
            .document("""
                query {
                    user(id: 1) {
                        id
                        name
                        email
                    }
                }
            """)
            .execute()
            .path("user.id").entity(String::class.java).isEqualTo("1")
            .path("user.name").entity(String::class.java).isEqualTo("John Doe")
            .path("user.email").entity(String::class.java).isEqualTo("john@example.com")
    }

    @Test
    fun `should return error when user not found`() {
        given(userService.findById(999L)).willReturn(null)

        graphQlTester
            .document("""
                query {
                    user(id: 999) {
                        id
                        name
                    }
                }
            """)
            .execute()
            .errors()
            .expect { it.errorType == ErrorType.NOT_FOUND }
    }
}

💡 Практические советы

✅ Best Practices

Schema Design:

  • Используйте nullable поля разумно
  • Проектируйте схему под потребности клиентов
  • Версионируйте через deprecation
  • Используйте Connection pattern для pagination

Performance:

  • Всегда используйте DataLoader
  • Ограничивайте глубину запросов (max 10-15)
  • Мониторьте query complexity
  • Кэшируйте на уровне resolver'ов

Security:

  • Отключите introspection в продакшене
  • Используйте query whitelisting
  • Валидируйте все input'ы
  • Ограничивайте rate limiting

❌ Anti-patterns

Архитектурные:

  • 1:1 маппинг GraphQL типов на DB таблицы
  • Отсутствие DataLoader'ов
  • Слишком глубокие nested запросы
  • Использование для file uploads

Производительность:

  • Игнорирование N+1 проблемы
  • Отсутствие query complexity analysis
  • Синхронные resolver'ы для I/O операций
  • Кэширование на GraphQL уровне вместо HTTP

🔧 Мониторинг

Метрики для отслеживания:

  • Query execution time
  • Query complexity score
  • DataLoader hit ratio
  • Error rate by query type
  • Subscription connection count

Инструменты:

  • GraphiQL — интерфейс для разработки
  • Apollo Studio — мониторинг и аналитика
  • GraphQL Voyager — визуализация схемы
  • Altair — GraphQL клиент

🔌 WebSockets

Шпаргалка для Java/Kotlin разработчика

🎯 Основы WebSockets

Что такое WebSockets?

WebSocket — протокол полнодуплексной связи поверх TCP. Позволяет клиенту и серверу обмениваться данными в реальном времени после установления соединения.

Ключевые концепции

Компонент Описание Назначение
Handshake HTTP Upgrade запрос Установление WebSocket соединения
Frame Единица передачи данных Text, Binary, Control frames
Session Соединение клиент-сервер Управление состоянием соединения
Endpoint Точка подключения Server/Client endpoint
Message Handler Обработчик сообщений OnOpen, OnMessage, OnClose, OnError
STOMP Messaging protocol Структурированные сообщения поверх WebSocket

WebSockets vs HTTP vs SSE

Критерий WebSockets HTTP SSE
Дуплексность Full-duplex Request-Response Server → Client
Overhead Низкий (после handshake) Высокий (headers) Средний
Real-time Отличный Плохой (polling) Хороший
Сложность Высокая Низкая Низкая
Кэширование Нет Да Нет
Firewall/Proxy Проблемы Нет проблем Нет проблем
Автореконнект Ручной Не нужен Автоматический

Когда использовать WebSockets?

Подходит для:

  • Чаты и мессенджеры
  • Real-time игры
  • Live обновления (биржевые котировки)
  • Collaborative editing (Google Docs)
  • IoT и sensor data
  • Live notifications

Не подходит для:

  • Простых CRUD операций
  • File uploads/downloads
  • SEO-critical контента
  • Односторонних уведомлений (лучше SSE)
  • RESTful API

📚 Теория WebSockets

Жизненный цикл соединения

1. Handshake (HTTP Upgrade)

GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

2. Communication (Frames)

  • Text Frame: UTF-8 текст
  • Binary Frame: Бинарные данные
  • Control Frames: Ping/Pong, Close

3. Close

  • Graceful close (Close frame)
  • Connection drop
  • Error close

STOMP Protocol

Simple Text Oriented Messaging Protocol — протокол для структурированного обмена сообщениями поверх WebSocket.

CONNECT
accept-version:1.2
host:localhost

SUBSCRIBE
id:sub-0
destination:/topic/chat

MESSAGE
destination:/topic/chat
message-id:007
content-length:11

Hello World

DISCONNECT
receipt:77

Проблемы WebSockets

1. Scaling (Горизонтальное масштабирование)

  • Session affinity (sticky sessions)
  • Shared state между серверами
  • Message broadcasting между узлами

2. Connection Management

  • Heartbeat/Ping-Pong для проверки живости
  • Graceful shutdown
  • Reconnection logic

3. Security

  • CSRF атаки
  • Origin validation
  • Authentication/Authorization

🛠 Spring Boot Setup

Dependencies (build.gradle)

implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-starter-security")

// Для STOMP messaging
implementation("org.springframework:spring-messaging")

// Для Redis (scaling)
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")

WebSocket Configuration

@Configuration
@EnableWebSocket
class WebSocketConfig : WebSocketConfigurer {

    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(ChatWebSocketHandler(), "/chat")
            .setAllowedOrigins("*") // В продакшене указать конкретные домены
            .withSockJS() // Fallback для старых браузеров
    }
}

STOMP Configuration

@Configuration
@EnableWebSocketMessageBroker
class StompConfig : WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(config: MessageBrokerRegistry) {
        // In-memory broker для /topic, /queue
        config.enableSimpleBroker("/topic", "/queue")
        
        // Внешний broker (RabbitMQ, ActiveMQ)
        // config.enableStompBrokerRelay("/topic", "/queue")
        //     .setRelayHost("localhost")
        //     .setRelayPort(61613)
        
        config.setApplicationDestinationPrefixes("/app")
        config.setUserDestinationPrefix("/user")
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("*")
            .withSockJS()
    }
}

🔧 WebSocket Handler

Raw WebSocket Handler

@Component
class ChatWebSocketHandler : TextWebSocketHandler() {
    
    private val sessions = ConcurrentHashMap<String, WebSocketSession>()
    private val logger = LoggerFactory.getLogger(ChatWebSocketHandler::class.java)

    override fun afterConnectionEstablished(session: WebSocketSession) {
        val userId = getUserId(session)
        sessions[userId] = session
        logger.info("WebSocket connection established for user: $userId")
        
        // Отправляем приветственное сообщение
        session.sendMessage(TextMessage("""{"type": "welcome", "message": "Connected"}"""))
    }

    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        val userId = getUserId(session)
        val payload = message.payload
        
        logger.info("Received message from $userId: $payload")
        
        // Парсим сообщение
        val chatMessage = objectMapper.readValue(payload, ChatMessage::class.java)
        
        // Обрабатываем сообщение
        when (chatMessage.type) {
            "chat" -> broadcastMessage(chatMessage, userId)
            "typing" -> broadcastTyping(chatMessage, userId)
            else -> logger.warn("Unknown message type: ${chatMessage.type}")
        }
    }

    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        val userId = getUserId(session)
        sessions.remove(userId)
        logger.info("WebSocket connection closed for user: $userId, status: $status")
    }

    override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
        val userId = getUserId(session)
        logger.error("WebSocket transport error for user: $userId", exception)
        sessions.remove(userId)
    }

    private fun broadcastMessage(message: ChatMessage, senderId: String) {
        val response = ChatResponse(
            id = UUID.randomUUID().toString(),
            senderId = senderId,
            content = message.content,
            timestamp = Instant.now()
        )
        
        val jsonMessage = objectMapper.writeValueAsString(response)
        
        sessions.values.forEach { session ->
            if (session.isOpen) {
                try {
                    session.sendMessage(TextMessage(jsonMessage))
                } catch (e: IOException) {
                    logger.error("Failed to send message to session", e)
                }
            }
        }
    }

    private fun getUserId(session: WebSocketSession): String {
        return session.attributes["userId"] as? String ?: "anonymous"
    }
}

📨 STOMP Messaging

Message Controller

@Controller
class ChatController(
    private val messagingTemplate: SimpMessagingTemplate,
    private val chatService: ChatService
) {

    @MessageMapping("/chat.sendMessage") // /app/chat.sendMessage
    @SendTo("/topic/public") // Broadcast всем подписчикам
    fun sendMessage(chatMessage: ChatMessage): ChatMessage {
        return chatService.saveMessage(chatMessage)
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    fun addUser(chatMessage: ChatMessage, headerAccessor: SimpMessageHeaderAccessor): ChatMessage {
        // Добавляем username в WebSocket session
        headerAccessor.sessionAttributes?.put("username", chatMessage.sender)
        return chatMessage
    }

    // Отправка сообщения конкретному пользователю
    @MessageMapping("/chat.private")
    fun sendPrivateMessage(message: PrivateMessage) {
        messagingTemplate.convertAndSendToUser(
            message.recipientId,
            "/queue/private",
            message
        )
    }

    // Периодические обновления
    @Scheduled(fixedRate = 5000)
    fun sendPeriodicUpdates() {
        val update = SystemUpdate(
            timestamp = Instant.now(),
            activeUsers = chatService.getActiveUsersCount()
        )
        messagingTemplate.convertAndSend("/topic/updates", update)
    }
}

Event Listeners

@Component
class WebSocketEventListener(
    private val messagingTemplate: SimpMessagingTemplate
) {

    @EventListener
    fun handleWebSocketConnectListener(event: SessionConnectedEvent) {
        val username = getUsernameFromEvent(event)
        logger.info("User connected: $username")
    }

    @EventListener
    fun handleWebSocketDisconnectListener(event: SessionDisconnectEvent) {
        val username = getUsernameFromEvent(event)
        if (username != null) {
            val chatMessage = ChatMessage(
                type = MessageType.LEAVE,
                sender = username
            )
            messagingTemplate.convertAndSend("/topic/public", chatMessage)
            logger.info("User disconnected: $username")
        }
    }

    private fun getUsernameFromEvent(event: AbstractSubProtocolEvent): String? {
        return event.message.headers[SimpMessageHeaderAccessor.SESSION_ATTRIBUTES]
            ?.let { it as Map<*, *> }
            ?.get("username") as? String
    }
}

🔒 Security

WebSocket Security Configuration

@Configuration
class WebSocketSecurityConfig {

    @Bean
    fun webSocketMessageBrokerConfigurer(): WebSocketMessageBrokerConfigurer {
        return object : WebSocketMessageBrokerConfigurer {
            override fun configureClientInboundChannel(registration: ChannelRegistration) {
                registration.interceptors(authenticationInterceptor())
            }
        }
    }

    @Bean
    fun authenticationInterceptor(): ChannelInterceptor {
        return object : ChannelInterceptor {
            override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
                val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)
                
                if (StompCommand.CONNECT == accessor?.command) {
                    val token = accessor.getNativeHeader("Authorization")?.firstOrNull()
                    if (token != null && jwtTokenProvider.validateToken(token)) {
                        val principal = jwtTokenProvider.getPrincipal(token)
                        accessor.user = principal
                    } else {
                        throw IllegalArgumentException("Invalid token")
                    }
                }
                return message
            }
        }
    }
}

Origin Validation

@Component
class WebSocketOriginInterceptor : HandshakeInterceptor {

    private val allowedOrigins = setOf("https://myapp.com", "https://app.mycompany.com")

    override fun beforeHandshake(
        request: ServerHttpRequest,
        response: ServerHttpResponse,
        wsHandler: WebSocketHandler,
        attributes: MutableMap<String, Any>
    ): Boolean {
        val origin = request.headers.origin
        return allowedOrigins.contains(origin)
    }

    override fun afterHandshake(
        request: ServerHttpRequest,
        response: ServerHttpResponse,
        wsHandler: WebSocketHandler,
        exception: Exception?
    ) {
        // Логирование
    }
}

🧪 Testing

WebSocket Test

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebSocketIntegrationTest {

    @LocalServerPort
    private var port: Int = 0

    private lateinit var stompSession: StompSession

    @BeforeEach
    fun setup() {
        val sockJsClient = SockJsClient(listOf(WebSocketTransport(StandardWebSocketClient())))
        val stompClient = WebSocketStompClient(sockJsClient)
        stompClient.messageConverter = MappingJackson2MessageConverter()

        val url = "ws://localhost:$port/ws"
        stompSession = stompClient.connect(url, TestStompSessionHandler()).get()
    }

    @Test
    fun `should receive broadcast message`() {
        // Given
        val messageHandler = TestMessageHandler()
        stompSession.subscribe("/topic/public", messageHandler)

        // When
        val chatMessage = ChatMessage(
            type = MessageType.CHAT,
            content = "Hello World",
            sender = "testUser"
        )
        stompSession.send("/app/chat.sendMessage", chatMessage)

        // Then
        await().atMost(5, TimeUnit.SECONDS).until {
            messageHandler.receivedMessages.isNotEmpty()
        }
        
        val received = messageHandler.receivedMessages.first()
        assertThat(received.content).isEqualTo("Hello World")
        assertThat(received.sender).isEqualTo("testUser")
    }

    @AfterEach
    fun cleanup() {
        stompSession.disconnect()
    }
}

class TestStompSessionHandler : StompSessionHandlerAdapter() {
    override fun handleException(session: StompSession, command: StompCommand?, headers: StompHeaders, payload: ByteArray, exception: Throwable) {
        exception.printStackTrace()
    }
}

class TestMessageHandler : StompFrameHandler {
    val receivedMessages = mutableListOf<ChatMessage>()

    override fun getPayloadType(headers: StompHeaders): Type = ChatMessage::class.java

    override fun handleFrame(headers: StompHeaders, payload: Any?) {
        receivedMessages.add(payload as ChatMessage)
    }
}

🚀 Scaling & Production

Redis для масштабирования

@Configuration
class RedisWebSocketConfig {

    @Bean
    fun redisMessageBroker(): MessageBrokerRegistry.() -> Unit = {
        enableStompBrokerRelay("/topic", "/queue")
            .setRelayHost("redis-server")
            .setRelayPort(6379)
            .setClientLogin("app")
            .setClientPasscode("password")
    }

    @Bean
    fun redisTemplate(): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.connectionFactory = jedisConnectionFactory()
        template.setDefaultSerializer(GenericJackson2JsonRedisSerializer())
        return template
    }
}

Connection Management

@Component
class WebSocketConnectionManager {
    
    private val activeConnections = ConcurrentHashMap<String, WebSocketSession>()
    private val userSessions = ConcurrentHashMap<String, MutableSet<String>>()

    fun addConnection(userId: String, sessionId: String, session: WebSocketSession) {
        activeConnections[sessionId] = session
        userSessions.computeIfAbsent(userId) { ConcurrentHashMap.newKeySet() }.add(sessionId)
    }

    fun removeConnection(sessionId: String) {
        activeConnections.remove(sessionId)
        userSessions.values.forEach { sessions ->
            sessions.remove(sessionId)
        }
    }

    fun getActiveUsersCount(): Int = userSessions.size

    fun sendToUser(userId: String, message: String) {
        userSessions[userId]?.forEach { sessionId ->
            activeConnections[sessionId]?.let { session ->
                if (session.isOpen) {
                    session.sendMessage(TextMessage(message))
                }
            }
        }
    }

    @Scheduled(fixedRate = 30000) // Каждые 30 секунд
    fun cleanupClosedConnections() {
        val closedSessions = activeConnections.filterValues { !it.isOpen }.keys
        closedSessions.forEach { removeConnection(it) }
    }
}

💡 Практические советы

✅ Best Practices

Connection Management:

  • Реализуйте heartbeat (ping/pong) каждые 30-60 секунд
  • Graceful shutdown с уведомлением клиентов
  • Автоматический reconnect на клиенте
  • Ограничивайте количество соединений на пользователя

Performance:

  • Используйте message broker (Redis, RabbitMQ) для scaling
  • Batch отправка сообщений
  • Компрессия для больших сообщений
  • Connection pooling

Security:

  • Валидация Origin header
  • Authentication через JWT в handshake
  • Rate limiting на сообщения
  • Input validation и sanitization

❌ Anti-patterns

Архитектурные:

  • Хранение бизнес-логики в WebSocket handlers
  • Отсутствие error handling
  • Игнорирование connection cleanup
  • Синхронная обработка в message handlers

Производительность:

  • Блокирующие операции в message handlers
  • Отсутствие backpressure handling
  • Unlimited message queue
  • No connection limits

🔧 Мониторинг

Ключевые метрики:

  • Active WebSocket connections
  • Messages per second (in/out)
  • Connection duration
  • Error rate
  • Memory usage per connection

Инструменты:

  • WebSocket King — тестирование WebSocket соединений
  • Artillery — нагрузочное тестирование
  • wscat — CLI WebSocket клиент
  • Chrome DevTools — отладка WebSocket в браузере