Информация
- В рейтинге
- Не участвует
- Откуда
- Томск, Томская обл., Россия
- Дата рождения
- Зарегистрирован
- Активность
Специализация
Бэкенд разработчик
Ведущий
C++
Базы данных
NoSQL
Docker
Проектирование архитектуры приложений
Кросс-платформенная разработка
Алгоритмы и структуры данных
Математика
Что касается натса, тоже выбирал его за его низкую latency, и тоже доволен. У нас используется суперкластер: один кластер из 3х нод в амазоне, один такой же в овх. Проблем особых не было, один раз сертификат какой-то только забыли обновить)
Мы пишем микросервисы в основном на плюсах, и с этим есть проблемы. У них есть только си либа, плюсовые только сторонние и скорее мертвы. Я писал свою билиотеку, мне важна была полная асинхронность, и с этим были проблемы. Их сишная либа дает асинхронность либо через libuv, либо через libevent. Обертку-то плюсовую я в итоге написал, добавил туда примитивы для удобной работы с асинхронными операциями, но это была боль, потому что их либа все равно внутри какие-то операции (например, flush) делает синхронно, а выясняется это только опытным путем, когда дедлок ловишь где-то. К счастью, это некоторым образом компенсируется тем, что натсовские разработчики хорошо идут на контакт, с их помощью эти проблемы я либо фиксил, либо они вносили изменения в либу.
Только написал статью, где говорил про то, что про NATS ничего нет на хабре и вуаля) Спасибо)
Да, связаность между сервисами, которые напрямую коннектятся друг с другом, тоже скрывается, и механизмы этого хорошо изучены (service discovery).
По моему мнению, это приводит к усложнению системы, и работать с очередью, которая кроме этого дает и много других преимуществ, важных для меня, предпочтительнее. Например, при использовании очереди мне проще держать сервисы слабо-связанными, с четко определенной ответственностью. Выше в ветке я приводил пример, как чистый grpc может приводить к тому, что сервисы постепенно обрастают функциональностью, которая по-хорошему не часть их ответственности. В своей работе я стараюсь убеждать людей, что новую фунциональность лучше добавлять "сбоку", а не "внутрь" (ну, сервис вообще может хоть один метод реализовывать, но при этом само API остается простым для понимания, т.к. вы все равно смотрите на привычные классы и методы).
Многое говорилось про прозрачность бэкенда, т.е. возможности просмотреть, какие сообщения в нем ходят. Например, проследить все методы (и ответы на них) для какого-то конкретного юзера. Это в высшей степени полезно для отладки и поиска узких мест. С очередью это почти тривиально - вы может подписаться на нужные топики (конечные точки в моей терминологии) - и вуаля, все видно. Как вы сделаете подобное в grpc? Сделать-то можно - например есть jaeger, распределенные логи, и т.д. Но это бремя упадет на ваши сервисы, т.к. им это надо будет отправлять.
Ну и вообще, в любом случае, какую-то очередь, куда ваши сервисы будут паблишить важные ивенты, вы все равно будете использовать в МСА. И grpc эту потребность не решит. В итоге у вас будет grpc + discovery + message queue. А у меня просто message queue. Видно что одна система в общем сложнее устроена, чем другая. Понятно, что эта дополнительная сложность может как-то решаться, но в целом изначально если мы берем очередь за основу, мы в принципе не сталкиваемся с целым классом проблем. Но очевидно, сталкиваемся с другими, которые в свою очередь тоже можно как-то решить.
И тут многое решает опыт использования той или иной технологии. Я вот не могу сказать, что строил полноценную МСА на grpc+discovery, но у нас была похожая реализация со своим протоколом и своим discovery (справедливости ради, значительно хуже, чем grpc). Ее я и рефакторил примерно на то, что описано в статье. Новая система мне нравится значительно больше, поэтому захотелось поделиться этим опытом.
Ну право же, не делать же сетевое взаимодействие синхронным?) Слава богу, есть корутины, которые заставляют асинхронное взаимодействие выглядеть синхронным.
Вообще, правильно ли я вас понимаю, что вы не возражаете против самой концепции RPC поверх очереди сообщений? Что идея и принципы из манифеста кажутся вам допустимыми, но вот формат данных выбран неудачно?
Было бы супер, если бы вы предоставили какой-то чуть более конкретный вариант формата. Поверьте, я без сарказма или негатива, мне вообще интересны эти дискуссии. Но мне бы хотелось чуть более предметного разговора. Взгляните, если не затруднит, на пример API из репозитория. Я в статье рассказал, как гарантирую соблюдения спецификации (есть devtool самописный), и в обсуждении с вами показал, как мы эту спецификацию используем для реализации клиентской библиотеки. Если вы для этого API предложите какое-то свое описание (пусть для какой-то небольшой части) с использованием тех форматов, что вы предлагаете, то мне будет проще взглянуть на систему с вашей стороны. Просто я профдеформированный C++ разработчик, и это безусловно отражается на моем образе мышления (типизация, шаблоны, и т.д.). Вы, как я понимаю, пишите в основном на другом ЯП, где есть свои особенности, популярные свои форматы и т.д. и вы очевидно видите возможности, которые мне неочевидны.
Про интеграционные тесты я написал выше, как бы я их делал. Повторюсь, я бы для QA команды предоставил инструмент, который позволяет указать набор методов (параметры + ожидаемый результат), которые он бы потом дергал и проверял. QA сами бы писали тестовые кейсы и запускали их в продакшен и стейдж окружениях.
Про ваши вопросы по ошибкам, у меня к вам встречный вопрос - а что будет с вашими сервисами в этом же случае? Я думаю, что это не вопрос к механизму, по которому сервис получил сообщение, из-за которого завис. Но если отвечать на вопрос, то у нас используется паттерн circuit breaker: вызывающий сервис обнаруживает методы, которые в течение таймаута не возвращают ответ и помечает их как проблемные. При необходимости опять вызвать этот метод, сервис смотрит, есть ли сейчас уже pending вызов. Если нет, то пытается вызвать (вдруг метод починился), а если да, то сразу переходит в обработке ошибки. Это позволяет избежать глобального зависания системы, когда один метод сломался, ответов от него долго ждет один сервис, и пока он ждет, он сам блокирует другой сервис и т.д.
Да, есть ошибки, которые связаны именно с очередью и отсутствуют при прямом соединении сервисов - например, упомянутое вами переупорядочивание сообщений. Не знаю, насколько вас удовлетворит такой ответ, но мое мнение - не надо так делать. Не надо писать API, в котором какие-то операции должны выполняться в какой-то строгой последовательности, это плохо пахнет в любой архитектуре.
Вообще, в целом про то, как я работаю с ошибками. Я их разделяю на две группы: критичные для пользователя и некритичные. Рассмотрим две ошибки:
Пользователь купил подписку, деньги списались, но подписка не применилась.
Пользователь изменил что-то в профиле, но изменения не сохранились
Первую я считаю критичной, вторую нет. Система должна гарантировать насколько возможно отсутствие критичных ошибок, но может допускать ошибки второй группы. Разумеется, это не значит, что можно мириться с тем, что каждый второй запрос пользователя на изменения профиля не обрабатывается, но определенный фейл-рейт допустим. Когда он вырастает до состояния, когда пользователи или отдел QA начинают высказывать неудовольствие (писать в ваш саппорт, или лично вам во внутренней переписке), становится понятно, что с ошибкой пора что-то делать.
Это не значит, что я вообще закрываю глаза на некритичные ошибки. По отношению к ним используется такая идеология:
в рамках штатной работы системы, они не должны происходить
в каких-то критических ситуациях (баги, ddos и т.д.) они могут происходить
Для гарантии, что при штатной работе системы некритичные ошибки не происходят, я реализую сервисы так:
любой сервис запускается минимум в двух экземплярах
сервисы перезапускаются по одному
при получении сигнала на перезапуск, сервис вначале закрывает все подписки на свои методы (вся нагрузка начинает уходить на оставшийся сервис), потом ждет, когда уже принятые вызовы будут обработаны и ответ отправлен
Почему я разделяю ошибки? Потому что гарантия надежности не дается бесплатно. Что для вас лучше, чтобы latency вашего RPC механизма было 10мс, но один из 100 000 пакетов терялся, или чтобы у вас latency было 500мс, но этот пакет бы не потерялся. Стоит ли для всего вашего API использовать механизм с максимальной надежностью? Возможно, в ваших проектах да, но мне не доводилось работать над настолько критичной инфраструктурой. В моей практике подавляющее большинство операций не нуждается в таких жестких гарантиях надежности, и нецелесообразно обеспечивать их без разбора для всех частей API. Если говорить про пример выше, то для подписок использовалась перзистентная кафка и какие-то дополнительные скрипты для мониторинга
В остальном я не то, чтобы хочу с вами спорить или доказывать, что то, как я делаю, это единственно правильный подход. Я довольно давно занимаюсь бэкенд разработкой, и вроде избавился от категоричности и безапелляционности. Вы предлагаете грамотные и хорошие решения, невозможно отрицать, что их многие успешно используют. И с помощью них можно добиться и того, что важно для меня. Я считаю, что с очередью мои задачи решаются удобнее, но их совершенно точно можно решить и вашей схеме. Например, мне нужен прозрачный бэкенд, где я могу отследить любое сообщение, и очередь мне это дает. Но вы всегда сможете мне возразить и сказать, "да я просто jaeger прикручу, распределенные логи, elasticsearch и kibana". И будете правы - будет еще и красиво. Но это работает и вашу сторону) По сути ваши замечания (спасибо за них, кстати) - это повод поискать для них решение, а не прийти к выводу, что это вообще не юзабельно.
Подумал над вашим вопросом о тестировании. Я бы предложил создать несколько профилей тестирования для отдельных блоков функциональности вашего проекта (например, логин пользователя, изменения профиля и т.д.). Каждый профиль тестирования состоит из набора json с параметрами методов и ожидаемых результатов. Писать такие профили сможет ваш qa отдел. Относительно не сложно создать инструмент, который будет брать json из этого профиля, с помощью protobuf либы преобразовывать его в параметры метода, коннектиться в очередь и отправлять туда вызов с этими параметрами, проверяя итоговый результат. Но повторюсь, в целом ваш вопрос больше к МСА в целом, вероятно вы найдете массу других практик и рекомендаций.
Микросервисная архитектура наверное возможна, смотря что ею называть. Вы не могли бы привести пример, как бы вы решили ту задачу, которую я в примере привел, если у вас механизм взаимодействия между микросервисами grpc?
Да может, конечно. Можно на L3/L4 модели OSI сделать балансировку, тогда и сервис не понадобится. С очередью сообщений это правда все сразу есть.
API busrpc-бэкенда, если смотреть на сгенерированную документацию, ничем не отличается от API какой-нибудь ООП либы. Сколько времени вам нужно для того, чтобы понять работу ООП либы? А сколько, чтобы начать ее использовать? Я бы хотел, чтобы для моих коллег процесс работы с API бэкенда напоминал работу с обычной библиотекой.
Что искать? API документировано в одном месте - в директории busrpc проекта. Для каждого метода можно увидеть, какими сервисами он вызывается или реализуется, вместе со ссылкой на их репозиторий. Ну если вам удобнее, можно выгрузить все репозитории в одну папку.
Я не занимался плотно этим вопросом, конкретного ответа не могу дать. Мы обходились юнит-тестами и как-то хватало. Мы не ракету в космос запускаем, и нам достаточно было запускать просто несколько инстансов каждого сервиса на случай какого-то падения. Но вообще это в целом вопрос к микросервисной архитектуре, не конкретно к моей платформе.
Да, ну и с кондовостью в конечном счете можно бороться. Посмотрите на код выше - там в целом все норм, никаких костылей не торчит.
Вы так говорите, как будто это что-то плохое)
Если я вас правильно понял, то вы намекаете на то что, что один вид связности я заменил другим? Я не соглашусь с вами. Связность топика и метода статична, а значит может быть где-то захардкодена и скрыта. А связи между сервисами динамичны, ведь ip и port сервисов могут меняться. Ну или потребуется какой-то discovery механизм, который будет находить сервис.
В busrpc мой код выглядит примерно так (обратите внимание, что топик там вообще нигде не фигурирует и вычисляется внутри реализации):
Жаль, что вы так это видите, что это все ради протабафа) Нет, конечно, привычный инструмент сериализации здесь не при чем. Приобрели мы (в большей или меньшей степени, т.к. мир не идеален) то, что описывается в манифесте, вот прям по пунктам.
Вообще, когда я начинал заниматься этой архитектурой, я думал о том, что мне хотелось бы получить на выходе. А хотелось мне API, которое формулируется в терминах обычного ООП API (чтобы любой джуниор за полчаса примерно понял, что есть в системе), при этом чтобы методы классы были реализованы на разных ЯП разными сервисами, запущенными как докер-контейнер в k8s. И естественно, чтобы была high availability (т.е несколько инстансов сервиса) и возможность простого масштабирования ( поднял еще один инстанс и готово). Я знаю, что "микро" не про размер в LOC, но мне приятно самому писать, и при необходимости разбираться в сервисах, которые содержат 300-400 строк кода и находятся в отдельном репозитории. Из-за того, что эти сервисы типовые (законнектился в очередь и собственную БД, подписался на методы, которые реализуешь, все), работа над новым начиналась с того, что разработчик просто клонировал репозиторий существующего, удалял из него файл с реализацией методов и писал новый. Можно было бы сделать темплейтный репозиторий, но в гитлабе это платная фича, решили обойтись до поры.
Заметьте, очередь сообщения в этой архитектуре необходимый компонент. Чуть ниже я отвечал, почему мне не походит grpc и аналогичные механизмы, которые основываются на p2p взаимодействии между сервисом и тем, кто его вызывает. Поэтому, я не хотел брать условный SOAP+WSDL+UDDI, или какой-нибудь OpenApi - Swagger.
Таким образом, мне надо было поверх очереди прикрутить каким-то образом стилистику ООП и RPC. Кондовость, упомянутая вами, является следствием того, что мой основной инструмент - очередь сообщений - вообще говоря не дает из коробки мне то, что я хочу. Я разумеется посмотрел не одну и не две очереди, перед тем как браться за работу, но нашел в них только базовые механизмы для организации модели request-reply, ни тебе ООП, ни RPC. Пришлось что-то придумывать.
В нашей с вами дискуссии мне пока видится так, что у вас инструмент первичен, а архитектура вторична (ну, вот есть wsdl, есть soap, есть масса инструментов для них, значит будем как-то его прикручивать). Я в целом-то согласен, что это грамотный подход (особенно с точки зрения бизнеса, которому платить за кастомные инструменты и библиотеки), но мне вот довелось в кои то веки поставить архитектуру на первое место, а инструменты уж потом для нее разработать.
Справедливый вопрос. У нас преимущественно использовался C++, и протобаф для этого языка наиболее простой и часто используемый способ описать формат данных, а потом получить типы, которые можно использовать для сериализации/десериализации.
Кроме того, библиотека protobuf предоставляет возможность делать с этими сгенерированными данными очень многое (чтобы было нужно для красивой реализации внутренней клиентской библиотеки), а C++ с помощью шаблонов и концептов позволяет еще добиться довольно понятного API в клиентской библиотеке (пишу по памяти, могут быть ошибки):
Вероятно, я не сумел понятным образом донести идеи, попробую исправиться, отвечая на ваш комментарий.
Итак, как работает grpc? Вы создаете описание сервиса, потом protoc генерит из него код для клиента или сервера. Сервер в итоге будет слушать на некотором порту входящие соединения, а клиент коннектиться на этот порт. Т.е. клиент обязан знать, куда ему коннектиться. Если клиенту нужно вызвать несколько операций, ему нужно законнектиться к нескольким сервисам. Схожим образом устроено множество RPC-like технологий: json-rpc, SOAP, любой REST API на HTTP. Понимаете, куда я веду? Если на бумаге изобразить все взаимодействия между сервисами, получится спагетти. Как это все масштабировать, например, когда понадобится запустить дополнительно еще один инстанс grpc сервера? Переделать все клиенты, чтобы они знали про еще один сервер? Ну, так себе вариант. А! Нам нужен лоад-балансер, спрячем серверы grpc за ним, и все ок! Не забываем добавить еще стрелочек на картинку взаимодействия сервисов. Как думаете, легко будет новым сотрудникам в этом разбираться? А админам следить, где какие порты у вас открыты? А как на работающем проде посмотреть, кто там какие пакеты куда шлет, что у вас юзеры не могут залогиниться?
В том фреймворке, что описывается в статье, сервисы не коннектятся напрямую друг к другу. У каждого сервиса ровно один коннект - до очереди сообщений - поэтому конфигурация тривиальна. Когда сервис вызывает метод, он понятия не имеет, где и кем он реализован, а балансировка нагрузки выполняется самой очередью. При использовании очереди сообщений в качестве промежуточного уровня появляется возможность сделать сервисы максимально слабо-связанными, потому что они могут паблишить в очередь какие-то важные ивенты, не зная, кто, когда и как их будет обрабатывать (может они и вообще никому не нужны в данный момент). Такие сервисы элементарно запустить как докер-контейнер в k8s. Их работу легко мониторить, т.к. весь траффик проходит через одну точку - очередь сообщений.
Чтобы не быть голословным, приведу пример продуктовой задачи. От нас потребовали отправлять приветственное сообщение юзеру, который впервые залогинился в систему.
Для начала предположим, что мы используем grpc. В данный момент у нас есть аккуратненький сервис А с четко определенной ответственностью - он обрабатывает логин пользователя (считайте, просто проверяет пароль). Новую задачу мы вероятно реализовали бы одни из следующих способов:
Добавили бы логику проверки на первый логин к сервису А. Для этого ему нужно будет хранить дополнительную информацию в базе, а также научиться коннектиться к другому сервису, который предоставляет метод для отправки сообщения пользователю.
Добавили бы какой-нибудь новый метод вроде isFirstSignIn к сервису A, чтобы какой-то другой сервис мог в некоторый момент времени с помощью него определить, нужно ли высылать приветственное сообщение.
Написали бы новый grpc сервис B, в который добавили бы операцию вроде onUserSignedIn, в которой этот новый сервис проверял бы, не является ли это первым логином юзера, и если да, отправлял бы приветственное сообщение. Сервис А при этом нужно модифицировать, чтобы он вызывал этот новый метод при логине пользователя.
Заметили, как во всех 3х пунктах нам пришлось изменять сервис А? А что его ответственность теперь не только проверка пароля? Имхо, называть такую архитектуру микросервисной - это выдавать желаемое за действительное. Чем дольше существует проект, тем дальше сервис А будет уходить от своей первоначальной простой ответственности.
Как решали эту задачу мы, когда начали делать микросервисы? Когда наш сервис А создавался, после реализации основной операции sign_in, мы предположили, что ивент о том, что юзер залогинен в систему является достаточно полезным, и сразу решили паблишить его в нашу очередь, хоть на тот момент он особо не был нужен (ну, разве что позволял мониторить логины). Когда появилась задача на отправку приветственного сообщения, мы реализовали сервис B, который в своей БД хранит юзеров, которые еще не логинились и слушает ивенты о логине пользователей. Если залогинившийся пользователь есть в табличке, ему высылается сообщение (опять же, это осуществляется с помощью отправки пакета в очередь сообщений) и запись удаляется. Этот сервис делал человек, который вообще ни дня не работал в бэкенд-команде, писал на другом ЯП, и тем не менее он быстро и успешно с ней справился, ведь ему вообще не пришлось залазить в чужой код (в сервис А). Вы можете возразить, что если бы изначально мы не подумали о том, что ивент о логине в систему полезен и не добавили его сразу, то сервис А пришлось бы менять, и будете правы. Однако уже при следующей подобной задаче ивент будет на месте, а значит с большой степенью вероятности ничего менять не придется.
Замечу, что busrpc в этом примере вообще не при чем. Ключевым механизмом МСА является очередь сообщений (по описанным выше причинам), к которой grpc не относится. Это не меняет того, что grpc отличный инструмент, но лично я использую его для других целей, например для реализации API-шлюзов. Busrpc - это просто вариант того, как можно описывать интерфейсы, которые есть в вашей очереди сообщений. Возможно, вам дополнительно будет интересно посмотреть на пример в моем репозитории, который лучше продемонcтрирует, как может выглядеть busrpc API.
Просто хотел больше аналогии с throw/catch из C++. Ну а catch - это не обязательно обработка.
В документации сказано "does not have to have a valid
generatororbinaryDir". Имхо, это переводится как "не обязан иметь валидныйgeneratorилиbinaryDir, а не так, как вы написали. Я еще раз дополнительно проверил - по крайней мере моя текущая версия CMake (3.22.2) корректно обрабатывает hidden пресет с установленнойbinaryDir.Ну если ваша библиотека требует определенного стандарта, то да.
Насчет исполняемых файлов - тут все зависит от того, кто будет его собирать и упаковывать. Если проект опен-сорсный и упаковывать его могут какие-то thrid-party мейнтейнеры, то требования те же, что к библиотекам. Если это что-то проприетарное и вы сами будете деплоить, то на ваше усмотрение. Естественно, если сделаете не очень грамотно, то проблемы могут возникнуть у ваших коллег, если им потребуется собрать ваше приложение на какую-то архитектуру/тулчейн, которые вы сами не проверяли и не использовали. Им тогда придется тоже лезть в ваши CMakeLists.txt.
Ну может когда-нибудь) Пока посмотрите статью Ulrich Drepper, которая есть в ссылках. Она полностью раскрывает все технические детали динамических библиотек в линуксе, может это именно то, что вам нужно. Под винду плюс-минус тоже самое.
Нашел небольшой баг с тем, что MYLIB_SHARED_LIBS не оказывает влияние на сборку библиотеки, т.к. используется после того, как таргет mylib определен. Пофиксил этот баг в репозитории, плюс заменил
PUBLIC_HEADERнаinstall(DIRECTORY). Для общего решение это более универсальный подход (см. комментарий от @Nipheris и мой после него).Файл-сеты мимо меня прошли, а фича интересная, спасибо за информацию. К сожалению, я пока не могу позволить себе заюзать ее, т.к. слишком жесткое требование на минимальную версию будет, что для библиотек не подходит.
С
PUBLIC_HEADERдействительно есть проблемы, когда у вас хедеры раскиданы по разным директориям внутри include/. Вероятно для общего решения мне стоило использовать более традиционный подход - инсталлировать хедеры черезinstall(DIRECTORY).По поводу копирования хедеров в билд директорию. Это позволит упростить инсталляцию, плюс в target_include_directories(PRIVATE) указывать можно будет только путь до билд-директории (если я что-то еще упускаю, то напишите). За это придется заплатить тем, что ваш проект будет переконфигурироваться перед сборкой каждый раз, когда вы вносите изменение в header файлы. А это порой может занимать приличное время (например, при определенном использовании внешних зависимостей через ExtenalProject/FetchContent).
К ответу @Nipheris мне нечего добавить, согласен со всем. И статью по ссылке его обязательно почитайте.
Я file(glob) не использую, хотя какое-то время использовал. Всё-таки нет-нет, но что-то вылазило у меня с ним. Если вы используете, то обратите внимание, что относительно недавно появился в этой функции аргумент CONFIGURE_DEPENDS, который по большому счету решает проблему, за которую file(glob) особо критиковали (ну, ту, что он не переконфигурирует автоматом проект при добавлении файлов).
Признаться мне не доводилось организовывать кросс-компиляция сколько-нибудь серьезных CMake-проектов, поэтому особо делиться нечем. Очевидно, вам понадобится создать тулчейн-файл - это не должно вызвать сложностей. Общие рекомендации те же - никаких захардкоденных настроек в CMakeLists.txt. Собственно, все настройки целевой платформы в тулчейн-файле можно указать, а сам тулчейн файл для красоты добавить в пресет (см. поле toolchainFile). Тогда вы сможете вызывать что-то вроде `cmake .. --preset linux`, `cmake .. --preset android`, `cmake .. --preset win` и т.д.
По зависимостям так не смогу подсказать, мало конкретики) Могу однако порекомендовать посмотреть в сторону новой фичи Cmake (появилась в 3.24) - провайдеры зависимостей (dependency providers). Провайдер зависимости - это по сути просто функция (или, что чаще, макрос), которая перехватывает все вызовы find_package и/или FetchContent и дальше может делать с ними все, что угодно, тем более, что все параметры, с которыми вызывалась find_package/FetchContent в нее передаются. Называется она так по тому, что все-таки в основном предполагается, что делать она будет следующее: дергать какую-то внешнюю команду, которая подтянет каким-то образом зависимость.
Например, ваш провайдер при вызове функции
find_package(Boost 1.77.0)может выполнить (с помощью стандартных средств CMake, например,execute_process) командуconan install boost/1.77.0@ --generator cmake_paths, а потом вызвать уже "обычный"find_package, который найдет только что установленный буст.Вы также можете свой кастомный провайдер сделать, заточенный под свой проект, который будет находить ваши разбросанные зависимости.