Про тонкощі «шифрованого трубопроводу» в процесі розробки IMAP-клієнта на Scala+Akka+Spray

Зовсім недавно я перейшов з улюбленого мною об'єктно-орієнтованого C + + на новий для мене і ще не зовсім зрозумілий функціональний Scala. Причини переходу — зовсім окрема історія. Але однією з них була наявність досить хорошою, судячи з відгуків, підтримки моделі акторів — з допомогою бібліотеки Akka. Я давно мріяв випробувати на власному досвіді всі описувані переваги цієї технології, а існуючі реалізації на C++ (CAF_C++ і Theron), які я трохи покрутив у невеликих тестах, виявилися досить сирими для моїх потреб. Найбільш канонічне ж (на мою думку) рішення моделі акторів — Erlang, — я відкинув, так як вважав, що для його освоєння мені знадобиться дуже багато часу, та й не факт, що я зможу знайти необхідні мені сторонні бібліотеки для цього далеко не універсальної мови. Тому в результаті мій вибір припав саме на Scala в зв'язці з Akka, тим більше що Scala я коли-то давно вже починав вивчати, але закинув за недоцільністю. Однак, як виявилося, на цей раз час для свого експерименту я вибрав не найвдаліший, в чому я переконався тільки після того, як досить солідна частина проекту була вже завершена.

Початок

Розробка із самого початку йшла ударними темпами: майже весь необхідний функціонал, який був потрібен для мого додатки, була в достатку доступна в інтернеті в якості сторонніх бібліотек. А відсутність того, що ще не написано конкретно для Scala, з лишком компенсувалося значним розмаїттям різних компонент для Java. Однак, проблема, як завжди, виникла в самому несподіваному місці.

Справа в тому, що в певний момент мого додатку необхідно підключатися до IMAP-сервера для читання та обробки отриманих поштових повідомлень. А так як модель акторів передбачає асинхронну роботу з мережею, — мені знадобилася така бібліотека, яка змогла б підключатися до сервера і отримувати пошту асинхронно, щоб красиво і органічно вписатися в структуру мого нового додатка. Після недовгих пошуків я натрапив на модуль akka-camel, який дозволяє використовувати бібліотеку apache-camel в якості каналу повідомлень для акторів. А camel, як з'ясувалося, вміє підключатися, в числі іншого, до поштових серверів. Крім того, при вказівці потрібних параметрів підключення, camel може читати тільки свіжі (з прапором \Recent) повідомлення, видаляти прочитані повідомлення, або копіювати/переміщати їх в спеціально створену папку. Про більше я і мріяти не міг. Для початку роботи в SBT всього лише потрібно було згадати в залежностях akka-camel, camel-core і camel-mail.

Перша спроба

Створення актора та підключення його до IMAP-сервера зайняло буквально кілька рядків коду. І ось в лог додатки вивалився текст повідомлення, яке я сам собі відправив на пошту для тіста. Я вже почав задоволено потирати руки і думати над наступним завданням, але вирішив на всяк випадок спробувати підключитися до робочого скриньки, в який буде в результаті приходити пошта для обробки. І ось тут мій реактор викинув виняток і «впав». Як виявилося, він не зміг правильно розпарсити відповідь сервера. В інтернеті жодної інформації про цю помилку і можливі шляхи рішення я не знайшов. І трохи зажурився. Витрачати час на вивчення специфікацій протоколу і написання свого клієнта якось не дуже хотілося. І я, згнітивши серце, в ім'я економії часу, вирішив відступити від наміченого курсу повної асинхронності та використовувати синхронну блокуючу бібліотеку JavaMail. Однак, в тому ж самому місці з тим же самим винятком впала і ця бібліотека. Після цього я вже твердо вирішив, що відмовлятися від ідеалів — шлях для слабаків і ледарів, а я все-таки напишу свого клієнта IMAP, з асинхронностью і акторами. Тим більше, реалізовувати весь IMAP цілком мені було не потрібно, функціонал потрібен був вельми обмежений: авторизація, вибір папки INBOX, отримання списку повідомлень, читання конкретного повідомлення, скопіювати повідомлення в іншу папку і видалення.

Друга спроба

Вибирати, з чого почати, мені особливо не довелося. Як відомо, розробники Akka на певному етапі відмовилися від Netty для мережевого вводу-виводу на користь Spray. Надалі, розробка Akka і Spray настільки тісно переплелася, що навіть документація їх взаємно посилається один на одного, а шматки коду з spray.io плавно перекочували в akka.io. І ось тут-то мене і чекав основний підступ: коли-то, в період розробки версій 2.x, Akka взяла на озброєння використовувану в Spray ідею каналів (вони ж «трубопроводи», вони ж pipelines на англ.), які дозволяють (за твердженням авторів) з легкістю створювати мережеві протоколи, підтримують зворотний тиск — тобто можливість «прикрутити вентиль» щоб «труби не забивалися» на випадок, якщо одержувач не встигає обробляти потік даних від відправника, фільтрувати, ділити, множити дані і що ще з ними не робити. Але щось у цих трубопроводах» пішло не так, і вони, так і не вийшовши із стадії experimental, були оголошені deprecated. Останнім анонсоване нововведення від Akka, мета якого — повноцінно замінити канали, — це «реакційні потоки» (reactive streams), про які вже писали на хабре. Але так як це нововведення все ще в стадії анонсу, то в останній версії akka 2.3.6 його ще немає, а каналів — вже немає. Канали залишилися в spray, але вся документація по них веде на застарілу документацію Akka Версія 2.2.0-RC1, яка вже не відображає всієї нинішньої дійсності. А нова документація по Akka каже, що канали залишилися в Spray. Загалом, перша версія мого поштового клієнта вийшла приблизно схожою на багатостраждальне дитя Франкенштейна — зібраною з різних шматочків розрізненої і місцями суперечливій документації. Від «трубопроводу» я відразу вирішив відмовитися на увазі показавшейся мені захмарною складності цієї концепції, і тому працював мій клієнт безпосередньо з потоком символів від сервера у вигляді ByteString. Точніше сказати, з обривками цього потоку, так як ніхто не гарантує, що цікавить відповідь прийде одним цілісним шматком, або дві відповіді не зліпляться разом. Якимось дивним чином, через купу излитых в монітор матюків і переписаних шматків коду, мені вдалося таки прикрутити до мого актору шифрування SSL/TLS за допомогою декількох, знайдених в різних місцях шматків коду. Код, знайдений мною тільки в якійсь (сильно застарілої) конкретної з безлічі версій офіційної документації працювати відмовився.

З реалізацією кожного наступного етапу свого скромного функціоналу мій клієнт ставав все монструознее. Зрештою, після чергової ітерації, години в 3 ночі, я зрозумів, що далі так жити не можна, з досади написав собі в TODO спробувати все той же блокуючий JavaMail, але через POP3, без можливості переміщення повідомлень по папках, і пішов спати.

Третя спроба

Однак, будучи впертим відморозком, я на наступний ранок (а точніше, в обід), замість спроб приручити JavaMail, першим ділом поліз в исходники Spray на гітхабі. Я витратив кілька днів на їх вивчення та адаптування отриманої інформації під свої потреби, але витрачений час окупилося сторицею. В першу чергу, в исходниках я натрапив на не описаний ніде в документації клас ConnectionHandler, який значно спростив мені і моєму створення життя, розставивши багато по своїх місцях. Саме вивчаючи застосування цього класу в spray-can я зрозумів, яким чином і де можна використати ці самі «трубопроводи», про яких з документації я з'ясував тільки те, які завдання вони покликані вирішувати, але не вони це роблять. Там же, в исходниках, я виявив, як можна з'єднувати труби» — тобто об'єднувати кілька «труб» (стадій) пайплайна (PipelineStage) в один загальний «трубопровід», до чого це веде, і як це передбачається використовувати. І ще я з'ясував, чому і як саме працює прикрученное мною напередодні SSL-шифрування, яке до цього моменту залишалося для мене чорним ящиком, який «просто працює і не треба лізти».

Просвітлення

Для тих, кому цікаві подробиці: «трубопровід» складається з частин, в оригіналі вони називаються «стадіями» або «етапами» (англ. stages), але я їх буду називати «трубами» для підтримки образності. Об'єднуються ці «труби» в коді за допомогою оператора >>, порядок має значення. Першими йдуть «труби», найбільш «близькі» до клієнта, останніми — до сервера. Тобто, все, що йде від клієнта — проходить «трубопроводу» зліва направо, від сервера — навпаки, справа наліво. Приміром, «труба», що здійснює шифрування, вказується останньої, тому все, що посилає в «трубопровід» клієнт, спочатку проходить всі необхідні трансформації, і тільки потім шифрується, і на сервер в результаті відправляються шифровані дані. І навпаки, все, що надсилає на сервер спочатку розшифровується, потім трансформується рештою «трубопроводу». Для чого взагалі потрібна вся ця сантехніка? Для найрізноманітніших речей. Наприклад, для фільтрації посилаються або одержуваних даних. Або для трансформації одних сутностей в інші, що корисно при реалізації протоколів. Скажімо, існує якийсь case class DeleteMessage(id: String). Клієнт посилає в «трубопровід» примірник DeleteMessage(«23»), і на одній із стадій (в одній з «труб») цей клас перетворюється в зрозумілу сервера команду «a001 STORE 23 +FLAGS.SILENT (\Deleted)». Ще «труби» можуть затримувати доставку даних, якщо, наприклад, відповідь від сервера неповний, і очікується додаток.

Основний момент, який спочатку зовсім збив мене з пантелику, — це наявність концептуальних понять «подія» (Event) і «команда» (Command), і відповідне розбиття трубопроводу на два: подієвий (event pipeline) і командний (command pipeline) у межах одного класу: PipelineStage. Саме ці не поняті мною на самому початку концепції (ну мануали ж, тим більше такі розрізнені і незрозумілі, дочитують до кінця тільки невдахи, нормальні хлопці відразу йдуть напролом і набивають шишок) змусили мене подумати про трубопроводах погане і вирішити для себе, що це занадто складно і не варто витраченого часу. Мені здалося, що це якось пов'язано з тим самим «зворотним тиском», яке обов'язково доведеться враховувати та реалізовувати, хоча мені воно зовсім не потрібно. І це на додачу до того, що я взагалі спочатку не розумів, куди й одну «трубу» «встромляти», і як у неї щось засунути, щоб це дійшло до сервера. І як потім з неї витягнути відповідь. А тут цих труб ще й дві виявилося. З іншого боку, якщо б не це непорозуміння — я б не відчув повною мірою всієї могутності «сантехнічного» підходу після винаходу свого маленького чудовиська. Насправді, ідея виявилася настільки простий, що мені стало навіть смішно: Event — це те, що приходить від сервера клієнту, Command — це те, що йде від клієнта на сервер. Труба в результаті опинилася одна, просто всередині вона сама для себе розділяє два зустрічних потоку, щоб не заплутатися, що куди звідки йде.

Результат

Загалом, в результаті моїх досліджень у мене з'явився новий клас, який відповідає за з'єднання з IMAP-сервером, який прийняв такий короткий і лаконічний вигляд:

class Connection(client: ActorRef, remoteAddress: InetSocketAddress, sslEncryption: Boolean, connectTimeout: Duration)(implicit sslEngineProvider: ClientSSLEngineProvider) extends ConnectionHandler { actor =>
override def supervisorStrategy = SupervisorStrategy.stoppingStrategy
def tcp = IO(Tcp)(context.system)

log.debug("Attempting connection to {}", remoteAddress)
tcp ! Tcp.Connect(remoteAddress)//, timeout = Some(Duration(connectTimeout, TimeUnit.SECONDS)))
context.setReceiveTimeout(connectTimeout)

val pipeline = eventFrontend >> ResponseParsing() >> SslTlsSupport(512, publishSslSessionInfo = false)
override def receive: Receive = {
case connected: Tcp.Connected =>
val connection = sender()
connection ! Tcp.Register(self, keepOpenOnPeerClosed = sslEncryption)
client ! connected
context.watch(connection)
context.become(running(connection, pipeline, pipelineContext(connected)))
case Tcp.CommandFailed(_: Tcp.Connect) =>
throw new ConnectionFailure(1, "Failed to connect to IMAP server")
case ReceiveTimeout =>
log.warning("Connect timed out after {}", connectTimeout)
throw new ConnectionFailure(2, "Connect timed out")
}

def eventFrontend = new PipelineStage {
def apply(context: PipelineContext, commandPL: CPL, eventPL: EPL): Pipelines = new Pipelines {
val commandPipeline: CPL = commandPL
val eventPipeline: EPL = {
case event => client ! event
}
}
}

def pipelineContext(connected: Tcp.Connected) = new SslTlsContext {
def actorContext = context
def remoteAddress = connected.remoteAddress
def localAddress = connected.localAddress
def log = actor.log
def sslEngine = if (sslEncryption) sslEngineProvider(this) else None
}
}


Цей клас я отримав спрощенням класу spray.can.client.HttpClientConnection. Він успадковується від spray.io.ConnectionHandler, а той, у свою чергу — від akka.Actor. Тобто, він являє собою звичайного актора. По суті — це сантехнік, назвемо його Станіславом. Станіслав відповідає за трубопровід від клієнта до сервера і доставку даних по цьому трубопроводу, туди і назад. Він прокладає цей трубопровід при ініціалізації актора (тобто, його самого) стандартним context.actorOf(...). Властивість pipeline — і є той самий трубопровід, зібраний у даному випадку з трьох труб — фронтенда, парсера відповідей від сервера і SSL/TLS-шифровальщика. І тепер, всі дані, надіслані сантехніку Станіславу (звичайною посилкою йому, як актору, повідомлення оператором !, методом tell або будь-якими іншими доступними способами), він дбайливо складе в трубу і відправить на сервер. А все, що прийде в відповідь від сервера, так само дбайливо дістане з труби і відішле клієнту. Ось такий він, наш працьовитий хлопець Стасик.

Що стосується використаних мною «труб».

SslTlsSupport — це реалізована в Spray стандартна можливість підключення SSL/TLS-шифрування. Вимагає особливого контексту (повертається методом pipelineContext), а також вимагає підтримувати клієнтську сторону з'єднання відкритою, навіть після того як сервер закриє з'єднання зі свого боку (т.зв. напіввідкритий з'єднання).

ResponseParsing — це написаний вже мною об'єкт з функцією apply(), яка повертає примірник «труби», що відповідає за парсинг — розбір потоку символів від сервера (у вигляді «сирих» повідомлень Tcp.Received) на case-класи конкретних відповідей, розуміються й оброблювані вже цільовим актором (яким і є мій IMAP-клієнт). На парсер також покладено обов'язок слідкувати за цілісністю повернутих даних: чекати додаткових даних, якщо відповідь від сервера не повний, а також відокремлювати кілька відповідей один від одного, якщо вони прийшли одним шматком. Це дуже сильно розвантажило код мого клієнта, який тепер жахливого клаптикового монстра перетворився на простого, зрозумілого, прямого і нехитрого хлопця Василя, нерозлучного друга нашого аккуратиста Стасика (ще б ним не дружити, Стас просто прийшов і купу брудної роботи взяв на себе). Маса тестів, необхідних для підтримки Васіної працездатності, теж помітно поуменьшилась.

Нарешті, eventFrontend — функція, що повертає примірник «труби» — PipelineStage, суть якої полягає в одному: передавати всі «події» (тобто, дані, що пройшли по всьому трубопроводу від сервера, і вже зазнали всі необхідні зміни) клієнту, тобто Васі, адреса якого Станіслав знає завдяки переданої в конструкторі класу змінної.

Якогось спеціального рендеринга команд я не робив, за відсутністю такої необхідності. Всі команди надсилаються на сервер з допомогою простого Tcp.Write.

Епілог

Ось, власне, і вся сантехніка. В якості епілогу можу сказати, що сам клієнт представляє з себе кінцевий автомат (Finite state machine), заснований на Akka.FSM. Я просто закохався в реалізацію цієї концепції в Akka, так як написання автомата та юніт-тестів для нього — це така собі захоплююча міні-гра.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.