🌐 REST и RESTful API

🎯 Основные принципы 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
class UserController(
    private val userService: UserService,
    private val rateLimitService: RateLimitService
) {

    @GetMapping
    fun getUsers(
        @RequestParam(defaultValue = "0") page: Int,
        @RequestParam(defaultValue = "20") size: Int,
        @RequestParam(required = false) search: String?
    ): ResponseEntity<Page<UserDto>> {
        val users = userService.findUsers(page, size, search)
        return ResponseEntity.ok(users)
    }

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

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

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

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

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

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

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException::class)
    fun handleNotFound(ex: EntityNotFoundException): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            code = "RESOURCE_NOT_FOUND",
            message = ex.message ?: "Resource not found",
            timestamp = Instant.now()
        )
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error)
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
        val errors = ex.bindingResult
            .fieldErrors
            .associate { it.field to (it.defaultMessage ?: "Invalid value") }
        
        val error = ErrorResponse(
            code = "VALIDATION_FAILED",
            message = "Validation failed",
            timestamp = Instant.now(),
            details = errors
        )
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error)
    }

    @ExceptionHandler(DataIntegrityViolationException::class)
    fun handleConflict(ex: DataIntegrityViolationException): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            code = "RESOURCE_CONFLICT",
            message = "Resource already exists",
            timestamp = Instant.now()
        )
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error)
    }
}

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

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

@Data
@AllArgsConstructor
class ApiResponse<T> {
    val success: Boolean
    val data: T? = null
    val message: String? = null
    val timestamp: Instant = Instant.now()
    
    companion object {
        fun <T> success(data: T): ApiResponse<T> {
            return ApiResponse(true, data, null)
        }
        
        fun <T> error(message: String): ApiResponse<T> {
            return ApiResponse(false, null, message)
        }
    }
}

Пагинация

@Data
class PageResponse<T> {
    var content: List<T> = emptyList()
    var page: Int = 0
    var size: Int = 0
    var totalElements: Long = 0
    var totalPages: Int = 0
    var hasNext: Boolean = false
    var hasPrevious: Boolean = false
    
    companion object {
        fun <T> of(page: Page<T>): PageResponse<T> {
            return PageResponse<T>().apply {
                content = page.content
                this.page = page.number
                size = page.size
                totalElements = page.totalElements
                totalPages = page.totalPages
                hasNext = page.hasNext()
                hasPrevious = page.hasPrevious()
            }
        }
    }
}

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

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

@Configuration
class SecurityHeadersConfig {
    
    @Bean
    fun securityHeadersFilter(): FilterRegistrationBean<SecurityHeadersFilter> {
        val registration = FilterRegistrationBean(SecurityHeadersFilter())
        registration.addUrlPatterns("/api/*")
        return registration
    }
}

class SecurityHeadersFilter : Filter {
    
    override fun doFilter(
        request: ServletRequest,
        response: ServletResponse,
        chain: FilterChain
    ) {
        val httpResponse = response as HttpServletResponse
        
        // CORS
        httpResponse.setHeader("Access-Control-Allow-Origin", "https://myapp.com")
        httpResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
        httpResponse.setHeader("Access-Control-Allow-Credentials", "true")
        
        // Security
        httpResponse.setHeader("X-Content-Type-Options", "nosniff")
        httpResponse.setHeader("X-Frame-Options", "DENY")
        httpResponse.setHeader("X-XSS-Protection", "1; mode=block")
        httpResponse.setHeader("Content-Security-Policy", "default-src 'self'")
        
        // Cache
        httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
        httpResponse.setHeader("Pragma", "no-cache")
        
        chain.doFilter(request, response)
    }
}

Conditional Requests (кэширование)

@Configuration
class ConditionalRequestConfig {

    @Bean
    fun webMvcConfigurer(): WebMvcConfigurer {
        return object : WebMvcConfigurer {
            override fun addInterceptors(registry: InterceptorRegistry) {
                registry.addInterceptor(ConditionalRequestInterceptor())
            }
        }
    }
}

class ConditionalRequestInterceptor : HandlerInterceptor {
    
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        val ifNoneMatch = request.getHeader("If-None-Match")
        val ifModifiedSince = request.getHeader("If-Modified-Since")
        
        if (ifNoneMatch != null || ifModifiedSince != null) {
            // Проверяем ETag или Last-Modified
            val currentETag = generateETag()
            
            if (ifNoneMatch == currentETag) {
                response.status = HttpStatus.NOT_MODIFIED.value()
                return false
            }
        }
        
        return true
    }
    
    private fun generateETag(): String = UUID.randomUUID().toString()
}

🚀 Асинхронные endpoints (WebFlux)

Async endpoints

@RestController
@RequestMapping("/api/v1/users")
class UserAsyncController(
    private val userService: UserService
) {

    @GetMapping
    suspend fun getUsersAsync(
        @RequestParam(defaultValue = "0") page: Int,
        @RequestParam(defaultValue = "20") size: Int
    ): Page<UserDto> {
        return userService.findUsersAsync(page, size)
    }

    @GetMapping("/{id}")
    fun getUserAsync(@PathVariable id: Long): CompletableFuture<UserDto> {
        return CompletableFuture.supplyAsync {
            userService.findById(id)
        }
    }

    @PostMapping
    fun createUserAsync(@Valid @RequestBody request: CreateUserRequest): Mono<UserDto> {
        return Mono.fromCallable {
            userService.create(request)
        }.subscribeOn(Schedulers.boundedElastic())
    }
}

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

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

@Component
@Slf4j
class RequestLoggingFilter : Filter {
    
    override fun doFilter(
        request: ServletRequest,
        response: ServletResponse,
        chain: FilterChain
    ) {
        val httpRequest = request as HttpServletRequest
        val httpResponse = response as HttpServletResponse
        
        val startTime = System.currentTimeMillis()
        
        try {
            chain.doFilter(request, response)
        } finally {
            val duration = System.currentTimeMillis() - startTime
            
            log.info("HTTP Request: {} {} - Status: {} - Duration: {}ms",
                httpRequest.method,
                httpRequest.requestURI,
                httpResponse.status,
                duration
            )
        }
    }
}

Метрики с Micrometer

@Component
class ApiMetrics(
    private val meterRegistry: MeterRegistry
) {
    
    private val requestCounter = Counter.builder("api_requests_total")
        .description("Total API requests")
        .register(meterRegistry)
    
    private val requestTimer = Timer.builder("api_request_duration")
        .description("API request duration")
        .register(meterRegistry)
    
    fun recordRequest(method: String, status: String, duration: Long) {
        requestCounter.increment(
            Tags.of(
                Tag.of("method", method),
                Tag.of("status", status)
            )
        )
        requestTimer.record(duration, TimeUnit.MILLISECONDS)
    }
}

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

@Configuration
class OpenApiConfig {
    
    @Bean
    fun customOpenAPI(): OpenAPI {
        return OpenAPI()
            .info(Info()
                .title("User Management API")
                .version("v1.0")
                .description("API для управления пользователями"))
            .addSecurityItem(SecurityRequirement().addList("bearerAuth"))
            .components(Components()
                .addSecuritySchemes("bearerAuth",
                    SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")))
    }
}

📱 Content Negotiation

@GetMapping(value = "/users/{id}", produces = [
    MediaType.APPLICATION_JSON_VALUE,
    MediaType.APPLICATION_XML_VALUE,
    "application/vnd.api+json"
])
fun getUser(@PathVariable id: Long): ResponseEntity<UserDto> {
    val user = userService.findById(id)
    return ResponseEntity.ok(user)
}

🔄 Версионирование API

URL версионирование

/api/v1/users
/api/v2/users

Header версионирование

Accept: application/vnd.myapi.v1+json
Accept: application/vnd.myapi.v2+json

Accept header версионирование

@GetMapping(value = "/users", headers = "Accept=application/vnd.myapi.v1+json")
fun getUsersV1(): ResponseEntity<List<UserDtoV1>> { }

@GetMapping(value = "/users", headers = "Accept=application/vnd.myapi.v2+json")
fun getUsersV2(): ResponseEntity<List<UserDtoV2>> { }

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

Integration тесты с MockMvc

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockBean
    private lateinit var userService: UserService

    @Test
    fun `should return users list`() {
        // Given
        val users = listOf(
            UserDto(1L, "John", "john@example.com"),
            UserDto(2L, "Jane", "jane@example.com")
        )
        given(userService.findUsers(0, 20, null)).willReturn(PageImpl(users))

        // When & Then
        mockMvc.perform(get("/api/v1/users"))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.content[0].name").value("John"))
            .andExpect(jsonPath("$.content[1].name").value("Jane"))
    }

    @Test
    fun `should return 404 when user not found`() {
        // Given
        given(userService.findById(999L))
            .willThrow(EntityNotFoundException("User not found"))

        // When & Then
        mockMvc.perform(get("/api/v1/users/999"))
            .andExpect(status().isNotFound())
    }
}

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

✅ Best Practices

  • Версионирование API: используйте /api/v1/ в URL
  • Rate Limiting: ограничивайте количество запросов от клиента
  • Документация: используйте OpenAPI/Swagger для автогенерации
  • Валидация: всегда валидируйте входные данные
  • Логирование: логируйте все запросы и ошибки
  • Мониторинг: отслеживайте метрики производительности
  • Caching: используйте HTTP кэширование для GET запросов
  • Compression: сжимайте большие ответы (gzip)

❌ Anti-patterns

  • Использование GET для изменения данных
  • Смешивание версий API в одном endpoint'е
  • Отсутствие error handling'а
  • Неправильные HTTP статус коды
  • Огромные JSON ответы без пагинации
  • Отсутствие security заголовков
  • Синхронные blocking операции

Сравнение: REST vs GraphQL vs gRPC

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

🚀 gRPC

🎯 Основные преимущества 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;
  string name = 2;
  string email = 3;
  bool is_active = 4;
  double balance = 5;
  bytes avatar = 6;
  
  Status status = 7;
  Address address = 8;
  
  repeated string tags = 9;
  repeated Order orders = 10;
  
  map<string, string> metadata = 11;
  
  optional string middle_name = 12;
  
  google.protobuf.Timestamp created_at = 13;
  google.protobuf.Timestamp updated_at = 14;
}

enum Status {
  UNKNOWN = 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;
}

🛠 Настройка в 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 {
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("net.devh:grpc-spring-boot-starter:2.15.0.RELEASE")
    
    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")
    
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    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
    max-inbound-message-size: 4194304  # 4MB
  client:
    user-service:
      address: 'static://localhost:9091'
      negotiation-type: plaintext
      max-inbound-message-size: 4194304

spring:
  application:
    name: grpc-service

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

🔧 Server Streaming реализация

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

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

🔧 Client Streaming реализация

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: ValidationException) {
                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: ${t.message}")
                    .asException()
            )
        }

        override fun onCompleted() {
            try {
                val response = CreateUsersResponse.newBuilder()
                    .addAllUsers(createdUsers.map { it.toProto() })
                    .setTotalCreated(createdUsers.size)
                    .build()
                
                responseObserver.onNext(response)
                responseObserver.onCompleted()
            } catch (e: Exception) {
                responseObserver.onError(
                    Status.INTERNAL.withCause(e).asException()
                )
            }
        }
    }
}

🔧 Bidirectional Streaming реализация

override fun chat(
    responseObserver: StreamObserver<ChatMessage>
): StreamObserver<ChatMessage> {
    return object : StreamObserver<ChatMessage> {
        private val messageQueue = mutableListOf<ChatMessage>()
        
        override fun onNext(message: ChatMessage) {
            try {
                // Обработка входящего сообщения
                val processedMessage = chatService.process(message)
                
                // Отправляем ответ
                responseObserver.onNext(processedMessage)
                messageQueue.add(message)
            } catch (e: Exception) {
                responseObserver.onError(
                    Status.INTERNAL.withCause(e).asException()
                )
            }
        }

        override fun onError(t: Throwable) {
            log.error("Chat stream error", t)
        }

        override fun onCompleted() {
            log.info("Chat stream completed, processed ${messageQueue.size} messages")
            responseObserver.onCompleted()
        }
    }
}

🔒 Error Handling с Status кодами

override fun getUser(
    request: GetUserRequest,
    responseObserver: StreamObserver<UserResponse>
) {
    try {
        if (request.userId <= 0) {
            responseObserver.onError(
                Status.INVALID_ARGUMENT
                    .withDescription("User ID must be positive")
                    .asException()
            )
            return
        }

        val user = userService.findById(request.userId)
            ?: return responseObserver.onError(
                Status.NOT_FOUND
                    .withDescription("User with ID ${request.userId} not found")
                    .asException()
            )

        val response = UserResponse.newBuilder()
            .setUser(user.toProto())
            .build()
        
        responseObserver.onNext(response)
        responseObserver.onCompleted()
    } catch (e: AuthenticationException) {
        responseObserver.onError(
            Status.UNAUTHENTICATED
                .withDescription("Authentication failed: ${e.message}")
                .asException()
        )
    } catch (e: AuthorizationException) {
        responseObserver.onError(
            Status.PERMISSION_DENIED
                .withDescription("Permission denied: ${e.message}")
                .asException()
        )
    } catch (e: TimeoutException) {
        responseObserver.onError(
            Status.DEADLINE_EXCEEDED
                .withDescription("Request timeout")
                .asException()
        )
    } catch (e: Exception) {
        responseObserver.onError(
            Status.INTERNAL
                .withDescription("Internal server error")
                .withCause(e)
                .asException()
        )
    }
}

🧵 Server Interceptors

Логирование

@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")
                super.onMessage(message)
            }
        }
    }
}

Authentication

@Component
class AuthServerInterceptor(
    private val jwtTokenProvider: JwtTokenProvider
) : ServerInterceptor {

    override fun <ReqT, RespT> interceptCall(
        call: ServerCall<ReqT, RespT>,
        headers: Metadata,
        next: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        
        val authHeader = headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER))
        
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            val token = authHeader.substring(7)
            if (!jwtTokenProvider.validateToken(token)) {
                val ex = Status.UNAUTHENTICATED.withDescription("Invalid token").asException()
                call.close(Status.UNAUTHENTICATED, Metadata())
            }
        }
        
        return next.startCall(call, headers)
    }
}

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

@Component
class GrpcMetrics(private val meterRegistry: MeterRegistry) {
    
    private val requestsCounter = Counter.builder("grpc_requests_total")
        .description("Total gRPC requests")
        .register(meterRegistry)
    
    private val requestsTimer = Timer.builder("grpc_requests_duration_seconds")
        .description("gRPC request duration")
        .register(meterRegistry)
    
    fun recordRequest(method: String, status: String, duration: Duration) {
        requestsCounter.increment(
            Tags.of(
                Tag.of("method", method),
                Tag.of("status", status)
            )
        )
        
        requestsTimer.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)
    }
}

🔧 Client Implementation

Async клиент

@Service
class UserGrpcAsyncClient(
    @GrpcClient("user-service") 
    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() {}
            })

        return future
    }

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

        return Flux.create { sink ->
            userServiceStub
                .withDeadlineAfter(30, TimeUnit.SECONDS)
                .getUsers(request, object : StreamObserver<UserResponse> {
                    override fun onNext(response: UserResponse) {
                        sink.next(response)
                    }

                    override fun onError(t: Throwable) {
                        sink.error(t)
                    }

                    override fun onCompleted() {
                        sink.complete()
                    }
                })
        }
    }

    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
    }
}

🔐 Load Balancing и Health Checks

@Configuration
class GrpcLoadBalancingConfig {

    @Bean
    fun userServiceStub(channel: Channel): UserServiceGrpc.UserServiceStub {
        return UserServiceGrpc.newStub(
            channel.withCompression("gzip")
        )
    }
}

@GrpcService
class HealthCheckService : HealthGrpc.HealthImplBase() {

    override fun check(
        request: HealthCheckRequest,
        responseObserver: StreamObserver<HealthCheckResponse>
    ) {
        val status = HealthCheckResponse.ServingStatus.SERVING
        
        responseObserver.onNext(
            HealthCheckResponse.newBuilder()
                .setStatus(status)
                .build()
        )
        responseObserver.onCompleted()
    }

    override fun watch(
        request: HealthCheckRequest,
        responseObserver: StreamObserver<HealthCheckResponse>
    ) {
        val status = HealthCheckResponse.ServingStatus.SERVING
        
        responseObserver.onNext(
            HealthCheckResponse.newBuilder()
                .setStatus(status)
                .build()
        )
        // Поток для health checks остаётся открытым
    }
}

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

Unit тесты

@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)
    }

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

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

        assertThat(response.user.id).isEqualTo(userId)
    }

    @Test
    fun `should throw NOT_FOUND when user not exists`() {
        given(userService.findById(999L))
            .willThrow(EntityNotFoundException("User not found"))

        val request = GetUserRequest.newBuilder().setUserId(999L).build()
        
        val exception = assertThrows<StatusRuntimeException> {
            stub.getUser(request)
        }
        
        assertThat(exception.status.code).isEqualTo(Status.Code.NOT_FOUND)
    }

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

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

grpcurl тестирование

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

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

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

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

✅ Do's

  • Используйте streaming для больших объемов данных
  • Всегда устанавливайте deadline/timeout
  • Реализуйте retry policy с exponential backoff
  • Логируйте все gRPC ошибки
  • Мониторьте latency и error rate
  • Используйте TLS в продакшене

❌ Don'ts

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

Сравнение: gRPC vs REST vs SOAP

Критерий gRPC REST SOAP
Формат Binary (Protobuf) JSON/XML XML
Размер Маленький Средний Большой
Производительность Высокая (7-10x) Средняя Низкая
Streaming ❌ (требует WebSocket)
Типизация Строгая (Proto) Слабая Строгая (XSD)
Кэширование ✅ (HTTP) Сложное

🧼 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

Жизненный цикл 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 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

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

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Header>
        <!-- Метаданные, аутентификация -->
        <wsse:Security xmlns:wsse="...">
            <wsse:UsernameToken>
                <wsse:Username>user</wsse:Username>
                <wsse:Password>pass</wsse:Password>
            </wsse:UsernameToken>
        </wsse:Security>
    </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>
        <errorMessage>User with ID 123 not found</errorMessage>
    </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")
implementation("com.sun.xml.wss:xws-security:4.0")  // Для WS-Security

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:element name="createdAt" type="xs:dateTime"/>
        </xs:sequence>
    </xs:complexType>
</xs:schema>

🔧 SOAP Endpoint Implementation

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

    @PayloadRoot(
        namespace = "http://example.com/userservice",
        localPart = "getUserRequest"
    )
    @ResponsePayload
    fun getUser(@RequestPayload request: GetUserRequest): GetUserResponse {
        try {
            if (request.userId <= 0) {
                throw UserValidationException("User ID must be positive")
            }

            val user = userService.findById(request.userId)
                ?: throw UserNotFoundException("User not found: ${request.userId}")

            val response = GetUserResponse()
            response.user = user.toSoapUser()
            return response
        } catch (e: UserNotFoundException) {
            throw SoapFaultException(
                SoapFaultDetail(
                    faultCode = "USER_NOT_FOUND",
                    faultString = e.message ?: "User not found"
                )
            )
        }
    }

    @PayloadRoot(
        namespace = "http://example.com/userservice",
        localPart = "createUserRequest"
    )
    @ResponsePayload
    fun createUser(@RequestPayload request: CreateUserRequest): CreateUserResponse {
        try {
            val user = userService.create(request.toDomain())
            val response = CreateUserResponse()
            response.user = user.toSoapUser()
            return response
        } catch (e: ValidationException) {
            throw SoapFaultException(
                SoapFaultDetail(
                    faultCode = "VALIDATION_ERROR",
                    faultString = e.message ?: "Validation failed"
                )
            )
        }
    }
}

🔒 WS-Security Implementation

Security Header Handler

@Configuration
class SecurityHeaderHandler {

    @Bean
    fun securityHeaderHandler(): SoapHeaderHandler {
        return object : SoapHeaderHandler {
            override fun handleHeader(
                source: Source,
                soapHeader: SoapHeader
            ) {
                val securityElement = soapHeader.extractSecurityElement()
                val usernameToken = securityElement?.extractUsernameToken()
                
                if (usernameToken != null) {
                    val username = usernameToken.username
                    val password = usernameToken.password
                    
                    if (!validateCredentials(username, password)) {
                        throw SOAPFaultException("Authentication failed")
                    }
                }
            }

            private fun validateCredentials(username: String, password: String): Boolean {
                // Реализация валидации
                return true
            }
        }
    }
}

WS-ReliableMessaging

@Configuration
class ReliableMessagingConfig {

    @Bean
    fun reliableMessagingInterceptor(): ClientInterceptor {
        return ReliableMessagingClientInterceptor().apply {
            setDeliveryAssurance(DeliveryAssurance.AT_LEAST_ONCE)
            setRetryCount(3)
            setRetryDelay(1000)
        }
    }
}

📞 SOAP Client Implementation

WebServiceTemplate 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")
        marshaller.setMarshallerProperties(mapOf(
            "com.sun.xml.bind.indentString" to "  "
        ))
        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,
                object : WebServiceMessageCallback {
                    override fun doWithMessage(message: WebServiceMessage) {
                        val soapMessage = message as SoapMessage
                        soapMessage.soapHeader.addHeaderElement(
                            QName("http://example.com", "CorrelationId")
                        ).textContent = UUID.randomUUID().toString()
                    }
                }
            ) as GetUserResponse
            
            response.user.toDomainUser()
        } catch (e: SoapFaultException) {
            when {
                e.faultCode.contains("USER_NOT_FOUND") -> null
                else -> throw ServiceException("SOAP call failed: ${e.faultString}")
            }
        }
    }

    fun getUsersWithRetry(maxRetries: Int = 3): List<User> {
        var lastException: Exception? = null
        
        repeat(maxRetries) {
            try {
                val request = GetUsersRequest()
                val response = webServiceTemplate.marshalSendAndReceive(request) as GetUsersResponse
                return response.users.map { it.toDomainUser() }
            } catch (e: Exception) {
                lastException = e
                Thread.sleep(1000 * (it + 1).toLong())  // Exponential backoff
            }
        }
        
        throw lastException ?: ServiceException("Failed to get users")
    }
}

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

@Component
class SoapMetrics(private val meterRegistry: MeterRegistry) {

    private val soapRequestsCounter = Counter.builder("soap_requests_total")
        .description("Total SOAP requests")
        .register(meterRegistry)

    private val soapRequestsTimer = Timer.builder("soap_requests_duration_seconds")
        .description("SOAP request duration")
        .register(meterRegistry)

    fun recordRequest(method: String, status: String, duration: Long) {
        soapRequestsCounter.increment(
            Tags.of(
                Tag.of("method", method),
                Tag.of("status", status)
            )
        )
        soapRequestsTimer.record(duration, TimeUnit.MILLISECONDS)
    }
}

@Component
class SoapLoggingInterceptor(
    private val soapMetrics: SoapMetrics
) : EndpointInterceptor {

    override fun handleRequest(messageContext: MessageContext): Boolean {
        messageContext.getProperty("startTime")?.let {
            messageContext.setProperty("startTime", System.currentTimeMillis())
        }
        return true
    }

    override fun handleResponse(messageContext: MessageContext): Boolean {
        val startTime = (messageContext.getProperty("startTime") as? Long) ?: 0
        val duration = System.currentTimeMillis() - startTime

        val method = messageContext.request.soapAction ?: "unknown"
        soapMetrics.recordRequest(method, "success", duration)

        return true
    }

    override fun handleFault(messageContext: MessageContext): Boolean {
        val startTime = (messageContext.getProperty("startTime") as? Long) ?: 0
        val duration = System.currentTimeMillis() - startTime

        val method = messageContext.request.soapAction ?: "unknown"
        soapMetrics.recordRequest(method, "fault", duration)

        return true
    }

    override fun afterCompletion(messageContext: MessageContext, ex: Exception?) {}
}

🧪 Testing

Server Testing

@WebServiceServerTest
class UserEndpointTest {

    @Autowired
    private lateinit var mockWebServiceClient: MockWebServiceClient

    @MockBean
    private lateinit var userService: UserService

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

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

        // When & Then
        mockWebServiceClient
            .sendRequest(RequestCreators.withPayload(StringSource(request)))
            .andExpect(ResponseMatchers.noFault())
            .andExpect(ResponseMatchers.xpath("//ns:user/ns:name", 
                mapOf("ns" to "http://example.com/userservice"))
                .evaluatesTo("John"))
    }

    @Test
    fun `should return fault when user not found`() {
        // Given
        given(userService.findById(999L))
            .willThrow(UserNotFoundException("User not found"))

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

        // When & Then
        mockWebServiceClient
            .sendRequest(RequestCreators.withPayload(StringSource(request)))
            .andExpect(ResponseMatchers.fault())
            .andExpect(ResponseMatchers.faultCode("soap:Client"))
    }
}

### Client Testing
@Test
fun `client should handle soap fault`() {
    val client = UserSoapClient(webServiceTemplate)
    
    assertThrows<ServiceException> {
        client.getUser(999L)
    }
}

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

curl для тестирования SOAP

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 vs REST vs gRPC

Критерий SOAP REST gRPC
Интеграция Старые системы Современные API Микросервисы
Безопасность WS-Security OAuth2 mTLS
Производительность Низкая Средняя Высокая
Простота Сложная Простая Средняя
Стандартизация Высокая (WS-*) Низкая Средняя

🔮 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

Проблемы 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. Error Handling

  • GraphQL возвращает 200 даже с ошибками в body Решение: Правильная структура ошибок

4. Security

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

🛠 Spring Boot Setup

build.gradle (Kotlin DSL)

dependencies {
    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")
}

application.yml

spring:
  graphql:
    graphiql:
      enabled: true
      path: /graphiql
    schema:
      introspection:
        enabled: true  # Отключить в продакшене
  webflux:
    base-path: /graphql

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

📄 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 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!
    updatePost(id: ID!, input: PostInput!): Post!
}

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

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

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

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

🔧 Error Handling

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()
                
                is ValidationException -> GraphqlErrorBuilder.newError()
                    .message(ex.message)
                    .errorType(ErrorType.BAD_REQUEST)
                    .extension("validationErrors", ex.errors)
                    .build()
                
                is AuthorizationException -> GraphqlErrorBuilder.newError()
                    .message("Permission denied")
                    .errorType(ErrorType.FORBIDDEN)
                    .build()
                
                else -> GraphqlErrorBuilder.newError()
                    .message("Internal server error")
                    .errorType(ErrorType.INTERNAL_ERROR)
                    .build()
            }
        }
    }

    @Bean
    fun queryComplexityInstrumentation(): Instrumentation {
        return object : SimpleInstrumentation() {
            override fun instrumentExecutionResult(
                executionResult: ExecutionResult,
                parameters: InstrumentationContext<ExecutionResult>
            ): ExecutionResult {
                if (executionResult.errors.isNotEmpty()) {
                    executionResult.errors.forEach { error ->
                        log.error("GraphQL Error: ${error.message}", error)
                    }
                }
                return executionResult
            }
        }
    }
}

🔧 Query Resolvers

@Controller
class UserQueryController(
    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.findAllPaginated(first ?: 10, after)
    }

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

🔧 Mutation Resolvers

@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
    }
}

🔧 Field-Level Authorization

@Component
class FieldAuthorizationInterceptor(
    private val authContext: AuthContext
) : ExecutionResultInstrumentation() {
    
    override fun instrumentExecutionResult(
        executionResult: ExecutionResult,
        parameters: InstrumentationExecutionResultParameters
    ): ExecutionResult {
        return executionResult
    }
}

@Controller
class ProtectedFieldController(
    private val userService: UserService,
    private val authContext: AuthContext
) {

    @SchemaMapping(typeName = "User", field = "email")
    fun getEmail(user: User): String? {
        // Только собственные данные или администратор
        return if (authContext.userId == user.id || authContext.isAdmin) {
            user.email
        } else {
            null
        }
    }
}

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

@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 }
            }
        }
    }

    @Bean
    fun dataLoaderRegistry(
        userDataLoader: DataLoader<Long, User>,
        postDataLoader: DataLoader<Long, List<Post>>
    ): DataLoaderRegistry {
        return DataLoaderRegistry.newRegistry()
            .register("userDataLoader", userDataLoader)
            .register("postDataLoader", postDataLoader)
            .build()
    }
}

📝 Query Validation и Directives

directive @auth(requires: String!) on FIELD_DEFINITION
directive @deprecated(reason: String!) on FIELD_DEFINITION
directive @rateLimit(limit: Int!) on FIELD_DEFINITION

type User {
    id: ID!
    name: String!
    email: String! @auth(requires: "READ_EMAIL")
    password: String! @deprecated(reason: "Use passwordHash instead")
}

type Query {
    sensitiveData: String @auth(requires: "ADMIN") @rateLimit(limit: 10)
}

Directive Handler

@Component
class AuthDirectiveHandler(
    private val authContext: AuthContext
) : SchemaDirectiveWiring {
    
    override fun onField(
        environment: SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition>
    ): GraphQLFieldDefinition {
        val requiredRole = environment.directive.getArgument("requires").value as String
        
        val originalDataFetcher = environment.getFieldDataFetcher()
        
        val wrappedDataFetcher = DataFetcher { dataFetchingEnvironment ->
            if (!authContext.hasRole(requiredRole)) {
                throw AuthorizationException("Permission denied")
            }
            originalDataFetcher.get(dataFetchingEnvironment)
        }
        
        environment.setFieldDataFetcher(wrappedDataFetcher)
        return environment.element
    }
}

🔄 Subscription Implementation

@Component
class PostEventPublisher {
    companion object {
        private val postAddedPublisher = BehaviorProcessor.create<Post>()
        
        fun publishPostAdded(post: Post) {
            postAddedPublisher.onNext(post)
        }
        
        fun getPostStream(): Flux<Post> {
            return postAddedPublisher.asFlux()
        }
    }
}

@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 }
    }
}

🧪 Testing

@SpringBootTest
class UserGraphQLTest {

    @Autowired
    private lateinit var graphQlTester: GraphQlTester

    @MockBean
    private lateinit var userService: UserService

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

        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")
    }

    @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 }
    }
}

📊 Мониторинг и Observability

@Component
class GraphQLMetrics(private val meterRegistry: MeterRegistry) {

    private val queryCounter = Counter.builder("graphql_queries_total")
        .description("Total GraphQL queries")
        .register(meterRegistry)

    private val queryTimer = Timer.builder("graphql_query_duration")
        .description("GraphQL query duration")
        .register(meterRegistry)

    fun recordQuery(operationName: String, duration: Long) {
        queryCounter.increment(Tag.of("operation", operationName))
        queryTimer.record(duration, TimeUnit.MILLISECONDS)
    }
}

@Component
class GraphQLInstrumentation(
    private val graphQLMetrics: GraphQLMetrics
) : SimpleInstrumentation() {

    override fun instrumentExecutionResult(
        executionResult: ExecutionResult,
        parameters: InstrumentationExecutionResultParameters
    ): ExecutionResult {
        val document = parameters.query
        val operationName = parameters.operation?.name?.value ?: "anonymous"
        val duration = System.currentTimeMillis() - parameters.startTime
        
        graphQLMetrics.recordQuery(operationName, duration)
        return executionResult
    }
}

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

✅ Best Practices

Schema Design:

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

Performance:

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

Security:

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

❌ Anti-patterns

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

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

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

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

Сравнение: GraphQL vs REST vs gRPC

Критерий GraphQL REST gRPC
Запросы Один endpoint Множество endpoints Предопределённые методы
Over/Under fetching Нет Часто Нет
Кэширование Сложное HTTP кэширование Нет
Real-time Subscriptions WebSocket/SSE Streaming
Типизация Строгая Слабая Строгая
Простота Средняя Простая Средняя

🔌 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

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

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

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)

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework:spring-messaging")
    
    // Для Redis (масштабирование)
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    implementation("org.springframework.session:spring-session-data-redis")
}

STOMP Configuration

@Configuration
@EnableWebSocketMessageBroker
class StompConfig : WebSocketMessageBrokerConfigurer {

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

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

🔧 Message Handler (Raw WebSocket)

@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")
    @SendTo("/topic/public")
    fun sendMessage(chatMessage: ChatMessage): ChatMessage {
        return chatService.saveMessage(chatMessage)
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    fun addUser(
        chatMessage: ChatMessage,
        headerAccessor: SimpMessageHeaderAccessor
    ): ChatMessage {
        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?
    ) {}
}

🔄 Connection Management & Reconnection

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

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

    fun removeConnection(sessionId: String) {
        activeConnections.remove(sessionId)
        sessionMetadata.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) {
                    try {
                        session.sendMessage(TextMessage(message))
                    } catch (e: IOException) {
                        removeConnection(sessionId)
                    }
                }
            }
        }
    }

    // Heartbeat для проверки живости соединения
    @Scheduled(fixedRate = 30000)  // Каждые 30 секунд
    fun sendHeartbeat() {
        activeConnections.forEach { (sessionId, session) ->
            if (session.isOpen) {
                try {
                    session.sendMessage(TextMessage("""{"type": "ping"}"""))
                } catch (e: IOException) {
                    removeConnection(sessionId)
                }
            }
        }
    }

    // Очистка закрытых соединений
    @Scheduled(fixedRate = 60000)
    fun cleanupClosedConnections() {
        val closedSessions = activeConnections.filterValues { !it.isOpen }.keys
        closedSessions.forEach { removeConnection(it) }
    }
}

data class SessionMetadata(
    val userId: String,
    val connectedAt: Instant,
    var lastActivityAt: Instant = Instant.now()
)

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

@Configuration
class RedisWebSocketConfig {

    @Bean
    fun messageBrokerConfigurer(): WebSocketMessageBrokerConfigurer {
        return object : WebSocketMessageBrokerConfigurer {
            override fun configureMessageBroker(config: MessageBrokerRegistry) {
                config.enableStompBrokerRelay("/topic", "/queue")
                    .setRelayHost("redis-server")
                    .setRelayPort(6379)
                    .setClientLogin("app")
                    .setClientPasscode("password")
            }

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

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

🧪 Testing

@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`() {
        val messageHandler = TestMessageHandler()
        stompSession.subscribe("/topic/public", messageHandler)

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

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

    @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)
    }
}

📊 Мониторинг

@Component
class WebSocketMetrics(private val meterRegistry: MeterRegistry) {

    private val activeConnectionsGauge = AtomicInteger(0)
    private val messagesCounter = Counter.builder("websocket_messages_total")
        .description("Total WebSocket messages")
        .register(meterRegistry)
    
    private val messageLatencyTimer = Timer.builder("websocket_message_latency")
        .description("WebSocket message latency")
        .register(meterRegistry)

    init {
        Gauge.builder("websocket_active_connections") { activeConnectionsGauge.get() }
            .description("Active WebSocket connections")
            .register(meterRegistry)
    }

    fun recordConnection() = activeConnectionsGauge.incrementAndGet()
    fun recordDisconnection() = activeConnectionsGauge.decrementAndGet()
    
    fun recordMessage(direction: String, latency: Long) {
        messagesCounter.increment(Tag.of("direction", direction))
        messageLatencyTimer.record(latency, TimeUnit.MILLISECONDS)
    }
}

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

✅ 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

Сравнение: WebSocket vs HTTP vs gRPC

Критерий WebSocket HTTP gRPC
Real-time ✅ Full-duplex ❌ Polling ✅ Streaming
Latency Низкий Средний Низкий
Масштабирование Сложное Простое Среднее
Браузер ✅ Встроено ✅ Встроено ❌ Требует JS
Firewall ❌ Проблемы ✅ Стандартно ✅ HTTP/2
Кэширование

📡 JSON-RPC


🎯 Основы JSON-RPC

JSON-RPC — лёгкий протокол удалённого вызова процедур (RPC). Использует JSON для кодирования данных и работает поверх HTTP, WebSocket и других транспортных протоколов.

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

Компонент Описание Назначение
Request Запрос с методом и параметрами Вызов удалённой процедуры
Response Ответ с результатом или ошибкой Результат выполнения
Notification Запрос без ожидания ответа Fire-and-forget вызовы
Error Структурированная ошибка Standardized error responses
Batch Множество запросов в одном Оптимизация для нескольких вызовов
ID Идентификатор запроса Matching responses to requests

📚 Теория JSON-RPC

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

Client                          Server
  |
  |-- Request (HTTP POST) ------>
  |                               |
  |                               | Processing
  |                               |
  |<----- Response (Result) ------|
  |

JSON-RPC vs REST vs gRPC vs GraphQL

Критерий JSON-RPC REST gRPC GraphQL
Формат JSON JSON/XML Binary (Protobuf) JSON
Транспорт HTTP, WebSocket HTTP HTTP/2 HTTP
Streaming ✅ (Subscriptions)
Batch вызовы
Типизация Слабая Слабая Строгая Строгая
Простота Простая Простая Средняя Средняя
Использование Legacy, Legacy systems Веб API Микросервисы GraphQL APIs
Error handling Встроенное HTTP status codes Status codes GraphQL errors

JSON-RPC версии

JSON-RPC 1.0 (deprecated)

  • Проблемы с обработкой ошибок
  • Не стандартизировано

JSON-RPC 2.0 (recommended)

  • Улучшенная спецификация ошибок
  • Batch requests
  • Notifications
  • Backward compatible

📄 Структура сообщений

Request структура

{
  "jsonrpc": "2.0",
  "method": "user.getById",
  "params": {
    "id": 123
  },
  "id": 1
}

Response структура (успех)

{
  "jsonrpc": "2.0",
  "result": {
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"
  },
  "id": 1
}

Response структура (ошибка)

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": {
      "details": "Parameter 'id' must be a positive number"
    }
  },
  "id": 1
}

Notification (без ответа)

{
  "jsonrpc": "2.0",
  "method": "user.notifyStatusChange",
  "params": {
    "userId": 123,
    "newStatus": "online"
  }
}

Batch Request

[
  {
    "jsonrpc": "2.0",
    "method": "user.getById",
    "params": {"id": 1},
    "id": 1
  },
  {
    "jsonrpc": "2.0",
    "method": "user.getById",
    "params": {"id": 2},
    "id": 2
  },
  {
    "jsonrpc": "2.0",
    "method": "user.create",
    "params": {"name": "Jane", "email": "jane@example.com"},
    "id": 3
  }
]

Batch Response

[
  {
    "jsonrpc": "2.0",
    "result": {"id": 1, "name": "John"},
    "id": 1
  },
  {
    "jsonrpc": "2.0",
    "result": {"id": 2, "name": "Jane"},
    "id": 2
  },
  {
    "jsonrpc": "2.0",
    "error": {
      "code": -32602,
      "message": "Invalid params"
    },
    "id": 3
  }
]

Стандартные ошибки

Код Сообщение Описание
-32700 Parse error Ошибка парсинга JSON
-32600 Invalid Request Невалидный запрос
-32601 Method not found Метод не существует
-32602 Invalid params Некорректные параметры
-32603 Internal error Внутренняя ошибка сервера
-32000 to -32099 Server error Зарезервировано для серверных ошибок

🛠 Spring Boot Setup

build.gradle (Kotlin DSL)

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.core:jackson-databind:2.16.0")
    implementation("com.github.briandilley.jsonrpc4j:jsonrpc4j:1.6.1")  // опционально
}

application.yml

spring:
  application:
    name: json-rpc-service
  web:
    base-path: /api

server:
  servlet:
    context-path: /

jsonrpc:
  endpoint: /rpc
  introspection: true  # Отключить в продакшене

🔧 Server Implementation (без библиотек)

Data Classes

data class JsonRpcRequest(
    val jsonrpc: String = "2.0",
    val method: String,
    val params: Map<String, Any>? = null,
    val id: Any? = null  // Null для notifications
) {
    fun isNotification(): Boolean = id == null
    fun isBatch(): Boolean = false
}

data class JsonRpcResponse(
    val jsonrpc: String = "2.0",
    val result: Any? = null,
    val error: JsonRpcError? = null,
    val id: Any? = null
)

data class JsonRpcError(
    val code: Int,
    val message: String,
    val data: Map<String, Any>? = null
)

data class BatchRequest(
    val requests: List<JsonRpcRequest>
)

data class BatchResponse(
    val responses: List<JsonRpcResponse>
)

RPC Service

@Component
class UserRpcService(
    private val userService: UserService
) {

    private val logger = LoggerFactory.getLogger(UserRpcService::class.java)

    fun invokeMethod(method: String, params: Map<String, Any>?): Any {
        return when (method) {
            "user.getById" -> getUser(params)
            "user.getAll" -> getAllUsers()
            "user.create" -> createUser(params)
            "user.update" -> updateUser(params)
            "user.delete" -> deleteUser(params)
            else -> throw MethodNotFoundException("Method '$method' not found")
        }
    }

    private fun getUser(params: Map<String, Any>?): UserDto {
        val id = (params?.get("id") as? Number)?.toLong()
            ?: throw InvalidParamsException("Parameter 'id' is required and must be a number")
        
        return userService.findById(id)?.toDto()
            ?: throw RpcException(-32001, "User not found")
    }

    private fun getAllUsers(): List<UserDto> {
        return userService.findAll().map { it.toDto() }
    }

    private fun createUser(params: Map<String, Any>?): UserDto {
        val name = params?.get("name") as? String
            ?: throw InvalidParamsException("Parameter 'name' is required")
        val email = params["email"] as? String
            ?: throw InvalidParamsException("Parameter 'email' is required")
        
        if (!email.contains("@")) {
            throw InvalidParamsException("Invalid email format")
        }
        
        return userService.create(name, email).toDto()
    }

    private fun updateUser(params: Map<String, Any>?): UserDto {
        val id = (params?.get("id") as? Number)?.toLong()
            ?: throw InvalidParamsException("Parameter 'id' is required")
        val name = params?.get("name") as? String
        val email = params?.get("email") as? String
        
        if (name == null && email == null) {
            throw InvalidParamsException("At least one field must be provided")
        }
        
        return userService.update(id, name, email).toDto()
    }

    private fun deleteUser(params: Map<String, Any>?): Boolean {
        val id = (params?.get("id") as? Number)?.toLong()
            ?: throw InvalidParamsException("Parameter 'id' is required")
        
        userService.delete(id)
        return true
    }
}

sealed class RpcException(
    val code: Int,
    message: String
) : Exception(message)

class InvalidParamsException(message: String) : 
    RpcException(-32602, message)

class MethodNotFoundException(message: String) : 
    RpcException(-32601, message)

JSON-RPC Controller

@RestController
@RequestMapping("/api/rpc")
class JsonRpcController(
    private val userRpcService: UserRpcService,
    private val objectMapper: ObjectMapper
) {

    private val logger = LoggerFactory.getLogger(JsonRpcController::class.java)

    @PostMapping
    fun handleRequest(@RequestBody body: String): ResponseEntity<String> {
        return try {
            val request = objectMapper.readValue(body, JsonRpcRequest::class.java)
            val response = processSingleRequest(request)
            
            val jsonResponse = objectMapper.writeValueAsString(response)
            ResponseEntity.ok(jsonResponse)
        } catch (e: JsonMappingException) {
            val error = createErrorResponse(-32700, "Parse error", null)
            ResponseEntity.ok(objectMapper.writeValueAsString(error))
        }
    }

    @PostMapping("/batch")
    fun handleBatchRequest(@RequestBody body: String): ResponseEntity<String> {
        return try {
            val requests = objectMapper.readValue(body, Array<JsonRpcRequest>::class.java)
            val responses = requests
                .map { processSingleRequest(it) }
                .filter { !it.id.toString().isEmpty() }  // Exclude notifications
            
            val jsonResponses = objectMapper.writeValueAsString(responses)
            ResponseEntity.ok(jsonResponses)
        } catch (e: JsonMappingException) {
            val error = createErrorResponse(-32700, "Parse error", null)
            ResponseEntity.ok(objectMapper.writeValueAsString(error))
        }
    }

    private fun processSingleRequest(request: JsonRpcRequest): JsonRpcResponse {
        logger.info("Processing RPC method: ${request.method}")
        
        return try {
            if (!request.jsonrpc.equals("2.0", ignoreCase = true)) {
                return createErrorResponse(
                    -32600,
                    "Invalid Request",
                    request.id
                )
            }

            val result = userRpcService.invokeMethod(request.method, request.params)
            
            // Для notifications не отправляем ответ
            if (request.isNotification()) {
                return JsonRpcResponse(id = null)
            }
            
            JsonRpcResponse(
                result = result,
                id = request.id
            )
        } catch (e: InvalidParamsException) {
            createErrorResponse(e.code, e.message ?: "Invalid params", request.id)
        } catch (e: MethodNotFoundException) {
            createErrorResponse(e.code, e.message ?: "Method not found", request.id)
        } catch (e: Exception) {
            logger.error("Unexpected error processing RPC request", e)
            createErrorResponse(
                -32603,
                "Internal error",
                request.id,
                mapOf("details" to (e.message ?: "Unknown error"))
            )
        }
    }

    private fun createErrorResponse(
        code: Int,
        message: String,
        id: Any?,
        data: Map<String, Any>? = null
    ): JsonRpcResponse {
        return JsonRpcResponse(
            error = JsonRpcError(
                code = code,
                message = message,
                data = data
            ),
            id = id
        )
    }
}

🔐 Security & Authentication

JWT Authentication Interceptor

@Component
class JsonRpcAuthenticationInterceptor(
    private val jwtTokenProvider: JwtTokenProvider
) : HandlerInterceptor {

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        if (request.requestURI.contains("/rpc")) {
            val authHeader = request.getHeader("Authorization")
            
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                val token = authHeader.substring(7)
                if (jwtTokenProvider.validateToken(token)) {
                    val principal = jwtTokenProvider.getPrincipal(token)
                    request.setAttribute("principal", principal)
                    return true
                }
            }
            
            response.status = HttpStatus.UNAUTHORIZED.value()
            response.contentType = "application/json"
            response.writer.write(
                """{"jsonrpc":"2.0","error":{"code":-32000,"message":"Unauthorized"}}"""
            )
            return false
        }
        
        return true
    }
}

@Configuration
class WebMvcConfig : WebMvcConfigurer {
    
    @Bean
    fun jsonRpcAuthInterceptor(
        authInterceptor: JsonRpcAuthenticationInterceptor
    ): WebMvcConfigurer {
        return object : WebMvcConfigurer {
            override fun addInterceptors(registry: InterceptorRegistry) {
                registry.addInterceptor(authInterceptor)
            }
        }
    }
}

Role-based Method Access

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RpcMethodAccess(
    val roles: Array<String> = []
)

@Component
class RpcMethodAuthorizationFilter(
    private val jwtTokenProvider: JwtTokenProvider
) {

    fun hasAccess(method: String, principal: String): Boolean {
        val requiredRoles = getRequiredRoles(method)
        if (requiredRoles.isEmpty()) return true
        
        val userRoles = jwtTokenProvider.getRoles(principal)
        return requiredRoles.any { it in userRoles }
    }

    private fun getRequiredRoles(method: String): List<String> {
        return when (method) {
            "user.delete" -> listOf("ADMIN")
            "user.create" -> listOf("ADMIN", "MANAGER")
            else -> emptyList()
        }
    }
}

🧪 Testing

@SpringBootTest
@AutoConfigureMockMvc
class JsonRpcControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @MockBean
    private lateinit var userService: UserService

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

        val request = JsonRpcRequest(
            method = "user.getById",
            params = mapOf("id" to 1),
            id = 1
        )

        mockMvc.perform(
            post("/api/rpc")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.result.id").value(1))
            .andExpect(jsonPath("$.result.name").value("John"))
            .andExpect(jsonPath("$.id").value(1))
    }

    @Test
    fun `should return error for missing parameter`() {
        val request = JsonRpcRequest(
            method = "user.getById",
            params = mapOf(),  // Missing 'id'
            id = 1
        )

        mockMvc.perform(
            post("/api/rpc")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.error.code").value(-32602))
            .andExpect(jsonPath("$.error.message").exists())
    }

    @Test
    fun `should return error for unknown method`() {
        val request = JsonRpcRequest(
            method = "user.unknownMethod",
            params = null,
            id = 1
        )

        mockMvc.perform(
            post("/api/rpc")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.error.code").value(-32601))
    }

    @Test
    fun `should handle batch requests`() {
        val user1 = User(1L, "John", "john@example.com")
        val user2 = User(2L, "Jane", "jane@example.com")
        given(userService.findById(1L)).willReturn(user1)
        given(userService.findById(2L)).willReturn(user2)

        val requests = listOf(
            JsonRpcRequest(method = "user.getById", params = mapOf("id" to 1), id = 1),
            JsonRpcRequest(method = "user.getById", params = mapOf("id" to 2), id = 2)
        )

        mockMvc.perform(
            post("/api/rpc/batch")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(requests))
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$[0].result.name").value("John"))
            .andExpect(jsonPath("$[1].result.name").value("Jane"))
    }

    @Test
    fun `should not return response for notification`() {
        val notification = JsonRpcRequest(
            method = "user.notifyChange",
            params = mapOf("userId" to 1),
            id = null  // No id means notification
        )

        mockMvc.perform(
            post("/api/rpc")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(notification))
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.id").doesNotExist())
    }
}

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

@Component
class JsonRpcMetrics(private val meterRegistry: MeterRegistry) {

    private val requestsCounter = Counter.builder("jsonrpc_requests_total")
        .description("Total JSON-RPC requests")
        .register(meterRegistry)

    private val requestsTimer = Timer.builder("jsonrpc_requests_duration")
        .description("JSON-RPC request duration")
        .register(meterRegistry)

    private val errorsCounter = Counter.builder("jsonrpc_errors_total")
        .description("Total JSON-RPC errors")
        .register(meterRegistry)

    fun recordRequest(method: String, duration: Long) {
        requestsCounter.increment(Tag.of("method", method))
        requestsTimer.record(duration, TimeUnit.MILLISECONDS)
    }

    fun recordError(method: String, errorCode: Int) {
        errorsCounter.increment(
            Tags.of(
                Tag.of("method", method),
                Tag.of("error_code", errorCode.toString())
            )
        )
    }
}

@Component
class JsonRpcMetricsInterceptor(
    private val jsonRpcMetrics: JsonRpcMetrics
) : HandlerInterceptor {

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        request.setAttribute("startTime", System.currentTimeMillis())
        return true
    }

    override fun afterCompletion(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
        ex: Exception?
    ) {
        if (request.requestURI.contains("/rpc")) {
            val startTime = request.getAttribute("startTime") as? Long ?: return
            val duration = System.currentTimeMillis() - startTime
            
            val body = request.getAttribute("jsonRpcMethod") as? String ?: "unknown"
            jsonRpcMetrics.recordRequest(body, duration)
        }
    }
}

🔧 Client Implementation

@Service
class JsonRpcClient(
    private val restTemplate: RestTemplate,
    private val objectMapper: ObjectMapper
) {

    private val logger = LoggerFactory.getLogger(JsonRpcClient::class.java)
    private var requestId = 0

    fun <T> call(
        method: String,
        params: Map<String, Any>? = null,
        resultType: Class<T>
    ): T {
        val request = JsonRpcRequest(
            method = method,
            params = params,
            id = ++requestId
        )

        return try {
            val response = restTemplate.postForObject(
                "http://localhost:8080/api/rpc",
                objectMapper.writeValueAsString(request),
                String::class.java
            )

            val jsonResponse = objectMapper.readValue(response, JsonRpcResponse::class.java)

            when {
                jsonResponse.error != null -> {
                    throw JsonRpcException(
                        jsonResponse.error.code,
                        jsonResponse.error.message
                    )
                }
                jsonResponse.result != null -> {
                    objectMapper.convertValue(jsonResponse.result, resultType)
                }
                else -> throw JsonRpcException(-32603, "Invalid response")
            }
        } catch (e: RestClientException) {
            logger.error("JSON-RPC call failed", e)
            throw JsonRpcException(-32002, "Connection error: ${e.message}")
        }
    }

    fun notify(method: String, params: Map<String, Any>? = null) {
        val request = JsonRpcRequest(
            method = method,
            params = params,
            id = null  // Notification
        )

        restTemplate.postForObject(
            "http://localhost:8080/api/rpc",
            objectMapper.writeValueAsString(request),
            String::class.java
        )
    }

    fun batchCall(requests: List<Pair<String, Map<String, Any>?>>): List<JsonRpcResponse> {
        val batchRequests = requests.map { (method, params) ->
            JsonRpcRequest(
                method = method,
                params = params,
                id = ++requestId
            )
        }

        val response = restTemplate.postForObject(
            "http://localhost:8080/api/rpc/batch",
            objectMapper.writeValueAsString(batchRequests),
            String::class.java
        )

        return objectMapper.readValue(
            response,
            object : TypeReference<List<JsonRpcResponse>>() {}
        )
    }
}

class JsonRpcException(
    val code: Int,
    message: String
) : Exception(message)

🚀 WebSocket JSON-RPC

@Configuration
@EnableWebSocket
class WebSocketJsonRpcConfig : WebSocketConfigurer {

    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(jsonRpcWebSocketHandler(), "/ws/rpc")
            .setAllowedOrigins("*")
    }

    @Bean
    fun jsonRpcWebSocketHandler(): WebSocketHandler {
        return JsonRpcWebSocketHandler()
    }
}

@Component
class JsonRpcWebSocketHandler(
    private val userRpcService: UserRpcService,
    private val objectMapper: ObjectMapper
) : TextWebSocketHandler() {

    private val logger = LoggerFactory.getLogger(JsonRpcWebSocketHandler::class.java)
    private val sessions = ConcurrentHashMap<String, WebSocketSession>()

    override fun afterConnectionEstablished(session: WebSocketSession) {
        val sessionId = session.id
        sessions[sessionId] = session
        logger.info("WebSocket connection established: $sessionId")
    }

    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        try {
            val request = objectMapper.readValue(message.payload, JsonRpcRequest::class.java)
            val response = processSingleRequest(request)
            
            val jsonResponse = objectMapper.writeValueAsString(response)
            session.sendMessage(TextMessage(jsonResponse))
        } catch (e: Exception) {
            logger.error("Error processing message", e)
            val error = JsonRpcResponse(
                error = JsonRpcError(-32603, "Internal error"),
                id = null
            )
            session.sendMessage(TextMessage(objectMapper.writeValueAsString(error)))
        }
    }

    private fun processSingleRequest(request: JsonRpcRequest): JsonRpcResponse {
        return try {
            val result = userRpcService.invokeMethod(request.method, request.params)
            JsonRpcResponse(result = result, id = request.id)
        } catch (e: Exception) {
            JsonRpcResponse(
                error = JsonRpcError(-32603, e.message ?: "Error"),
                id = request.id
            )
        }
    }

    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        sessions.remove(session.id)
        logger.info("WebSocket connection closed: ${session.id}")
    }
}

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

✅ Best Practices

Protocol:

  • Всегда включайте jsonrpc: "2.0" версию
  • Используйте стандартные error codes
  • Добавляйте id для отслеживания запросов
  • Используйте notifications для асинхронных операций

Performance:

  • Batch requests для нескольких вызовов
  • Кэшируйте результаты часто вызываемых методов
  • Ограничивайте размер параметров
  • Используйте timeout для вызовов

Security:

  • Валидируйте все входные параметры
  • Используйте role-based access control
  • Шифруйте sensitive данные
  • Логируйте все вызовы

Development:

  • Документируйте все доступные методы
  • Используйте versioning для API
  • Тестируйте error scenarios
  • Мониторьте performance метрики

❌ Anti-patterns

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

  • Длинные имена методов (используйте hierarchical: entity.action)
  • Смешивание разных типов операций в одном методе
  • Отсутствие input validation
  • Несогласованная обработка ошибок

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

  • Синхронные blocking операции
  • Отсутствие batch support
  • Неограниченный размер параметров
  • No timeout management

Когда использовать JSON-RPC

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

  • RPC-стиль интеграции
  • Legacy system integration
  • High-frequency batch operations
  • Simple request-response APIs
  • Notification-based communications

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

  • Complex query languages (использовать GraphQL)
  • Streaming data (использовать gRPC, WebSocket)
  • Stateful connections (использовать WebSocket)
  • File uploads (использовать REST)

Сравнение: JSON-RPC vs REST vs gRPC vs GraphQL

Критерий JSON-RPC REST gRPC GraphQL
Простота ✅ Простая ✅ Простая ❌ Средняя ❌ Средняя
Batch
Notifications
Streaming
Типизация Слабая Слабая ✅ Строгая ✅ Строгая
Кэширование ✅ HTTP
HTTP Compatible ❌ (HTTP/2)
Standard ✅ JSON-RPC 2.0 ❌ REST ✅ gRPC ✅ GraphQL