🌐 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 запроса
- Client создаёт SOAP Envelope с данными
- Serialization объектов в XML
- Transport по HTTP/HTTPS
- Server парсит SOAP сообщение
- Validation против XSD схемы
- Business Logic обработка запроса
- Response создание SOAP ответа
- 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 |