Як я замінив легасі Java-проксі на mitmproxy: абстракція і Claude Code для паралельної розробки
Привіт, DOU.
Я senior software engineer у Sombra, працюю переважно з Java і TypeScript. У цій статті розповім, як ми замінили застарілий proxy сервіс — від проблеми до архітектурного рішення і про те, як я організував паралельну розробку за допомогою Claude Code.
Старий компонент, якого всі бояться
Майже в кожному проєкті є такий компонент, до якого ніхто не хоче торкатися. «Якщо працює — не чіпай» У нас таким сервісом був проксі.
Його завдання просте: інші сервіси відправляли йому REST-запити, він проксіював HTTP-трафік, записував його у файл метаданих і повертав результат назад.
Але всередині все трималося на BrowserUp Proxy, Java-бібліотеці, яка вже кілька років не отримує жодних оновлень. У нашому коді накопичились обхідні рішення для проблем, які бібліотека ніколи не виправить. А кожна нова функціональність означала ще більше коду до цієї непідтримуваної бази.
Ми вирішили перейти на mitmproxy, і саме тут спливла проблема.
Проблема — не в заміні бібліотеки
На перший погляд, рішення очевидне, але BrowserUp живе усередині JVM тоді, як mitmproxy працює як окремий процес. Фактично треба було замінити не просто бібліотеку, а всю модель роботи сервісу.
Як зробити так, щоб інші компоненти системи цього навіть не «помітили»? Саме цей фактор вирішив усю подальшу архітектуру.
Спочатку інтерфейс — потім реалізація.
Ми створили інтерфейс ProxyManager, що включає три методи:
- beginRequest (ініціалізує сесію з поточною конфігурацією),
- endRequest (зупиняє запис і повертає метадані сесії),
- destroy (очищає ресурси).
Ось і все, контролер працює тільки із заданим інтерфейсом, і деталі реалізації для нього не важливі. А ось уже конкретні класи несуть відповідальність за ці деталі: для BrowserUp — це створення потоків, а для Mitmproxy — це запуск дочірнього процесу.
Саме ця межа дозволяє нам запускати обидві реалізації паралельно під час міграції і порівнювати результати.
Безпека міграції
Після того, як інтерфейс уведено, написали фабрики, які продукують конкретні реалізації. Це дозволило гнучко контролювати вибір певної реалізації на трьох рівнях:
- для конкретної сесії;
- для конкретного завдання;
- глобально для системи в цілому.
Завдяки цьому в день релізу система продовжила працювати так само, як і раніше. Гнучкість вибору способів керування дозволила включати нове проксі поступово, без змін коду. Також вона спростила відкат при виникненні проблем. У подальшому такий підхід спростить відключення старого проксі завдяки наявності глобальної конфігурації.
Як реалізовано MitmproxyManager
Метод beginRequest серіалізує конфігурацію сесії у тимчасовий JSON-файл, потім запускає mitmdump CLI із додатковими аргументами:
- шлях для конфігурації;
- список усіх аддонів;
- шлях для майбутнього файлу метаданих;
- додаткові аргументи.
Mitmdump завантажує Python-аддони при запуску. Кожен аддон реалізує окремий аспект функціоналу, необхідний для сесій, тільки конфігурація є спільною для них.
Метод endRequest відправляє сигнал завершення процесу, чекає, поки mitmdump збереже файл метаданих сесії на диск, і читає його для того, щоб віддати контролеру.
Метод destroy — це примусове завершення дочірнього процесу і видалення тимчасових файлів.
Інші сервіси як і раніше відправляють ті ж HTTP-виклики. Складність керування дочірнім процесом повністю інкапсульована всередині MitmproxyManager.
Саме це розмежування дозволило використовувати Claude Code
Для того, щоб перенести всю функціональність BrowserUp на mitmproxy, треба написати знову більше десятка різних можливостей: header injection, basic auth, NTLM, PDF handling, cookie management, content filtering, debug capture і т.д.
Кожен аспект функціоналу мав тести і реалізацію на Java з багатьма винятковими випадками, зібраними за багато років у production. Це все треба було написати на Python у вигляді аддонів для mitmproxy.
Це б зайняло не один тиждень роботи, тому я вирішив використати Claude Code для паралельної реалізації.
Як я організував роботу для делегування
Зі свого досвіду я вже зрозумів, що AI-агенти добре справляються із завданнями, що сформульовані точно, зрозуміло. Вони «не розуміють» оціночних суджень: їх жодна специфікація не фіксує.
Тому питання планування, яке я постійно піднімав: які частини завдання я можу описати достатньо точно, щоб агент реалізував їх без уточнень?
Відповідь сформувала структуру PR. Два foundation PR встановили спільну інфраструктуру: інтерфейс, фабрику, контролер, схему конфігурації сесії, патерн завантаження аддонів. Ці два мали мерджитися першими — кожен наступний PR залежав від встановленого ними патерну.
Після їх мерджу feature PR могли виконуватися в довільному порядку. Кожен фокусувався на одному Python-аддоні, своїх полів у спільній конфігурації та своїх тестів.
Foundation PR — це місце, де я доклав найбільше зусиль. Все у подальшому відбувалося паралельно, але тільки за умови достатньо чітко поставленої задачі.
Три аспекти, що зробили делегування можливим
1. Письмові специфікації для кожної фічі
Перед передачею будь-якого feature PR я писав короткий документ: що робить аддон, які поля він читає з конфігурації сесії, які виняткові випадки, що оригінальний Java-фільтр робить у кожному if з нотатками про те, де поведінка mitmdump відрізняється. Не дизайн документ, але достатньо, щоб ціль була конкретною. Розмиті специфікації давали реалізації, які потребували суттєвих доопрацювань. Точні специфікації давали код, з яким можна було працювати далі.
Показовий приклад — Basic auth. Оригінальний Java-фільтр із BrowserUp підключається в Netty pipeline LittleProxy через clientToProxyRequest:
@Override
public HttpResponse clientToProxyRequest(HttpObject httpObject) {
if (!(httpObject instanceof HttpRequest req)) return null;
String host = req.headers().get(HttpHeaderNames.HOST, "");
if (!hostPattern.matcher(host).find()) return null;
String encoded = Base64.getEncoder()
.encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
req.headers().set(HttpHeaderNames.AUTHORIZATION, "Basic " + encoded);
return null;
}
Специфікація для порту на mitmproxy перекладала цю логіку напряму: для кожного запиту слід перевіряти хост за налаштованим патерном та ін’єктувати заголовок. Аддон, що повернувся:
import base64
import re
from mitmproxy import http
from config import SessionConfig
class BasicAuthAddon:
def __init__(self):
self.config: SessionConfig | None = None
self._encoded: str | None = None
def configure(self, updated):
self.config = SessionConfig.load()
if self.config.basic_auth:
auth = self.config.basic_auth
self._encoded = base64.b64encode(
f"{auth.username}:{auth.password}".encode()
).decode()
else:
self._encoded = None
def request(self, flow: http.HTTPFlow) -> None:
if not self._encoded:
return
if self.config.basic_auth.host_pattern and not re.search(
self.config.basic_auth.host_pattern, flow.request.pretty_host
):
return
flow.request.headers["Authorization"] = f"Basic {self._encoded}"
addons = [BasicAuthAddon()]
Різна мова, різний фреймворк, різна модель підключення. Java-фільтр викликається per-request через Netty pipeline LittleProxy; Python-аддон підключається в event-систему mitmproxy. Але поведінкова модель залишається однаковою. Включення Java-коду в специфікацію дозволило AI перехресно посилатися на обидві сторони без того, щоб я коментував кожну гілку.
2. Юніт-тести як критерії прийняття
Кожен feature PR мав тести які AI міг запускати, щоб швидко підтвердити код і при необхідності виявляти помилки та усувати їх — а не за моїм відгуком. Це прискорило зворотній зв’язок та надало більше автономності агентам. Тести для кожного аддону були короткими. Приклад для basic auth:
def test_injects_header():
addon = BasicAuthAddon()
addon.config = _make_config("alice", "s3cr3t")
addon._encoded = base64.b64encode(b"alice:s3cr3t").decode()
flow = _make_flow("example.com")
addon.request(flow)
assert flow.request.headers["Authorization"] == f"Basic {addon._encoded}"
def test_skips_non_matching_host():
addon = BasicAuthAddon()
addon.config = _make_config(host_pattern=r"api\.example\.com")
addon._encoded = base64.b64encode(b"user:pass").decode()
flow = _make_flow("other.com")
addon.request(flow)
assert "Authorization" not in flow.request.headers
def test_no_op_without_auth_config():
addon = BasicAuthAddon()
flow = _make_flow("example.com")
addon.request(flow)
assert "Authorization" not in flow.request.headers
Ці тести я підключив до Gradle через pytest, щоб команда ./gradlew test запускала і Java, і Python тести за одну ітерацію. Кілька аддонів пройшли через кілька раундів ітерацій із реальними помилками, перш ніж я побачив кінцевий результат.
3. E2E-тести на реальному QA-сайті
Верифікація нового аддону означала запуск сервісу вручну, спостереження за його поведінкою, занотовування помилок та передача нотатків для виправлення агентом. Цей процес виявився повільним і потребував мого втручання як механізму зворотнього зв’язку. Це перетворило роботу з паралельної на послідовну. Тому я вирішив розширити тести, додавши E2E шар. Як і unit-тести, це додавало самостійності агентам. До того часу, як я переглядав PR, він вже пройшов кілька ітерацій на реальній поведінці. Якість коду помітно покращилась.
Що вимагало мого втручання
Цей процес не йшов повністю на автопілоті. Три моменти змушували постійно повертатися.
1. Особливості взаємодії процесів
Перетин межі процесів має специфічну поведінку, якої немає в документації і яку я не міг передбачити в специфікації. Тайминг запису файлів метаданих відносно завершення процесу. Граничні випадки того, як mitmdump обробляє специфічні конфігурації. Порядок подій у системі Python-аддонів. Коли це спливало у помилках, я діагностував особисто, а потім або писав виправлення, або переписував відповідну частину специфікації достатньо точно для AI.
2. Рішення про поведінку
Деякі поведінки мали точно відповідати BrowserUp, тому що інші сервіси залежали від специфічних форматів виводу. Інші варто було покращити — адже поведінка BrowserUp у певних випадках була обхідним рішенням, а не фічею. AI не міг прийняти це рішення. Я приймав його, оновлював специфікацію, а AI реалізовував.
3. Взаємодії між PR
Конфігурація сесії є спільною для всіх feature PR. Поле, додане в одному аддоні, може взаємодіяти з логікою в іншому. AI працював над PR ізольовано і не мав цього контексту. Я спостерігав за процесом взаємодії та втручався за необхідності.
Що я узяв би у наступний проєкт
Структура роботи важила значно більше, ніж будь-яка конкретна можливість інструменту. Foundation PR, розбиття по окремому функціоналу, письмові специфікації, юніт-тести, E2E-тести на реальному трафіку — це були рішення, що визначили якість результату.
Коли структура завдання була чіткою, я отримував код, який міг мержити. У протилежному випадку — код, який виглядав правильно, але усе одно потребував переписування.
Найбільше виграла від делегування механічна частина: переклад точної поведінкової специфікації в робочий код, покриття кожного випадку в тестах, ітерація на помилках до проходження тестів. Це значна частина будь-яких зусиль з реалізації.
Робота, що вимагала судження, нікуди не ділась: діагностика особливостей взаємодії процесів; вирішення, де відповідати старій поведінці, а де необхідно її покращити; проєктування основи, від якої все залежить. Вона просто сконцентрувалась там, де це насправді важливо.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів