JavaRush /Курсы /Java Server /Safe и unsafe операции в HTTP

Safe и unsafe операции в HTTP

Java Server
9 уровень , 1 лекция
Открыта

1. Идея безопасных операций

Когда вы только начинаете знакомство с HTTP, кажется, что всё довольно просто: есть сервер, есть запрос, есть ответ, и если статус 200, то всё хорошо. Но в реальном backend-мире такие наивные ожидания ломаются быстро и с хрустом, как печенька в руках у голодного тестировщика. Запросы повторяются, браузер может обновить страницу, прокси может закэшировать ответ, а вы сами (или ваш коллега) можете «случайно» дернуть один и тот же endpoint дважды — и вот уже данные изменились там, где вы этого не ждали.

Именно поэтому в HTTP существует идея безопасных операций. В очень человеческом смысле это означает: «есть такие вызовы, от которых клиент не ожидает изменения данных на сервере». Это не про «вежливость» и не про «хороший тон». Это про то, что огромная часть инструментов вокруг HTTP (браузеры, кэши, прокси, клиенты, мониторинг) молча предполагают: если операция безопасная, её можно повторить, можно заранее запросить, можно закэшировать — и мир не взорвётся.

А теперь представьте, что вы сделали «хитрый» GET, который на самом деле добавляет книгу в список чтения. Пользователь обновил страницу — книга добавилась ещё раз. Прокси повторил запрос из-за сетевого сбоя — добавилась ещё раз. Вы запускаете smoke-проверку и «чуть-чуть» жмёте на один запрос дважды — а потом удивляетесь, почему у вас в reading list десять одинаковых «Clean Code». Это тот самый момент, когда backend начинает мстить за плохие контракты.

Чтобы почувствовать идею на уровне кода (без сервера, Postman и всего остального), достаточно простого примера со «состоянием» и двумя методами — чтение и изменение:

import java.util.ArrayList;
import java.util.List;

class ReadingListState {
    // Внутреннее состояние (то, что меняется при "небезопасных" операциях)
    private final List<String> items = new ArrayList<>();

    List<String> getItems() {               // "безопасная" операция (чтение)
        // Важно возвращать копию, чтобы вызывающий код не мог поменять список снаружи
        return List.copyOf(items);
    }

    void addItem(String title) {            // "небезопасная" операция (изменение)
        // Любое добавление/удаление/обновление — это изменение состояния
        items.add(title);
    }
}

Метод getItems() ничего не меняет в состоянии списка — он лишь возвращает копию. Метод addItem(String title) меняет состояние. В HTTP мире это различие превращается в правило: «если это чтение — не притворяйся, что это изменение; если это изменение — не маскируй его под чтение».

И здесь важно не склеить эту тему с stateless. Stateless отвечает на вопрос, может ли сервер понять текущий запрос без скрытой истории диалога. Safe/unsafe отвечает на другой вопрос: меняет ли этот запрос состояние ресурса. Поэтому вполне нормальна операция, которая одновременно stateless и unsafe: например, корректный POST, где все данные переданы явно, но результатом всё равно становится изменение ресурса.

2. Признаки безопасной операции

Когда появляется слово «безопасная», мозг новичка часто делает логический кульбит и начинает слышать что-то вроде: «значит, она всегда успешная» или «значит, она не может вызвать ошибку». Увы, нет. Safe — это не про «успешность», а про отсутствие ожидаемого изменения данных.

Если говорить строго прикладно, безопасная операция — это такая операция, после которой клиент имеет право ожидать: «я ничего не испортил и не изменил». При этом сервер может спокойно ответить ошибкой, может не найти ресурс, может упасть — но сама идея безопасной операции говорит о семантике вызова, а не о гарантии результата.

Важно ещё одно: безопасность определяется эффектом, а не «названием» или «атмосферой» метода. Можно написать метод getSomethingVeryImportant(), который внутри удалит половину данных, и формально это будет get…, но по сути это разрушитель мира. Точно так же в HTTP: если вы взяли GET и внутри реально меняете состояние ресурса — вы нарушили смысл, даже если статус возвращаете красивый.

Мини-правило, которое полезно повторять как мантру: «безопасность определяется тем, меняется ли состояние ресурса, а не тем, каким методом вы это назвали».

Посмотрите на небольшую «проверку здравого смысла» в коде — она не про реальную валидацию, а про то, как голова должна мыслить:

class OperationSemantics {

    static boolean isSafe(boolean changesServerState) {
        // Safe == не меняет состояние на сервере
        return !changesServerState;
    }

    public static void main(String[] args) {
        // В примере просто показываем логику: меняем -> unsafe, не меняем -> safe
        System.out.println(isSafe(false));  // true
        System.out.println(isSafe(true));   // false
    }
}

Конечно, в реальности вы не передаёте флаг changesServerState в HTTP-запросе. Но вы, как разработчик, должны уметь честно ответить себе на вопрос: «этот endpoint меняет данные или нет?». Если да — это unsafe. Если нет — это safe.

И вот здесь появляется тонкий нюанс, который часто путает: «а как же логи, метрики, счётчики, время последнего доступа?». Логи и метрики почти всегда считаются допустимым «техническим» побочным эффектом: они не меняют бизнес-состояние ресурса и не должны влиять на то, что клиент увидит в ответе. А вот изменение статуса книги на IN_PROGRESS при просмотре — это уже бизнес-изменение, и оно ломает смысл safe-операции. В пятом разделе мы к этому вернёмся подробнее, потому что именно там обычно живут самые обидные баги.

3. Safe и HTTP-методы

Мы уже знакомы с базовыми методами HTTP, и у вас может возникнуть ощущение, что safe/unsafe — это просто «синоним GET и не-GET». В первом приближении это действительно близко к правде, и именно поэтому тема полезна: она помогает быстро выбирать метод по смыслу. Но важно понимать, что safe/unsafe — это семантика, а методы — инструмент выражения этой семантики.

В классическом прикладном HTTP мышлении обычно считают: GET должен быть безопасным, потому что он выражает чтение. POST, PUT, PATCH, DELETE относятся к операциям изменения данных, поэтому они небезопасные. Это не «каприз стандарта», а способ сделать API предсказуемым для клиентов и для всей инфраструктуры вокруг.

Давайте зафиксируем это в короткой таблице — она не «про всю Википедию», а про наш уровень курса:

HTTP-метод Ожидаемая роль Safe? Комментарий по смыслу
GET чтение да операция не должна менять состояние ресурса
POST создание/действие нет обычно создаёт ресурс или запускает изменение
PUT полная замена нет меняет состояние ресурса (но часто идемпотентен)
PATCH частичное изменение нет меняет состояние ресурса
DELETE удаление нет меняет состояние ресурса (часто идемпотентен)

Теперь привяжем это к нашему домену ReadLater Starter (даже если мы пока не пишем сервер). Представьте, что у нас будет локальный reading list API. Какие операции там есть по-человечески?

Если пользователь хочет посмотреть список книг, это чтение, и оно должно быть safe. Если пользователь хочет добавить книгу, это изменение состояния, и оно unsafe. Если пользователь хочет изменить статус книги, это тоже unsafe. То есть «просмотр» и «изменение» должны быть выражены разными HTTP-методами, чтобы клиент не жил в мире сюрпризов.

Очень показателен антипример — «плохой GET, который делает изменение». Он почти всегда выглядит как «удобный хак», пока не приходит реальность:

class BadApiIdea {
    // Эмуляция состояния "на сервере"
    private int booksCount = 0;

    String getAddBook() {
        // Проблема: GET по смыслу должен читать, а тут есть побочный эффект (изменение)
        booksCount++;
        return "200 OK, count=" + booksCount;
    }
}

Снаружи это выглядит как «просто получить ответ». Но внутри вы меняете состояние. Если кто-то повторит вызов — состояние изменится ещё раз. И вот здесь часто рождаются «призрачные» баги: вроде никто специально ничего не добавлял, но книги появились.

На уровне маршрутизации (опять же, без реального сервера) различие можно представить даже так:

class RouterDemo {

    String route(String method, String path) {
        // В реальном сервере здесь был бы разбор запроса и вызов handler-а.
        // Нам важно увидеть, что смысл операции задаётся связкой method + path.
        if ("GET".equals(method) && "/reading-list".equals(path)) return "list items"; // safe (чтение)
        if ("POST".equals(method) && "/reading-list".equals(path)) return "add item";  // unsafe (изменение)
        return "404 Not Found"; // неправильный маршрут
    }
}

Мы не пишем здесь настоящий HttpServer, не читаем body, не парсим JSON. Это ещё не время. Нам сейчас важно другое: смысл выражается уже на уровне «method + path», и это напрямую связано с тем, безопасна операция или нет.

4. Safe и идемпотентность

Вчера (и раньше) мы уже касались идемпотентности, и здесь есть типичная путаница: «если можно повторить — значит безопасно». Нет. И наоборот: «если безопасно — значит можно повторить». Тоже не всегда. Чтобы не запутаться, полезно держать в голове, что safe и idempotent отвечают на разные вопросы.

Safe отвечает на вопрос: «Операция должна менять состояние ресурса или нет?». Idempotent отвечает на вопрос: «Если я повторю один и тот же запрос несколько раз, эффект будет тот же, что и от одного раза?». Это похоже на разговор про аккуратность и повторяемость: можно быть аккуратным (не менять ничего), а можно менять, но повторяемо (каждый раз приводить к одному и тому же состоянию).

Классическая иллюстрация — DELETE. Удаление — это изменение состояния, значит операция unsafe. Но она часто идемпотентна: если ресурса уже нет, повторный DELETE в идеальном мире «не делает ничего нового» (хотя ответ может отличаться). Точно так же PUT обычно unsafe, но идемпотентен: вы ставите полное состояние ресурса, и повторный вызов приводит к тому же состоянию.

Сведём это в таблицу, которая обычно «лечит мозг» от путаницы:

Метод Safe? Идемпотентен (обычно)? Как это читать по-человечески
GET да да читаем, повторение не должно ничего менять
POST нет нет часто создаёт «ещё один» ресурс при повторе
PUT нет да приводим ресурс к заданному состоянию, повтор не меняет результат
PATCH нет зависит эффект зависит от того, как вы его определили
DELETE нет да удаляем ресурс, повтор обычно не даёт нового эффекта

И маленькая демонстрация «на пальцах» в Java, чтобы почувствовать разницу между «меняет/не меняет» и «повторение»:

class IdempotencyToyExample {
    // Простое "состояние ресурса"
    private String status = "PLANNED";

    void patchToFinished() {            // изменение состояния (unsafe)
        // Любой вызов меняет (или подтверждает) состояние: это не safe
        status = "FINISHED";
    }

    String getStatus() {                // чтение (safe)
        // Чтение не должно менять status
        return status;
    }
}

Если вызвать getStatus() 10 раз — состояние не меняется: safe. Если вызвать patchToFinished() 10 раз — состояние меняется (хотя после первого раза уже «такое же», и поэтому эффект повторения часто считается идемпотентным). Это и есть причина, почему safe и idempotent — разные понятия: одно про изменение, другое про повторяемость результата.

5. Скрытые изменения в safe-операциях

Самая коварная часть темы safe/unsafe в том, что опасность часто прячется не в методе, а в «ну мы тут чуть-чуть добавили…». Именно так появляются API, которые вроде бы «читают», но при этом тайно меняют состояние. А потом разработчик клянётся, что «ничего такого не делал», и, технически, он даже не врёт — он просто «чуть-чуть поправил счётчик». Проблема в том, что для клиента это уже изменение, а для инфраструктуры — нарушение ожиданий.

Давайте отделим два типа побочных эффектов. Есть эффекты чисто технические: логирование, метрики, трассировки, лимиты, корреляционные ID. Клиент обычно не видит их как изменение ресурса. Если вы сделали GET /reading-list и сервер записал лог «кто-то сходил в список чтения», это не ломает семантику чтения.

А есть эффекты, которые меняют бизнес-состояние ресурса или влияют на то, что клиент увидит. Например, вы делаете GET /reading-list/1, а сервер ставит статус книги IN_PROGRESS, потому что «раз открыли, значит начали читать». Это уже бизнес-логика, и она неожиданна. Или вы увеличиваете viewsCount и показываете это число клиенту — тогда повторный GET уже меняет данные, и вызов перестаёт быть безопасным в смысле контракта.

Плохой пример, который выглядит «невинно», но ломает смысл:

class SneakyGet {
    // Счётчик просмотров — это уже часть наблюдаемого состояния, если мы её возвращаем клиенту
    private int views = 0;

    String getReadingList() {
        // Ключевая проблема: GET-запрос начинает менять состояние ресурса
        views++;
        return "views=" + views;
    }
}

Почему это плохо? Потому что теперь повторный запрос меняет наблюдаемое состояние. Клиент может получить разные ответы просто потому, что кто-то обновил страницу. Даже если вам кажется, что это «полезная статистика», вы должны понимать: с точки зрения семантики safe-операции вы сделали её unsafe. Иногда бизнес действительно хочет счетчики просмотров — это нормально. Но тогда вы честно признаёте: «у чтения есть эффект», и вы должны очень аккуратно жить с тем, что повторение теперь не “ничего не меняет”.

Ещё один классический случай — «GET, который что-то чинит». Например, вы «на лету» исправляете неконсистентные данные, удаляете мусор, запускаете миграцию, подчищаете коллекции. Всё это — изменения. И если вы делаете это на чтении, вы создаёте систему, где один клиент случайно становится «админом, который чинит базу», просто открыв страницу. Это весело ровно до первого большого инцидента.

Самое практичное правило тут звучит не очень романтично, но спасает от хаоса: если вы пишете обработчик GET, держите в голове вопрос «какие поля/коллекции/состояния я реально меняю в этом коде?». Если ответ «что-то меняю» — остановитесь и подумайте, действительно ли это должно быть чтение.

6. Сценарий ReadLater: чтение и изменение

Сейчас мы ещё не реализуем наш ReadLater Starter как сервер, и тем более не пишем полноценный CRUD. Но это отличный момент, чтобы заранее поставить правильные «рельсы» в голове, потому что позже будет соблазн сделать «быстрее и проще», а быстро и просто обычно заканчивается тем, что вы дебажите собственный код в 2 часа ночи и философски смотрите в окно.

В нашем домене есть очень понятные действия: посмотреть список книг, посмотреть одну книгу, добавить книгу, обновить книгу, поменять статус, удалить книгу. Если вы мысленно раскладываете их на safe и unsafe, вам становится легче сразу выбрать метод и не пытаться «сэкономить» на семантике.

Вот как это обычно выглядит в здоровом API (без деталей, просто смысл):

Действие пользователя Пример endpoint-а Safe? Почему
Посмотреть список GET /api/v1/reading-list да чтение, не меняем состояние списка
Посмотреть одну запись GET /api/v1/reading-list/1 да чтение конкретного ресурса
Добавить запись POST /api/v1/reading-list нет создаём новый ресурс, меняем состояние
Полностью обновить PUT /api/v1/reading-list/1 нет меняем ресурс (даже если повторяемо)
Поменять статус PATCH /api/v1/reading-list/1/status нет частично меняем ресурс
Удалить запись DELETE /api/v1/reading-list/1 нет удаляем ресурс

Чтобы на уровне «кода в голове» это не было абстракцией, можно снова вернуться к простому состоянию и операциям:

import java.util.ArrayList;
import java.util.List;

class ReadingList {
    // Храним состояние списка "на сервере"
    private final List<String> items = new ArrayList<>();

    List<String> list() {                 // safe: только чтение
        // Возвращаем копию, чтобы чтение точно не давало наружу возможность модификации
        return List.copyOf(items);
    }

    void add(String title) {              // unsafe: изменение
        // Любое добавление меняет состояние списка
        items.add(title);
    }
}

Если кто-то вызывает list() хоть сто раз — список не изменится, и это соответствует ожиданию от GET. Если кто-то вызывает add(...) — список меняется, и это соответствует ожиданию от POST. Это кажется банальным, но именно на таких «банальностях» строится предсказуемость API.

А теперь — маленькая «ментальная проверка» для будущего. Представьте, что вам очень хочется сделать «удобный» endpoint вида: GET /api/v1/reading-list/add?title=Clean%20Code. С точки зрения человека это звучит как «ну я же просто дергаю URL в браузере, удобно». С точки зрения HTTP-контракта это катастрофа: обновление страницы превращается в повторное добавление. Любой инструмент, который считает GET безопасным и может повторить его (или закэшировать странным образом), будет работать против вас. Поэтому «удобно» здесь — ловушка, а не преимущество.

7. Safe не равно успешный ответ

Ещё одна частая ловушка — ожидание, что safe-операции обязаны быть «всегда зелёными». Мол, если GET, то должен быть 200, иначе «что-то не так с сервером». Но HTTP устроен честнее: чтение может не найти ресурс, чтение может быть сформировано неверно, чтение может упасть из-за внутренней ошибки. И это всё нормальные (хоть и неприятные) ситуации.

Если пользователь запрашивает книгу с id 999, которой нет, сервер может вернуть 404 Not Found. Это не делает операцию unsafe — она по-прежнему чтение. Просто чтение не нашло данные. Если пользователь передал в id не число, можно вернуть 400 Bad Request. Это снова не про безопасность, а про корректность запроса. И даже 500 не превращает чтение в «изменение» — он говорит о том, что сервер не смог обработать запрос.

На уровне кода «безопасная операция может ошибиться» выглядит так:

class SafeButNotAlwaysSuccessful {

    String getItemById(long id) {
        // Валидация входных данных: это не изменение ресурса, но запрос может быть некорректным
        if (id <= 0) {
            return "400 Bad Request";     // запрос неверный
        }
        // Негативный сценарий "ресурс не найден" — это всё ещё чтение
        if (id == 404) {
            return "404 Not Found";       // ресурс не найден (условно)
        }
        // Позитивный сценарий "нашли ресурс"
        return "200 OK";                  // ресурс найден
    }
}

Это, конечно, игрушечный пример, но он фиксирует важную мысль: safe/unsafe — это про эффект на данные, а статусы — про результат обработки.

И здесь всплывает ещё одна важная вещь: статус есть только у запроса, который вообще дождался ответа. Для чтения отсутствие ответа обычно означает, что мы просто не получили результат. Для изменяющей операции картина неприятнее: клиент уже не знает, сервер ничего не сделал или успел поменять данные, а ответ потерялся по дороге. Поэтому safe/unsafe — это не только про красивую семантику метода, но и про то, насколько болезненной становится неопределённость, когда сеть ведёт себя плохо.

8. Типичные ошибки при работе с safe/unsafe

Эта тема выглядит простой ровно до тех пор, пока вы не начинаете проектировать или реализовывать реальные endpoint-ы. Тогда внезапно выясняется, что «безопасность операций» — это не теория, а ежедневная дисциплина: каждый раз, когда вы выбираете метод или добавляете «маленький побочный эффект», вы либо укрепляете контракт, либо делаете будущую отладку интереснее (в плохом смысле). Ниже — ошибки, которые встречаются чаще всего у новичков.

Ошибка №1: использовать GET для операций, которые меняют данные, “потому что так проще дергать из браузера”.
Такое решение выглядит удобным на старте, но очень быстро превращает повторный запрос в «повторное изменение». Обновление страницы, повтор после сетевого сбоя, случайный двойной клик — и данные уже не там, где ожидалось. В backend-мире «проще» почти всегда означает «дороже потом».

Ошибка №2: считать, что safe-операция обязана возвращать только 200 OK.
Безопасная операция может вернуть 404, если ресурс отсутствует, может вернуть 400, если запрос некорректен, и может вернуть 500, если сервер сломан. Safe не означает success. Если вы начинаете мыслить “GET = всегда 200”, вы будете неправильно интерпретировать нормальные негативные сценарии.

Ошибка №3: путать безопасность и идемпотентность.
DELETE обычно идемпотентен, но он небезопасный, потому что меняет состояние. PUT часто идемпотентен, но тоже небезопасный. А POST обычно небезопасный и неидемпотентный. Если смешать эти понятия, вы начнёте принимать неправильные решения о повторных запросах и о том, какой метод «можно» использовать.

Ошибка №4: добавлять “невинные” бизнес-побочные эффекты в чтение.
Логи и метрики — это одно, а изменение статуса, счетчики просмотров, автопочинка данных и другие бизнес-изменения — другое. Когда GET начинает менять наблюдаемое состояние ресурса, клиенту становится труднее понимать систему, а инфраструктура вокруг HTTP перестаёт вам помогать и начинает мешать.

Ошибка №5: “маскировать” небезопасную операцию под безопасную красивым статусом.
Иногда разработчик делает unsafe-операцию, но возвращает 200 OK и думает, что всё в порядке. Статус отвечает за результат обработки, а не за семантику операции. Если вы поменяли состояние — это unsafe, даже если «всё прошло успешно». Клиенту важна предсказуемость, а не косметика.

1
Задача
Java Server, 9 уровень, 1 лекция
Недоступна
Чтение размера очереди без побочного эффекта
Чтение размера очереди без побочного эффекта
1
Задача
Java Server, 9 уровень, 1 лекция
Недоступна
Аудит семантики операций
Аудит семантики операций
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ