🌐 REST и RESTful API
Шпаргалка для Java/Kotlin разработчика
🎯 Основные принципы REST
Принцип | Описание | Пример |
---|---|---|
Stateless | Каждый запрос содержит всю необходимую информацию | JWT токен в заголовке |
Client-Server | Разделение ответственности клиента и сервера | Frontend ↔ Backend API |
Cacheable | Ответы могут кэшироваться | Cache-Control: max-age=3600 |
Uniform Interface | Единообразный интерфейс | HTTP методы + URI |
Layered System | Архитектура может иметь промежуточные слои | Load Balancer → API Gateway → Service |
Code on Demand | Опционально: сервер может отправлять код | JavaScript в ответе |
📋 HTTP методы и их использование
Основные методы
GET /api/users # Получить список пользователей
GET /api/users/123 # Получить конкретного пользователя
POST /api/users # Создать нового пользователя
PUT /api/users/123 # Полностью обновить пользователя
PATCH /api/users/123 # Частично обновить пользователя
DELETE /api/users/123 # Удалить пользователя
Идемпотентность методов
Метод | Идемпотентный | Безопасный | Описание |
---|---|---|---|
GET | ✅ | ✅ | Только чтение данных |
PUT | ✅ | ❌ | Повторный вызов даёт тот же результат |
PATCH | ❌ | ❌ | Зависит от реализации |
POST | ❌ | ❌ | Каждый вызов может создать новый ресурс |
DELETE | ✅ | ❌ | Повторное удаление безопасно |
HEAD | ✅ | ✅ | Как GET, но без тела ответа |
OPTIONS | ✅ | ✅ | Получение доступных методов |
🔗 Правильное построение URI
✅ Хорошие практики
GET /api/v1/users # Коллекция
GET /api/v1/users/123 # Конкретный ресурс
GET /api/v1/users/123/orders # Вложенная коллекция
POST /api/v1/users/123/orders # Создание в вложенной коллекции
GET /api/v1/orders?userId=123 # Фильтрация через query params
❌ Плохие практики
GET /api/getUsers # Глагол в URI
POST /api/user # Единственное число для коллекции
GET /api/users/delete/123 # Действие в URI вместо HTTP метода
GET /api/v1/users/123/delete # Глагол вместо DELETE метода
Соглашения по именованию
- Существительные, не глаголы:
/users
, не/getUsers
- Множественное число для коллекций:
/users
, не/user
- Строчные буквы с дефисами:
/user-profiles
, не/userProfiles
- Версионирование:
/api/v1/users
📊 HTTP статус коды
2xx - Успешные
Код | Название | Когда использовать |
---|---|---|
200 | OK | GET, PUT, PATCH успешны |
201 | Created | POST создал ресурс |
202 | Accepted | Запрос принят на обработку |
204 | No Content | DELETE, PUT без возврата данных |
4xx - Ошибки клиента
Код | Название | Когда использовать |
---|---|---|
400 | Bad Request | Некорректные данные запроса |
401 | Unauthorized | Не авторизован |
403 | Forbidden | Нет прав доступа |
404 | Not Found | Ресурс не найден |
409 | Conflict | Конфликт при создании/обновлении |
422 | Unprocessable Entity | Валидация не прошла |
429 | Too Many Requests | Rate limiting |
5xx - Ошибки сервера
Код | Название | Когда использовать |
---|---|---|
500 | Internal Server Error | Общая ошибка сервера |
502 | Bad Gateway | Ошибка upstream сервиса |
503 | Service Unavailable | Сервис временно недоступен |
🔧 Spring Boot реализация
Контроллер с правильными аннотациями
@RestController
@RequestMapping("/api/v1/users")
@Validated
public class UserController {
@GetMapping
public ResponseEntity<Page<UserDto>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String search) {
Page<UserDto> users = userService.findUsers(page, size, search);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDto createdUser = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(createdUser.getId())
.toUri();
return ResponseEntity.created(location).body(createdUser);
}
@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserDto updatedUser = userService.update(id, request);
return ResponseEntity.ok(updatedUser);
}
@PatchMapping("/{id}")
public ResponseEntity<UserDto> patchUser(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
UserDto patchedUser = userService.patch(id, updates);
return ResponseEntity.ok(patchedUser);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
Глобальная обработка ошибок
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"RESOURCE_NOT_FOUND",
ex.getMessage(),
Instant.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage
));
ErrorResponse error = new ErrorResponse(
"VALIDATION_FAILED",
"Validation failed",
Instant.now(),
errors
);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
ErrorResponse error = new ErrorResponse(
"RESOURCE_CONFLICT",
"Resource already exists",
Instant.now()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}
📄 Стандартизация ответов
Единый формат ответа
@Data
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
private Instant timestamp;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null, Instant.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message, Instant.now());
}
}
Пагинация
@Data
public class PageResponse<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
private boolean hasNext;
private boolean hasPrevious;
public static <T> PageResponse<T> of(Page<T> page) {
PageResponse<T> response = new PageResponse<>();
response.content = page.getContent();
response.page = page.getNumber();
response.size = page.getSize();
response.totalElements = page.getTotalElements();
response.totalPages = page.getTotalPages();
response.hasNext = page.hasNext();
response.hasPrevious = page.hasPrevious();
return response;
}
}
🔒 Безопасность и заголовки
Важные заголовки
@Configuration
public class SecurityHeadersConfig {
@Bean
public FilterRegistrationBean<SecurityHeadersFilter> securityHeadersFilter() {
FilterRegistrationBean<SecurityHeadersFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new SecurityHeadersFilter());
registration.addUrlPatterns("/api/*");
return registration;
}
}
public class SecurityHeadersFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
// CORS
httpResponse.setHeader("Access-Control-Allow-Origin", "*");
httpResponse.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
httpResponse.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
// Security
httpResponse.setHeader("X-Content-Type-Options", "nosniff");
httpResponse.setHeader("X-Frame-Options", "DENY");
httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
// Cache
httpResponse.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
chain.doFilter(request, response);
}
}
📊 Мониторинг и логирование
Логирование запросов
@Component
@Slf4j
public class RequestLoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
long startTime = System.currentTimeMillis();
try {
chain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
log.info("HTTP Request: {} {} - Status: {} - Duration: {}ms",
httpRequest.getMethod(),
httpRequest.getRequestURI(),
httpResponse.getStatus(),
duration
);
}
}
}
Метрики с Micrometer
@Component
public class ApiMetrics {
private final Counter requestCounter;
private final Timer requestTimer;
public ApiMetrics(MeterRegistry meterRegistry) {
this.requestCounter = Counter.builder("api_requests_total")
.description("Total API requests")
.register(meterRegistry);
this.requestTimer = Timer.builder("api_request_duration")
.description("API request duration")
.register(meterRegistry);
}
@EventListener
public void handleRequestCompleted(RequestCompletedEvent event) {
requestCounter.increment(
"method", event.getMethod(),
"status", String.valueOf(event.getStatus())
);
requestTimer.record(event.getDuration(), TimeUnit.MILLISECONDS);
}
}
🚀 Полезные советы
✅ Лучшие практики
- Версионирование API: используйте
/api/v1/
в URL или заголовокAccept: application/vnd.api+json;version=1
- Rate Limiting: ограничивайте количество запросов от клиента
- Документация: используйте OpenAPI/Swagger для автогенерации документации
- Валидация: всегда валидируйте входные данные
- Логирование: логируйте все запросы и ошибки
- Мониторинг: отслеживайте метрики производительности
🔍 OpenAPI документация
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("User Management API")
.version("v1.0")
.description("API for managing users"))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
📱 Content Negotiation
@GetMapping(value = "/users/{id}", produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(user);
}
🚀 gRPC
Шпаргалка для Java/Kotlin разработчика
🎯 Основные преимущества gRPC
Преимущество | Описание | vs REST |
---|---|---|
Производительность | Бинарный протокол, сжатие | В 7-10 раз быстрее JSON |
Type Safety | Строгая типизация через Protocol Buffers | Нет проблем с контрактами API |
Streaming | Поддержка потоковых данных | REST требует WebSockets |
Code Generation | Автогенерация клиентов и серверов | Ручное написание клиентов |
HTTP/2 | Мультиплексирование, Server Push | HTTP/1.1 по умолчанию |
Deadline/Timeout | Встроенная поддержка таймаутов | Нужно реализовывать отдельно |
📋 Типы gRPC вызовов
1. Unary RPC (обычный запрос-ответ)
service UserService {
rpc GetUser(GetUserRequest) returns (UserResponse);
}
2. Server Streaming (сервер отправляет поток)
service UserService {
rpc GetUsers(GetUsersRequest) returns (stream UserResponse);
}
3. Client Streaming (клиент отправляет поток)
service UserService {
rpc CreateUsers(stream CreateUserRequest) returns (CreateUsersResponse);
}
4. Bidirectional Streaming (двусторонний поток)
service ChatService {
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
📄 Protocol Buffers (proto3)
Основные типы данных
syntax = "proto3";
package com.example.grpc;
option java_package = "com.example.grpc";
option java_outer_classname = "UserProto";
// Базовые типы
message User {
int64 id = 1; // int64, int32, uint32, uint64
string name = 2; // string
string email = 3; // string
bool is_active = 4; // bool
double balance = 5; // double, float
bytes avatar = 6; // bytes
// Enum
Status status = 7;
// Вложенные объекты
Address address = 8;
// Массивы (repeated)
repeated string tags = 9;
repeated Order orders = 10;
// Map
map<string, string> metadata = 11;
// Optional (proto3)
optional string middle_name = 12;
// Timestamp
google.protobuf.Timestamp created_at = 13;
google.protobuf.Timestamp updated_at = 14;
}
enum Status {
UNKNOWN = 0; // Первое значение всегда 0
ACTIVE = 1;
INACTIVE = 2;
SUSPENDED = 3;
}
message Address {
string street = 1;
string city = 2;
string country = 3;
string postal_code = 4;
}
message Order {
int64 id = 1;
string product_name = 2;
double amount = 3;
}
Импорты и well-known types
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/wrappers.proto";
message UserResponse {
User user = 1;
google.protobuf.Timestamp response_time = 2;
// Nullable значения
google.protobuf.StringValue nickname = 3; // Может быть null
google.protobuf.Int64Value score = 4; // Может быть null
}
message DeleteUserRequest {
int64 user_id = 1;
}
message DeleteUserResponse {
// Пустой ответ
google.protobuf.Empty empty = 1;
// или просто не указывать поля
}
🛠 Настройка в Spring Boot
build.gradle (Kotlin DSL)
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
id("com.google.protobuf") version "0.9.4"
kotlin("jvm") version "1.9.20"
kotlin("plugin.spring") version "1.9.20"
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
// gRPC Spring Boot Starter
implementation("net.devh:grpc-spring-boot-starter:2.15.0.RELEASE")
// Protocol Buffers
implementation("com.google.protobuf:protobuf-java:3.25.1")
implementation("io.grpc:grpc-stub:1.58.0")
implementation("io.grpc:grpc-protobuf:1.58.0")
// Для Java 9+
implementation("javax.annotation:javax.annotation-api:1.3.2")
// Monitoring
implementation("io.micrometer:micrometer-registry-prometheus")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.1"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.58.0"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("grpc")
}
}
}
}
application.yml
grpc:
server:
port: 9090
enable-reflection: true # Для grpcurl и Postman
client:
user-service:
address: 'static://localhost:9091'
negotiation-type: plaintext
max-inbound-message-size: 4MB
max-outbound-message-size: 4MB
spring:
application:
name: grpc-service
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
🔧 Реализация сервера (Spring Boot)
gRPC Service
import net.devh.boot.grpc.server.service.GrpcService
import io.grpc.Status
import io.grpc.StatusException
@GrpcService
class UserGrpcService(
private val userService: UserService
) : UserServiceGrpc.UserServiceImplBase() {
override fun getUser(request: GetUserRequest, responseObserver: StreamObserver<UserResponse>) {
try {
val user = userService.findById(request.userId)
val response = UserResponse.newBuilder()
.setUser(user.toProto())
.setResponseTime(Timestamp.newBuilder()
.setSeconds(Instant.now().epochSecond)
.build())
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
} catch (e: UserNotFoundException) {
responseObserver.onError(
Status.NOT_FOUND
.withDescription("User not found: ${request.userId}")
.withCause(e)
.asException()
)
} catch (e: Exception) {
responseObserver.onError(
Status.INTERNAL
.withDescription("Internal server error")
.withCause(e)
.asException()
)
}
}
override fun getUsers(request: GetUsersRequest, responseObserver: StreamObserver<UserResponse>) {
try {
userService.findAll(request.pageSize, request.pageToken)
.forEach { user ->
val response = UserResponse.newBuilder()
.setUser(user.toProto())
.build()
responseObserver.onNext(response)
}
responseObserver.onCompleted()
} catch (e: Exception) {
responseObserver.onError(
Status.INTERNAL
.withDescription("Failed to fetch users")
.asException()
)
}
}
override fun createUsers(responseObserver: StreamObserver<CreateUsersResponse>): StreamObserver<CreateUserRequest> {
return object : StreamObserver<CreateUserRequest> {
private val createdUsers = mutableListOf<User>()
override fun onNext(request: CreateUserRequest) {
try {
val user = userService.create(request.toDomain())
createdUsers.add(user)
} catch (e: Exception) {
responseObserver.onError(
Status.INVALID_ARGUMENT
.withDescription("Invalid user data: ${e.message}")
.asException()
)
}
}
override fun onError(t: Throwable) {
responseObserver.onError(
Status.INTERNAL
.withDescription("Client stream error")
.withCause(t)
.asException()
)
}
override fun onCompleted() {
val response = CreateUsersResponse.newBuilder()
.addAllUsers(createdUsers.map { it.toProto() })
.setTotalCreated(createdUsers.size)
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
}
}
}
}
Маппинг между Domain и Proto
fun User.toProto(): UserProto.User = UserProto.User.newBuilder()
.setId(this.id)
.setName(this.name)
.setEmail(this.email)
.setIsActive(this.isActive)
.setBalance(this.balance)
.setStatus(this.status.toProto())
.setAddress(this.address.toProto())
.addAllTags(this.tags)
.putAllMetadata(this.metadata)
.setCreatedAt(this.createdAt.toProtoTimestamp())
.build()
fun CreateUserRequest.toDomain(): CreateUserCommand = CreateUserCommand(
name = this.name,
email = this.email,
address = this.address.toDomain()
)
fun Instant.toProtoTimestamp(): Timestamp = Timestamp.newBuilder()
.setSeconds(this.epochSecond)
.setNanos(this.nano)
.build()
📞 Реализация клиента
gRPC Client Configuration
@Configuration
class GrpcClientConfig {
@Bean
fun userServiceStub(@GrpcClient("user-service") channel: Channel): UserServiceGrpc.UserServiceBlockingStub {
return UserServiceGrpc.newBlockingStub(channel)
}
@Bean
fun userServiceAsyncStub(@GrpcClient("user-service") channel: Channel): UserServiceGrpc.UserServiceStub {
return UserServiceGrpc.newStub(channel)
}
}
Синхронный клиент
@Service
class UserGrpcClient(
private val userServiceStub: UserServiceGrpc.UserServiceBlockingStub
) {
fun getUser(userId: Long): UserResponse {
val request = GetUserRequest.newBuilder()
.setUserId(userId)
.build()
return try {
userServiceStub
.withDeadlineAfter(5, TimeUnit.SECONDS)
.getUser(request)
} catch (e: StatusRuntimeException) {
when (e.status.code) {
Status.Code.NOT_FOUND -> throw UserNotFoundException("User not found: $userId")
Status.Code.DEADLINE_EXCEEDED -> throw ServiceTimeoutException("Request timeout")
else -> throw ServiceException("gRPC call failed: ${e.status.description}")
}
}
}
fun getUsersStream(pageSize: Int): List<UserResponse> {
val request = GetUsersRequest.newBuilder()
.setPageSize(pageSize)
.build()
val users = mutableListOf<UserResponse>()
val iterator = userServiceStub
.withDeadlineAfter(30, TimeUnit.SECONDS)
.getUsers(request)
while (iterator.hasNext()) {
users.add(iterator.next())
}
return users
}
}
Асинхронный клиент
@Service
class UserGrpcAsyncClient(
private val userServiceStub: UserServiceGrpc.UserServiceStub
) {
fun getUserAsync(userId: Long): CompletableFuture<UserResponse> {
val request = GetUserRequest.newBuilder()
.setUserId(userId)
.build()
val future = CompletableFuture<UserResponse>()
userServiceStub
.withDeadlineAfter(5, TimeUnit.SECONDS)
.getUser(request, object : StreamObserver<UserResponse> {
override fun onNext(response: UserResponse) {
future.complete(response)
}
override fun onError(t: Throwable) {
future.completeExceptionally(t)
}
override fun onCompleted() {
// Response уже получен в onNext
}
})
return future
}
fun createUsersStream(users: List<CreateUserRequest>): CompletableFuture<CreateUsersResponse> {
val future = CompletableFuture<CreateUsersResponse>()
val requestObserver = userServiceStub
.withDeadlineAfter(60, TimeUnit.SECONDS)
.createUsers(object : StreamObserver<CreateUsersResponse> {
override fun onNext(response: CreateUsersResponse) {
future.complete(response)
}
override fun onError(t: Throwable) {
future.completeExceptionally(t)
}
override fun onCompleted() {}
})
// Отправляем все запросы
users.forEach { requestObserver.onNext(it) }
requestObserver.onCompleted()
return future
}
}
🔧 Интерсепторы и мидлвар
Server Interceptor
@Component
class LoggingServerInterceptor : ServerInterceptor {
private val logger = LoggerFactory.getLogger(LoggingServerInterceptor::class.java)
override fun <ReqT, RespT> interceptCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
val startTime = System.currentTimeMillis()
val methodName = call.methodDescriptor.fullMethodName
logger.info("gRPC call started: $methodName")
return object : ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(
next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
override fun close(status: Status, trailers: Metadata) {
val duration = System.currentTimeMillis() - startTime
logger.info("gRPC call completed: $methodName - Status: ${status.code} - Duration: ${duration}ms")
super.close(status, trailers)
}
}, headers)
) {
override fun onMessage(message: ReqT) {
logger.debug("gRPC request: $methodName - Message: $message")
super.onMessage(message)
}
}
}
}
Client Interceptor
@Component
class AuthClientInterceptor(
private val jwtTokenProvider: JwtTokenProvider
) : ClientInterceptor {
override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
next: Channel
): ClientCall<ReqT, RespT> {
return object : ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
next.newCall(method, callOptions)
) {
override fun start(responseListener: Listener<RespT>, headers: Metadata) {
// Добавляем JWT токен
val token = jwtTokenProvider.getToken()
headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer $token")
// Добавляем correlation ID
val correlationId = MDC.get("correlationId") ?: UUID.randomUUID().toString()
headers.put(Metadata.Key.of("x-correlation-id", Metadata.ASCII_STRING_MARSHALLER), correlationId)
super.start(responseListener, headers)
}
}
}
}
📊 Мониторинг и метрики
Micrometer метрики
@Component
class GrpcMetrics(
private val meterRegistry: MeterRegistry
) {
private val grpcRequestsCounter = Counter.builder("grpc_requests_total")
.description("Total gRPC requests")
.register(meterRegistry)
private val grpcRequestsTimer = Timer.builder("grpc_requests_duration_seconds")
.description("gRPC request duration")
.register(meterRegistry)
fun recordRequest(method: String, status: String, duration: Duration) {
grpcRequestsCounter.increment(
Tags.of(
Tag.of("method", method),
Tag.of("status", status)
)
)
grpcRequestsTimer.record(duration)
}
}
@Component
class MetricsServerInterceptor(
private val grpcMetrics: GrpcMetrics
) : ServerInterceptor {
override fun <ReqT, RespT> interceptCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
val startTime = Instant.now()
val methodName = call.methodDescriptor.fullMethodName
return next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
override fun close(status: Status, trailers: Metadata) {
val duration = Duration.between(startTime, Instant.now())
grpcMetrics.recordRequest(methodName, status.code.name, duration)
super.close(status, trailers)
}
}, headers)
}
}
🧪 Тестирование
Unit тесты с MockServer
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserGrpcServiceTest {
private lateinit var server: Server
private lateinit var channel: ManagedChannel
private lateinit var stub: UserServiceGrpc.UserServiceBlockingStub
@MockBean
private lateinit var userService: UserService
@BeforeAll
fun setUp() {
val serviceImpl = UserGrpcService(userService)
server = ServerBuilder.forPort(0)
.addService(serviceImpl)
.build()
.start()
channel = ManagedChannelBuilder.forAddress("localhost", server.port)
.usePlaintext()
.build()
stub = UserServiceGrpc.newBlockingStub(channel)
}
@AfterAll
fun tearDown() {
channel.shutdown()
server.shutdown()
}
@Test
fun `should return user when user exists`() {
// Given
val userId = 1L
val user = User(id = userId, name = "John Doe", email = "john@example.com")
given(userService.findById(userId)).willReturn(user)
// When
val request = GetUserRequest.newBuilder().setUserId(userId).build()
val response = stub.getUser(request)
// Then
assertThat(response.user.id).isEqualTo(userId)
assertThat(response.user.name).isEqualTo("John Doe")
assertThat(response.user.email).isEqualTo("john@example.com")
}
@Test
fun `should throw NOT_FOUND when user does not exist`() {
// Given
val userId = 999L
given(userService.findById(userId)).willThrow(UserNotFoundException("User not found"))
// When & Then
val request = GetUserRequest.newBuilder().setUserId(userId).build()
val exception = assertThrows<StatusRuntimeException> {
stub.getUser(request)
}
assertThat(exception.status.code).isEqualTo(Status.Code.NOT_FOUND)
}
}
Integration тесты с @GrpcTest
@GrpcTest(UserGrpcService::class)
class UserGrpcServiceIntegrationTest {
@MockBean
private lateinit var userService: UserService
@Autowired
private lateinit var userServiceStub: UserServiceGrpc.UserServiceBlockingStub
@Test
fun `should handle streaming requests`() {
// Given
val users = listOf(
User(1, "User 1", "user1@example.com"),
User(2, "User 2", "user2@example.com")
)
given(userService.findAll(any(), any())).willReturn(users)
// When
val request = GetUsersRequest.newBuilder().setPageSize(10).build()
val responses = mutableListOf<UserResponse>()
val iterator = userServiceStub.getUsers(request)
while (iterator.hasNext()) {
responses.add(iterator.next())
}
// Then
assertThat(responses).hasSize(2)
assertThat(responses[0].user.name).isEqualTo("User 1")
assertThat(responses[1].user.name).isEqualTo("User 2")
}
}
🚀 Полезные команды и инструменты
grpcurl (тестирование из командной строки)
# Список сервисов
grpcurl -plaintext localhost:9090 list
# Список методов сервиса
grpcurl -plaintext localhost:9090 list com.example.UserService
# Описание метода
grpcurl -plaintext localhost:9090 describe com.example.UserService.GetUser
# Вызов метода
grpcurl -plaintext -d '{"user_id": 1}' localhost:9090 com.example.UserService/GetUser
# Streaming
grpcurl -plaintext -d '{"page_size": 5}' localhost:9090 com.example.UserService/GetUsers
Postman
- Создать новый gRPC request
- Указать server URL:
localhost:9090
- Импортировать .proto файлы
- Выбрать метод и отправить запрос
Evans (интерактивный gRPC клиент)
# Установка
go install github.com/ktr0731/evans@latest
# Подключение
evans --host localhost --port 9090 --reflection
# Использование
show package
show service
call GetUser
💡 Лучшие практики
✅ Do's
- Используйте streaming для больших объемов данных
- Версионирование через package в proto файлах
- Deadline/Timeout для всех вызовов
- Retry policy с экспоненциальным backoff
- Circuit Breaker для внешних сервисов
- Мониторинг latency и error rate
- Логирование всех запросов и ошибок
- TLS в продакшене
❌ Don'ts
- Не используйте большие сообщения (>4MB)
- Не забывайте про onError/onCompleted в streaming
- Не блокируйте UI потоки длительными gRPC вызовами
- Не игнорируйте статус коды ошибок
- Не используйте plaintext в продакшене
🔒 Безопасность
// TLS конфигурация
@Configuration
class GrpcSecurityConfig {
@Bean
fun grpcServerCustomizer(): NettyChannelBuilderCustomizer {
return NettyChannelBuilderCustomizer { builder ->
builder.sslContext(
GrpcSslContexts.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build()
)
}
}
}
🧼 SOAP
Краткая шпаргалка для Java/Kotlin разработчика
🎯 Основы SOAP
Что такое SOAP?
SOAP (Simple Object Access Protocol) — протокол обмена структурированной информацией в распределённых системах. Использует XML для форматирования сообщений и может работать поверх различных транспортных протоколов (HTTP, SMTP, TCP).
Ключевые концепции
| Компонент | Описание | Назначение | |---
📚 Теория SOAP
Стили SOAP
1. Document/Literal (рекомендуется)
- XML Schema валидация
- Loose coupling
- Лучшая интероперабельность
- Современный подход
2. RPC/Encoded (deprecated)
- Tight coupling
- Проблемы с интероперабельностью
- Не используется в новых проектах
WS-* Standards (Web Services Standards)
Стандарт | Назначение | Пример использования |
---|---|---|
WS-Security | Безопасность сообщений | Username/Password, X.509, SAML |
WS-ReliableMessaging | Гарантированная доставка | At-least-once, exactly-once |
WS-Transaction | Распределённые транзакции | 2PC (Two-Phase Commit) |
WS-Policy | Политики безопасности | Требования к шифрованию |
WS-Addressing | Маршрутизация сообщений | Endpoint references |
WSDL Structure
WSDL = Types + Messages + PortTypes + Bindings + Services
Types: XSD схемы данных
Messages: Абстрактные сообщения
PortTypes: Абстрактные операции (интерфейс)
Bindings: Привязка к протоколу (SOAP/HTTP)
Services: Конкретные endpoints
Жизненный цикл SOAP запроса
- Client создаёт SOAP Envelope с данными
- Serialization объектов в XML
- Transport по HTTP/HTTPS
- Server парсит SOAP сообщение
- Validation против XSD схемы
- Business Logic обработка запроса
- Response создание SOAP ответа
- Deserialization в объекты клиента
-----------|----------|------------| | SOAP Envelope | Корневой элемент сообщения | Определяет структуру сообщения | | SOAP Header | Метаданные сообщения | Аутентификация, routing, транзакции | | SOAP Body | Основное содержимое | Данные запроса/ответа | | SOAP Fault | Информация об ошибке | Стандартизированная обработка ошибок | | WSDL | Описание веб-сервиса | Контракт API (методы, типы, endpoints) |
SOAP vs REST vs gRPC
Критерий | SOAP | REST | gRPC |
---|---|---|---|
Формат | XML | JSON/XML | Binary (Protobuf) |
Размер сообщения | Большой (~3-5x) | Средний | Маленький |
Стандарты | WS-*, строгие | HTTP, гибкие | HTTP/2, простые |
Типизация | Строгая (XSD) | Слабая | Строгая (Proto) |
Кэширование | Сложное (POST) | Простое (GET) | Нет |
Транспорт | HTTP, SMTP, TCP | HTTP | HTTP/2 |
Безопасность | WS-Security | HTTPS + OAuth | TLS + Interceptors |
Когда использовать SOAP?
✅ Подходит для:
- Интеграции с legacy системами
- Строгих контрактов (банки, страхование)
- Транзакционных операций
- Корпоративных систем с WS-Security
- Надёжной доставки сообщений
❌ Не подходит для:
- REST API для веб/мобильных приложений
- Высоконагруженных систем
- Микросервисной архитектуры
- Простых CRUD операций
📋 Структура SOAP сообщения
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<!-- Метаданные, аутентификация -->
</soap:Header>
<soap:Body>
<tns:GetUserRequest xmlns:tns="http://example.com/userservice">
<tns:userId>123</tns:userId>
</tns:GetUserRequest>
</soap:Body>
</soap:Envelope>
SOAP Fault
<soap:Fault>
<faultcode>soap:Client</faultcode>
<faultstring>User not found</faultstring>
<detail>
<errorCode>USER_NOT_FOUND</errorCode>
</detail>
</soap:Fault>
🛠 Spring Boot Setup
dependencies (build.gradle)
implementation("org.springframework.boot:spring-boot-starter-web-services")
implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.0")
implementation("org.glassfish.jaxb:jaxb-runtime:4.0.3")
Configuration
@Configuration
@EnableWs
class WebServiceConfig {
@Bean(name = ["users"])
fun defaultWsdl11Definition(usersSchema: XsdSchema): DefaultWsdl11Definition {
val wsdl = DefaultWsdl11Definition()
wsdl.setPortTypeName("UsersPort")
wsdl.setLocationUri("/ws")
wsdl.setTargetNamespace("http://example.com/userservice")
wsdl.setSchema(usersSchema)
return wsdl
}
@Bean
fun usersSchema(): XsdSchema = SimpleXsdSchema(ClassPathResource("users.xsd"))
@Bean
fun servletRegistrationBean(): ServletRegistrationBean<MessageDispatcherServlet> {
return ServletRegistrationBean(MessageDispatcherServlet(), "/ws/*")
}
}
📄 XSD Schema (users.xsd)
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://example.com/userservice">
<xs:element name="getUserRequest">
<xs:complexType>
<xs:sequence>
<xs:element name="userId" type="xs:long"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="getUserResponse">
<xs:complexType>
<xs:sequence>
<xs:element name="user" type="tns:user"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:complexType name="user">
<xs:sequence>
<xs:element name="id" type="xs:long"/>
<xs:element name="name" type="xs:string"/>
<xs:element name="email" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
🔧 SOAP Endpoint
@Endpoint
class UserEndpoint(private val userService: UserService) {
@PayloadRoot(namespace = "http://example.com/userservice", localPart = "getUserRequest")
@ResponsePayload
fun getUser(@RequestPayload request: GetUserRequest): GetUserResponse {
val user = userService.findById(request.userId)
?: throw UserNotFoundException("User not found")
val response = GetUserResponse()
response.user = user.toSoapUser()
return response
}
@SoapFault(faultCode = FaultCode.CLIENT)
class UserNotFoundException(message: String) : Exception(message)
}
📞 SOAP Client
Configuration
@Configuration
class SoapClientConfig {
@Bean
fun webServiceTemplate(): WebServiceTemplate {
val template = WebServiceTemplate()
template.defaultUri = "http://localhost:8080/ws"
template.marshaller = marshaller()
template.unmarshaller = marshaller()
return template
}
@Bean
fun marshaller(): Jaxb2Marshaller {
val marshaller = Jaxb2Marshaller()
marshaller.setContextPath("com.example.soap.generated")
return marshaller
}
}
Client Service
@Service
class UserSoapClient(private val webServiceTemplate: WebServiceTemplate) {
fun getUser(userId: Long): User? {
val request = GetUserRequest().apply { this.userId = userId }
return try {
val response = webServiceTemplate.marshalSendAndReceive(request) as GetUserResponse
response.user.toDomainUser()
} catch (e: SoapFaultException) {
if (e.faultCode == "USER_NOT_FOUND") null
else throw ServiceException("SOAP call failed")
}
}
}
🧪 Testing
@WebServiceServerTest
class UserEndpointTest {
@Autowired
private lateinit var mockWebServiceClient: MockWebServiceClient
@MockBean
private lateinit var userService: UserService
@Test
fun `should return user`() {
given(userService.findById(1L)).willReturn(testUser)
val request = """
<getUserRequest xmlns="http://example.com/userservice">
<userId>1</userId>
</getUserRequest>
""".trimIndent()
mockWebServiceClient
.sendRequest(RequestCreators.withPayload(StringSource(request)))
.andExpect(ResponseMatchers.noFault())
}
}
🔍 Полезные команды
curl
curl -X POST http://localhost:8080/ws \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<getUserRequest xmlns="http://example.com/userservice">
<userId>1</userId>
</getUserRequest>
</soap:Body>
</soap:Envelope>'
WSDL доступ
http://localhost:8080/ws/users.wsdl
💡 Практические советы
✅ Best Practices
Архитектурные:
- Используйте Document/Literal стиль
- Проектируйте coarse-grained операции
- Избегайте stateful сервисов
- Версионируйте через namespace
Производительность:
- Используйте connection pooling
- Кэшируйте WSDL и схемы
- Настройте reasonable timeouts (5-30с)
- Мониторьте размер XML сообщений
Безопасность:
- HTTPS обязательно в продакшене
- WS-Security для enterprise интеграций
- Валидируйте все входные данные
- Логируйте security events
❌ Anti-patterns
Технические:
- RPC/Encoded стиль (deprecated)
- Игнорирование SOAP Fault
- Отсутствие XSD валидации
- Больше 1MB в одном сообщении
Архитектурные:
- Chatty API (много мелких вызовов)
- Tight coupling через shared schemas
- Session state в SOAP сервисах
- Смешивание business logic с endpoint
🔧 Отладка и мониторинг
Логирование:
- Включите SOAP message logging
- Логируйте performance metrics
- Отслеживайте SOAP Faults
- Корреляционные ID для трейсинга
Инструменты:
- SoapUI — тестирование и отладка
- Postman — простые SOAP запросы
- Wireshark — анализ сетевого трафика
- Spring Boot Actuator — метрики и health checks
🔮 GraphQL
Шпаргалка для Java/Kotlin разработчика
🎯 Основы GraphQL
Что такое GraphQL?
GraphQL — язык запросов и среда выполнения для API. Позволяет клиентам запрашивать точно те данные, которые им нужны, в одном запросе.
Ключевые концепции
Компонент | Описание | Назначение |
---|---|---|
Schema | Описание API | Определяет типы, поля, операции |
Query | Чтение данных | SELECT в SQL |
Mutation | Изменение данных | INSERT/UPDATE/DELETE в SQL |
Subscription | Real-time данные | WebSocket подписки |
Resolver | Логика получения данных | Связь schema с бизнес-логикой |
DataLoader | Оптимизация N+1 | Batch loading, кэширование |
GraphQL vs REST vs gRPC
Критерий | GraphQL | REST | gRPC |
---|---|---|---|
Запросы | Один endpoint, гибкие запросы | Множество endpoints | Предопределённые методы |
Over/Under fetching | Нет | Часто | Нет (строгая типизация) |
Кэширование | Сложное | Простое (HTTP) | Нет |
Real-time | Subscriptions | WebSocket/SSE | Streaming |
Типизация | Строгая (Schema) | Слабая | Строгая (Proto) |
Инструменты | GraphiQL, Apollo | Swagger, Postman | grpcurl, Evans |
Сложность | Высокая | Низкая | Средняя |
Когда использовать GraphQL?
✅ Подходит для:
- Frontend с разными требованиями к данным
- Mobile приложений (экономия трафика)
- Микрофронтендов
- Rapid prototyping
- Агрегации данных из множества источников
❌ Не подходит для:
- Простых CRUD операций
- File uploads/downloads
- Систем реального времени с высокой нагрузкой
- Кэширования на CDN уровне
📚 Теория GraphQL
Типы операций
1. Query (чтение)
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts {
title
createdAt
}
}
}
2. Mutation (изменение)
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
name
email
}
}
3. Subscription (подписка)
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
author {
name
}
}
}
Скалярные типы
Тип | Описание | Java/Kotlin |
---|---|---|
String | UTF-8 строка | String |
Int | 32-bit integer | Int/Integer |
Float | Double precision | Double |
Boolean | true/false | Boolean |
ID | Уникальный идентификатор | String/Long |
Проблемы GraphQL
1. N+1 Problem
Query: users { posts { title } }
SQL: SELECT * FROM users (1 запрос)
SELECT * FROM posts WHERE user_id = 1 (N запросов)
Решение: DataLoader
2. Query Complexity
- Глубокие вложенные запросы
- Циклические зависимости Решение: Query depth limiting, complexity analysis
3. Security
- Denial of Service через сложные запросы
- Информационные утечки через introspection Решение: Query whitelisting, depth limiting
🛠 Spring Boot Setup
Dependencies (build.gradle)
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Для subscriptions
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-websocket")
Configuration
@Configuration
class GraphQLConfig {
@Bean
fun dataFetcherExceptionResolver(): DataFetcherExceptionResolver {
return DataFetcherExceptionResolverAdapter { ex, env ->
when (ex) {
is UserNotFoundException -> GraphqlErrorBuilder.newError()
.message(ex.message)
.errorType(ErrorType.NOT_FOUND)
.location(env.field.sourceLocation)
.build()
else -> null
}
}
}
@Bean
fun queryComplexityInstrumentation(): Instrumentation {
return MaxQueryComplexityInstrumentation(100)
}
}
📄 Schema Definition (schema.graphqls)
# Типы
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
}
# Input типы
input UserInput {
name: String!
email: String!
}
input PostInput {
title: String!
content: String!
authorId: ID!
}
# Скалярные типы
scalar DateTime
# Операции
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createUser(input: UserInput!): User!
updateUser(id: ID!, input: UserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: PostInput!): Post!
}
type Subscription {
postAdded: Post!
commentAdded(postId: ID!): Comment!
}
# Pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
🔧 Resolvers
Query Controller
@Controller
class UserController(
private val userService: UserService,
private val userDataLoader: DataLoader<Long, User>
) {
@QueryMapping
fun user(@Argument id: Long): User? {
return userService.findById(id)
}
@QueryMapping
fun users(
@Argument first: Int?,
@Argument after: String?
): Connection<User> {
return userService.findAll(first ?: 10, after)
}
@SchemaMapping(typeName = "Post", field = "author")
fun getAuthor(post: Post, env: DataFetchingEnvironment): CompletableFuture<User> {
return userDataLoader.load(post.authorId, env)
}
}
Mutation Controller
@Controller
class UserMutationController(
private val userService: UserService
) {
@MutationMapping
fun createUser(@Argument input: UserInput): User {
return userService.create(input.name, input.email)
}
@MutationMapping
fun updateUser(@Argument id: Long, @Argument input: UserInput): User {
return userService.update(id, input.name, input.email)
}
@MutationMapping
fun deleteUser(@Argument id: Long): Boolean {
userService.delete(id)
return true
}
}
Subscription Controller
@Controller
class SubscriptionController {
@SubscriptionMapping
fun postAdded(): Flux<Post> {
return PostEventPublisher.getPostStream()
}
@SubscriptionMapping
fun commentAdded(@Argument postId: Long): Flux<Comment> {
return CommentEventPublisher.getCommentStream()
.filter { it.postId == postId }
}
}
🚀 DataLoader (N+1 решение)
DataLoader Configuration
@Configuration
class DataLoaderConfig {
@Bean
fun userDataLoader(userService: UserService): DataLoader<Long, User> {
return DataLoader.newMappedDataLoader { userIds ->
CompletableFuture.supplyAsync {
userService.findByIds(userIds.toList())
.associateBy { it.id }
}
}
}
@Bean
fun postDataLoader(postService: PostService): DataLoader<Long, List<Post>> {
return DataLoader.newMappedDataLoader { userIds ->
CompletableFuture.supplyAsync {
postService.findByUserIds(userIds.toList())
.groupBy { it.authorId }
}
}
}
}
DataLoader Registry
@Component
class DataLoaderRegistryFactory(
private val userDataLoader: DataLoader<Long, User>,
private val postDataLoader: DataLoader<Long, List<Post>>
) {
fun create(): DataLoaderRegistry {
return DataLoaderRegistry.newRegistry()
.register("userDataLoader", userDataLoader)
.register("postDataLoader", postDataLoader)
.build()
}
}
🧪 Testing
Query Test
@GraphQlTest(UserController::class)
class UserControllerTest {
@Autowired
private lateinit var graphQlTester: GraphQlTester
@MockBean
private lateinit var userService: UserService
@Test
fun `should return user by id`() {
// Given
val user = User(1L, "John Doe", "john@example.com")
given(userService.findById(1L)).willReturn(user)
// When & Then
graphQlTester
.document("""
query {
user(id: 1) {
id
name
email
}
}
""")
.execute()
.path("user.id").entity(String::class.java).isEqualTo("1")
.path("user.name").entity(String::class.java).isEqualTo("John Doe")
.path("user.email").entity(String::class.java).isEqualTo("john@example.com")
}
@Test
fun `should return error when user not found`() {
given(userService.findById(999L)).willReturn(null)
graphQlTester
.document("""
query {
user(id: 999) {
id
name
}
}
""")
.execute()
.errors()
.expect { it.errorType == ErrorType.NOT_FOUND }
}
}
💡 Практические советы
✅ Best Practices
Schema Design:
- Используйте nullable поля разумно
- Проектируйте схему под потребности клиентов
- Версионируйте через deprecation
- Используйте Connection pattern для pagination
Performance:
- Всегда используйте DataLoader
- Ограничивайте глубину запросов (max 10-15)
- Мониторьте query complexity
- Кэшируйте на уровне resolver'ов
Security:
- Отключите introspection в продакшене
- Используйте query whitelisting
- Валидируйте все input'ы
- Ограничивайте rate limiting
❌ Anti-patterns
Архитектурные:
- 1:1 маппинг GraphQL типов на DB таблицы
- Отсутствие DataLoader'ов
- Слишком глубокие nested запросы
- Использование для file uploads
Производительность:
- Игнорирование N+1 проблемы
- Отсутствие query complexity analysis
- Синхронные resolver'ы для I/O операций
- Кэширование на GraphQL уровне вместо HTTP
🔧 Мониторинг
Метрики для отслеживания:
- Query execution time
- Query complexity score
- DataLoader hit ratio
- Error rate by query type
- Subscription connection count
Инструменты:
- GraphiQL — интерфейс для разработки
- Apollo Studio — мониторинг и аналитика
- GraphQL Voyager — визуализация схемы
- Altair — GraphQL клиент
🔌 WebSockets
Шпаргалка для Java/Kotlin разработчика
🎯 Основы WebSockets
Что такое WebSockets?
WebSocket — протокол полнодуплексной связи поверх TCP. Позволяет клиенту и серверу обмениваться данными в реальном времени после установления соединения.
Ключевые концепции
Компонент | Описание | Назначение |
---|---|---|
Handshake | HTTP Upgrade запрос | Установление WebSocket соединения |
Frame | Единица передачи данных | Text, Binary, Control frames |
Session | Соединение клиент-сервер | Управление состоянием соединения |
Endpoint | Точка подключения | Server/Client endpoint |
Message Handler | Обработчик сообщений | OnOpen, OnMessage, OnClose, OnError |
STOMP | Messaging protocol | Структурированные сообщения поверх WebSocket |
WebSockets vs HTTP vs SSE
Критерий | WebSockets | HTTP | SSE |
---|---|---|---|
Дуплексность | Full-duplex | Request-Response | Server → Client |
Overhead | Низкий (после handshake) | Высокий (headers) | Средний |
Real-time | Отличный | Плохой (polling) | Хороший |
Сложность | Высокая | Низкая | Низкая |
Кэширование | Нет | Да | Нет |
Firewall/Proxy | Проблемы | Нет проблем | Нет проблем |
Автореконнект | Ручной | Не нужен | Автоматический |
Когда использовать WebSockets?
✅ Подходит для:
- Чаты и мессенджеры
- Real-time игры
- Live обновления (биржевые котировки)
- Collaborative editing (Google Docs)
- IoT и sensor data
- Live notifications
❌ Не подходит для:
- Простых CRUD операций
- File uploads/downloads
- SEO-critical контента
- Односторонних уведомлений (лучше SSE)
- RESTful API
📚 Теория WebSockets
Жизненный цикл соединения
1. Handshake (HTTP Upgrade)
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
2. Communication (Frames)
- Text Frame: UTF-8 текст
- Binary Frame: Бинарные данные
- Control Frames: Ping/Pong, Close
3. Close
- Graceful close (Close frame)
- Connection drop
- Error close
STOMP Protocol
Simple Text Oriented Messaging Protocol — протокол для структурированного обмена сообщениями поверх WebSocket.
CONNECT
accept-version:1.2
host:localhost
SUBSCRIBE
id:sub-0
destination:/topic/chat
MESSAGE
destination:/topic/chat
message-id:007
content-length:11
Hello World
DISCONNECT
receipt:77
Проблемы WebSockets
1. Scaling (Горизонтальное масштабирование)
- Session affinity (sticky sessions)
- Shared state между серверами
- Message broadcasting между узлами
2. Connection Management
- Heartbeat/Ping-Pong для проверки живости
- Graceful shutdown
- Reconnection logic
3. Security
- CSRF атаки
- Origin validation
- Authentication/Authorization
🛠 Spring Boot Setup
Dependencies (build.gradle)
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-starter-security")
// Для STOMP messaging
implementation("org.springframework:spring-messaging")
// Для Redis (scaling)
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")
WebSocket Configuration
@Configuration
@EnableWebSocket
class WebSocketConfig : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(ChatWebSocketHandler(), "/chat")
.setAllowedOrigins("*") // В продакшене указать конкретные домены
.withSockJS() // Fallback для старых браузеров
}
}
STOMP Configuration
@Configuration
@EnableWebSocketMessageBroker
class StompConfig : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(config: MessageBrokerRegistry) {
// In-memory broker для /topic, /queue
config.enableSimpleBroker("/topic", "/queue")
// Внешний broker (RabbitMQ, ActiveMQ)
// config.enableStompBrokerRelay("/topic", "/queue")
// .setRelayHost("localhost")
// .setRelayPort(61613)
config.setApplicationDestinationPrefixes("/app")
config.setUserDestinationPrefix("/user")
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS()
}
}
🔧 WebSocket Handler
Raw WebSocket Handler
@Component
class ChatWebSocketHandler : TextWebSocketHandler() {
private val sessions = ConcurrentHashMap<String, WebSocketSession>()
private val logger = LoggerFactory.getLogger(ChatWebSocketHandler::class.java)
override fun afterConnectionEstablished(session: WebSocketSession) {
val userId = getUserId(session)
sessions[userId] = session
logger.info("WebSocket connection established for user: $userId")
// Отправляем приветственное сообщение
session.sendMessage(TextMessage("""{"type": "welcome", "message": "Connected"}"""))
}
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
val userId = getUserId(session)
val payload = message.payload
logger.info("Received message from $userId: $payload")
// Парсим сообщение
val chatMessage = objectMapper.readValue(payload, ChatMessage::class.java)
// Обрабатываем сообщение
when (chatMessage.type) {
"chat" -> broadcastMessage(chatMessage, userId)
"typing" -> broadcastTyping(chatMessage, userId)
else -> logger.warn("Unknown message type: ${chatMessage.type}")
}
}
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
val userId = getUserId(session)
sessions.remove(userId)
logger.info("WebSocket connection closed for user: $userId, status: $status")
}
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
val userId = getUserId(session)
logger.error("WebSocket transport error for user: $userId", exception)
sessions.remove(userId)
}
private fun broadcastMessage(message: ChatMessage, senderId: String) {
val response = ChatResponse(
id = UUID.randomUUID().toString(),
senderId = senderId,
content = message.content,
timestamp = Instant.now()
)
val jsonMessage = objectMapper.writeValueAsString(response)
sessions.values.forEach { session ->
if (session.isOpen) {
try {
session.sendMessage(TextMessage(jsonMessage))
} catch (e: IOException) {
logger.error("Failed to send message to session", e)
}
}
}
}
private fun getUserId(session: WebSocketSession): String {
return session.attributes["userId"] as? String ?: "anonymous"
}
}
📨 STOMP Messaging
Message Controller
@Controller
class ChatController(
private val messagingTemplate: SimpMessagingTemplate,
private val chatService: ChatService
) {
@MessageMapping("/chat.sendMessage") // /app/chat.sendMessage
@SendTo("/topic/public") // Broadcast всем подписчикам
fun sendMessage(chatMessage: ChatMessage): ChatMessage {
return chatService.saveMessage(chatMessage)
}
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
fun addUser(chatMessage: ChatMessage, headerAccessor: SimpMessageHeaderAccessor): ChatMessage {
// Добавляем username в WebSocket session
headerAccessor.sessionAttributes?.put("username", chatMessage.sender)
return chatMessage
}
// Отправка сообщения конкретному пользователю
@MessageMapping("/chat.private")
fun sendPrivateMessage(message: PrivateMessage) {
messagingTemplate.convertAndSendToUser(
message.recipientId,
"/queue/private",
message
)
}
// Периодические обновления
@Scheduled(fixedRate = 5000)
fun sendPeriodicUpdates() {
val update = SystemUpdate(
timestamp = Instant.now(),
activeUsers = chatService.getActiveUsersCount()
)
messagingTemplate.convertAndSend("/topic/updates", update)
}
}
Event Listeners
@Component
class WebSocketEventListener(
private val messagingTemplate: SimpMessagingTemplate
) {
@EventListener
fun handleWebSocketConnectListener(event: SessionConnectedEvent) {
val username = getUsernameFromEvent(event)
logger.info("User connected: $username")
}
@EventListener
fun handleWebSocketDisconnectListener(event: SessionDisconnectEvent) {
val username = getUsernameFromEvent(event)
if (username != null) {
val chatMessage = ChatMessage(
type = MessageType.LEAVE,
sender = username
)
messagingTemplate.convertAndSend("/topic/public", chatMessage)
logger.info("User disconnected: $username")
}
}
private fun getUsernameFromEvent(event: AbstractSubProtocolEvent): String? {
return event.message.headers[SimpMessageHeaderAccessor.SESSION_ATTRIBUTES]
?.let { it as Map<*, *> }
?.get("username") as? String
}
}
🔒 Security
WebSocket Security Configuration
@Configuration
class WebSocketSecurityConfig {
@Bean
fun webSocketMessageBrokerConfigurer(): WebSocketMessageBrokerConfigurer {
return object : WebSocketMessageBrokerConfigurer {
override fun configureClientInboundChannel(registration: ChannelRegistration) {
registration.interceptors(authenticationInterceptor())
}
}
}
@Bean
fun authenticationInterceptor(): ChannelInterceptor {
return object : ChannelInterceptor {
override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)
if (StompCommand.CONNECT == accessor?.command) {
val token = accessor.getNativeHeader("Authorization")?.firstOrNull()
if (token != null && jwtTokenProvider.validateToken(token)) {
val principal = jwtTokenProvider.getPrincipal(token)
accessor.user = principal
} else {
throw IllegalArgumentException("Invalid token")
}
}
return message
}
}
}
}
Origin Validation
@Component
class WebSocketOriginInterceptor : HandshakeInterceptor {
private val allowedOrigins = setOf("https://myapp.com", "https://app.mycompany.com")
override fun beforeHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
val origin = request.headers.origin
return allowedOrigins.contains(origin)
}
override fun afterHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
exception: Exception?
) {
// Логирование
}
}
🧪 Testing
WebSocket Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebSocketIntegrationTest {
@LocalServerPort
private var port: Int = 0
private lateinit var stompSession: StompSession
@BeforeEach
fun setup() {
val sockJsClient = SockJsClient(listOf(WebSocketTransport(StandardWebSocketClient())))
val stompClient = WebSocketStompClient(sockJsClient)
stompClient.messageConverter = MappingJackson2MessageConverter()
val url = "ws://localhost:$port/ws"
stompSession = stompClient.connect(url, TestStompSessionHandler()).get()
}
@Test
fun `should receive broadcast message`() {
// Given
val messageHandler = TestMessageHandler()
stompSession.subscribe("/topic/public", messageHandler)
// When
val chatMessage = ChatMessage(
type = MessageType.CHAT,
content = "Hello World",
sender = "testUser"
)
stompSession.send("/app/chat.sendMessage", chatMessage)
// Then
await().atMost(5, TimeUnit.SECONDS).until {
messageHandler.receivedMessages.isNotEmpty()
}
val received = messageHandler.receivedMessages.first()
assertThat(received.content).isEqualTo("Hello World")
assertThat(received.sender).isEqualTo("testUser")
}
@AfterEach
fun cleanup() {
stompSession.disconnect()
}
}
class TestStompSessionHandler : StompSessionHandlerAdapter() {
override fun handleException(session: StompSession, command: StompCommand?, headers: StompHeaders, payload: ByteArray, exception: Throwable) {
exception.printStackTrace()
}
}
class TestMessageHandler : StompFrameHandler {
val receivedMessages = mutableListOf<ChatMessage>()
override fun getPayloadType(headers: StompHeaders): Type = ChatMessage::class.java
override fun handleFrame(headers: StompHeaders, payload: Any?) {
receivedMessages.add(payload as ChatMessage)
}
}
🚀 Scaling & Production
Redis для масштабирования
@Configuration
class RedisWebSocketConfig {
@Bean
fun redisMessageBroker(): MessageBrokerRegistry.() -> Unit = {
enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("redis-server")
.setRelayPort(6379)
.setClientLogin("app")
.setClientPasscode("password")
}
@Bean
fun redisTemplate(): RedisTemplate<String, Any> {
val template = RedisTemplate<String, Any>()
template.connectionFactory = jedisConnectionFactory()
template.setDefaultSerializer(GenericJackson2JsonRedisSerializer())
return template
}
}
Connection Management
@Component
class WebSocketConnectionManager {
private val activeConnections = ConcurrentHashMap<String, WebSocketSession>()
private val userSessions = ConcurrentHashMap<String, MutableSet<String>>()
fun addConnection(userId: String, sessionId: String, session: WebSocketSession) {
activeConnections[sessionId] = session
userSessions.computeIfAbsent(userId) { ConcurrentHashMap.newKeySet() }.add(sessionId)
}
fun removeConnection(sessionId: String) {
activeConnections.remove(sessionId)
userSessions.values.forEach { sessions ->
sessions.remove(sessionId)
}
}
fun getActiveUsersCount(): Int = userSessions.size
fun sendToUser(userId: String, message: String) {
userSessions[userId]?.forEach { sessionId ->
activeConnections[sessionId]?.let { session ->
if (session.isOpen) {
session.sendMessage(TextMessage(message))
}
}
}
}
@Scheduled(fixedRate = 30000) // Каждые 30 секунд
fun cleanupClosedConnections() {
val closedSessions = activeConnections.filterValues { !it.isOpen }.keys
closedSessions.forEach { removeConnection(it) }
}
}
💡 Практические советы
✅ Best Practices
Connection Management:
- Реализуйте heartbeat (ping/pong) каждые 30-60 секунд
- Graceful shutdown с уведомлением клиентов
- Автоматический reconnect на клиенте
- Ограничивайте количество соединений на пользователя
Performance:
- Используйте message broker (Redis, RabbitMQ) для scaling
- Batch отправка сообщений
- Компрессия для больших сообщений
- Connection pooling
Security:
- Валидация Origin header
- Authentication через JWT в handshake
- Rate limiting на сообщения
- Input validation и sanitization
❌ Anti-patterns
Архитектурные:
- Хранение бизнес-логики в WebSocket handlers
- Отсутствие error handling
- Игнорирование connection cleanup
- Синхронная обработка в message handlers
Производительность:
- Блокирующие операции в message handlers
- Отсутствие backpressure handling
- Unlimited message queue
- No connection limits
🔧 Мониторинг
Ключевые метрики:
- Active WebSocket connections
- Messages per second (in/out)
- Connection duration
- Error rate
- Memory usage per connection
Инструменты:
- WebSocket King — тестирование WebSocket соединений
- Artillery — нагрузочное тестирование
- wscat — CLI WebSocket клиент
- Chrome DevTools — отладка WebSocket в браузере