2017-09-17

О MongoDB

После долгого перерыва я снова столкнулся с MongoDB. Не по своей воле.
В этом перерыве я тыкал разное. InfluxDB, чтобы понять, что Graphite, точнее Whisper, нифига не устарел, и вполне имеет право на жизнь. ClickHouse, чтобы окончательно решить, что для такого рода данных, когда нужно хранить разовые события, привязанные ко времени, а потом делать по ним разную хитрую аналитику, я буду использовать только ClickHouse. Но больше всего возился со старым добрым PostgreSQL, интенсивно заюзывая его модный jsonb.
MongoDB logo
А вот теперь пришлось снова тыкать MongoDB. В которую пришлось складывать эти самые разовые события, привязанные ко времени, и делать аналитику.
Историческими судьбами в этой Монге сложилась странная схема данных. Есть три коллекции. В одну события складываются как есть, но живут они там только четыре часа. Появилась в Монге такая возможность, индексы с TTL, что сильно удобнее старых capped collections.
В другую коллекцию складываются типа агрегированные данные, сгруппированные по одному из измерений, и собранные за пять минут. На каждые пять минут, на каждое уникальное значение этого измерения — создаётся новый документ. А в нём массив, который пополняется новыми событиями, которые в течение этих пяти минут возникнут.
В третью коллекцию складываются типа агрегированные данные, сгруппированные по другому измерению. Но собранные за неделю. Так же, в массиве. Почему за неделю, неизвестно. Видимо, потому, что размера монгодокумента хватает, чтобы хранить события за всю неделю для каждого уникального значения измерения.
В Монге по-прежнему искусственно ограничен размер документа. И это по-прежнему 16 мегабайт. Помните, зачем? Потому что операции над документами атомарны. И при передаче по сети шестнадцати мегабайт ещё можно сделать вид, что это происходит «атомарно».
Я сразу забраковал эту схему, потому что она подразумевает постоянные апдейты документов. А старый движок Монги, который теперь называют MMAPv1, модифицировал документы «на месте». Если было место, дописывал на месте. Если не было места, ему приходилось копировать документ в конец коллекции и дописывать там. А чтобы место было, он отводил для документа на диске место с запасом. Поэтому этот движок был чудовищно жаден до диска. И очень не любил, когда документы не просто модифицировались, а ещё и увеличивались в размере. Ну прям наш случай.
Над этим движком смеялись все кому не лень. Все разработчики настоящих баз данных. Ибо это были просто memory mapped файлы. В плане скорости это давало некоторые преимущества. Ибо на диске это BSONы, ОС маппит эти BSONы в память, а движку БД остаётся только кидать BSONы из памяти в сокеты и обратно. Но в плане надёжности сохранения данных это было никак. Там, конечно, прикрутили потом журнал. Но это не особо помогло.
И вот теперь, уже вполне солидно, официально и по дефолту у Монги работает другой движок — WiredTiger. Он уже вполне похож на настоящую БД. Он — версионник, MVCC. Это значит, что он никогда не апдейтит «на месте», для него, что insert, что update — одна фигня. Он жмёт данные на диске. Очень хорошо жмёт, кстати. У него есть свой самостоятельный кэш, а не только дисковый кэш ОС.
У WiredTiger тоже есть журнал. Впрочем, журнал всё равно сбрасывается на диск по таймеру. И если в операции записи вы выражаете желание дождаться записи в журнал, ваш запрос действительно будет ждать следующего тика скидывания журнала на диск, и только потом завершится.
«Связанный тигр» — существенный прогресс MongoDB как настоящей базы данных. Рекомендую, если приходится иметь дело с Монгой.
WiredTiger advantages
BSON. Продолжаю восхищаться этим изобретением ребят из 10gen. Все разработчики БД, которые, на волне хайпа по NoSQL, добавляют поддержку JSON. Не надо, прошу вас. Добавляйте поддержку BSON.
JSON — это текстовая фигня с сомнительной эффективностью хранения и парсинга, и совсем никакой поддержкой нормального набора типов данных. Там нет целых чисел, и тем более long. Там совсем-совсем невозможно нормально хранить бинарные данные. А бинарные данные у вас всегда будут. Оно вам надо, каждый раз эту фигню парсить и сериализовывать на входе и на выходе, даже если у вас внутри это как-то мегоэффективно хранится, как в jsonb?
BSON — это нормальный эффективный бинарный формат. Тут для каждого значения хранится длина, а значит, можно эффективно пробежаться по документу и извлечь только то, что надо. Тут есть нормальные int и long, и массивы байтов, и timestamp. Как правильно замечает Википедия, какой-нибудь ProtoBuf может быть эффективнее. Но в ProtoBuf нужна схема. А BSON гибок как JSON.
BSON example
Кажется, Монга научилась использовать несколько индексов одновременно. Но, похоже, предпочитает по старинке брать лишь один индекс для выполнения данного запроса. Дело в том, что индексы, точнее планы запросов, по-прежнему выбираются методом честного соревнования. Если Монга не помнит, какой план запроса был лучшим, она гоняет все возможные планы, и выбирает самый быстрый, и запоминает его на будущее. Просто. Тупо. Эффективно?
Aggregation framework возмужал. Когда-то это была лишь упрощённая и ограниченная замена map-reduce. Но теперь это довольно мощная штука. Заметно мощнее обычных запросов, но почти такая же эффективная. Судя по тому, с какой скоростью в новых версиях Монги добавляют сюда новые возможности, скоро с aggregation framework можно будет делать всё.
Чаще всего aggregation pipeline собирается из $match, $project и $group.
$match — это аналог where. Здесь можно задать условия выборки документов из коллекции. Тут работают индексы, если они есть.
$project — это аналог выражений после select, проекция. Тут всё значительно мощнее, чем в проекции обычного find(). Можно не только включать и исключать поля. Можно высчитывать любые выражения по содержимому документа. Например, можно взять, и сделать $filter и даже $map по содержимому массива в документе.
$group — это аналог group by. По данному ключу (а в частности, по ключу null, т.е. для всех документов) можно собрать агрегацию: сумму, минимум, максимум, всё такое.
Есть и другие интересные операторы. $unwind позволяет развернуть массив в виде последовательности документов. Примерно так, как это может делать PostgreSQL со своими массивами. $lookup позволяет вытащить из другой коллекции целый документ и воткнуть сюда. По сути, это join.
Все эти милые операторы собираются именно что в pipeline. Выхлоп предыдущего шага является входом следующего шага. Можно строить длинные цепочки преобразований, чтобы в конце получить лишь одно число, как оно часто и нужно в этих агрегациях :)
Всё это достаточно мощно, что люди всерьёз пишут конверторы из SQL в монговские aggregation pipeline.
Aggreration pipeline
Схема данных, как всегда, имеет значение. Не обольщайтесь schemaless, это вовсе не thoughtless для разработчика. В данном случае засада оказалась в количестве «горячих» документов.
С коллекцией номер раз всё понятно. Мы туда просто инсертим, и всё. Никакие документы не модифицируются.
С коллекцией номер два сложнее. Здесь в течение пяти минут интенсивно обновляются одни и те же документы. Сколько их, определяется мощностью множества значений того измерения, по которому происходит группировка. В данном случае — сотни. Практические замеры показали, что за пару часов модифицируются лишь тысячи документов. Несмотря на относительно большой размер этих документов — десятки килобайт — они все прекрасно помещаются в кэш размером меньше полугигабайта. Поэтому проблем с апсертами в эту коллекцию не было.
Проблемы случились с коллекцией номер три. Агрегаты за неделю, по второму измерению. Средний размер документа тут маленький — килобайты. Хотя встречаются документы, вплотную приближающиеся к заветным 16 мегабайтам. Но вот мощность множества значений этого второго измерения — миллионы. Миллионы «горячих» документов, килобайтного размера. Это, по-хорошему, уже нужен кэш в несколько гигабайт.
Памяти добавили, ядер добавили, IOPS добавили. А не помогало.
Оказалось, что я избаловался акторами и прочими прелестями асинхронной обработки. Даже наша фиговина, которая должна была в эту самую Монгу пихать события, хоть и принимала эти события через один сокет в одном потоке, потом вовсю юзала ThreadPoolExecutor, раcпихивала задания по очередям, а потом выполняла в столько потоков, в сколько нужно.
А вот MongoDB, как, впрочем, и большинство других баз данных, включая PostgreSQL, — не такая. Она — синхронная. Одно подключение — один поток, который выполняет все запросы в этом подключении строго последовательно.
И оказалось, что апдейты этой третьей коллекции по неизвестной причине (возможно из-за необходимости распаковывать и запаковывать документы) очень жрут CPU на сервере. Вставляем через одну коннекцию — упираемся в один поток на сервере, который выжирает лишь одно ядро CPU. И всё медленно.
Ок. Сделаем четыре потока на нашей клиентской стороне, нам же всё равно, у нас же ExecutorService. Получаем четыре коннекции к Монге. А там — четыре потока на четырёх ядрах CPU, которые занимаются тем, чем они хотят заниматься. Работает? Работает. Проблема решена. Тупой подход: «Тормозит? Добавь потоков!» — сработал.
Ну, конечно, есть нюансы, которые решились по ходу дела. По-хорошему, нужно не давать разным потокам на Монге лезть в одни и те же документы. Чтобы не порождать конфликтов и блокировок. Делается это переупорядочиванием входящих событий и грамотным созданием заданий в ExecutorService.
ClickHouse logo
И всё-таки, для этой задачи я бы взял ClickHouse. Прям руки чешутся. Зачем нужны все эти предварительные агрегации? Зачем хранить данные в трёх экземплярах? Если можно за приемлемое время быстренько прогрепать всё, что было за указанный период.
Для сравнения, те же самые события мы, ради Истории, решили записывать в логи и складывать в архив. CPU совсем прохлаждается, памяти не нужно, IOPSов тоже не нужно, ибо последовательная запись в один поток. Нужно место на диске. И тут по компактности WiredTiger побеждает gzip :) Подозреваю, ClickHouse уделает обоих. А извлекать данные будет сильно удобнее, чем с помощью zgrep.

2017-09-03

О JWT

А как вы ограничиваете доступ к вашему API?
Понятно, что в нашей аутсорсной разработке, когда это приватное API какого-то сервиса внутри конторы заказчика, можно понаставить огненных заборов, и вообще сделать API доступным только из приватной сети.
А если это публичное, да и к тому же многопользовательское API? Как вы аутентифицируете и авторизуете пользователей? Это ведь API, тут нет формы логина.
API
Тупой, но вполне для начала работающий способ: Basic Authentication. Можно даже взгромоздить проверку паролей на тот же Nginx.
Но, простите, о каких пользователях идёт речь? Это же API. Это не пользователи. Это некие программные агенты стучатся к вам в интересах пользователя.
Поэтому, чтобы не смущать себя эфемерными пользователями и их паролями, клиентам API выдают некие токены. Токен, в данном случае, некая строка, идентифицирующая данного клиента, а иногда и права, выданные клиенту.
Login form
Вопрос первый: где и как получать токены?
На эту тему сломано немало копий. Но, похоже, в случае веб-сервисов, побеждают разные варианты OAuth.
В OAuth для выдачи токена требуются явное участие и согласие юзера, в интересах которого агент будет действовать. Для этого задействуется веб-браузер, а пользователя явно просят в этом браузере по-человечески авторизоваться на сервисе, который будет предоставлять API. Т.е. на вашем сервисе.
Веб-браузер — хорошо. Для агентов, которые сами являются веб-сервисами. Для агентов, которые локальные приложения, вызов веб-браузера выглядит странно, особенно для консольных приложений, но вполне работает.
Если у вас не публичный веб-сервис, а просто бэкенд какого-то вебдванольного приложения, можно, конечно, поступить проще. Просто сделайте метод, который за логин-пароль юзера будет выдавать токен.
OAuth 2.0
Вопрос второй: как передавать токен?
HTTP предоставляет уйму способов. Есть заголовки запроса. Есть параметры запроса. Есть тело запроса (в тот же JSON самого запроса токен добавить). Все варианты имеют место быть.
Более-менее стандартным таки является заголовок Authorization. Причём у него есть тип.
Authorization: <type> <credentials>
Для базовой аутентификации тип — это «Basic». Для передачи токенов в OAuth 2.0 тип — это «Bearer». Ничто не мешает придумать свой «тип», только сначала напишите для этого RFC.
Понятно, что в любом случае токен передаётся открытым текстом. И тот, кто перехватит токен, сможет им воспользоваться, выдав себя за чужого агента. Поэтому, только HTTPS. Шифруемся.
Если у вас не HTTP/HTTPS или не только HTTP/HTTPS, то ничего другого не остаётся, кроме как включить токен одним из параметров запроса (то бишь, в тело запроса). Почему бы и нет.
Authorized
Вопрос третий: где хранить и как проверять токены?
На клиентской стороне, если это браузер, уверенно побеждает LocalStorage. Храните токены там, подсовывайте в запросы к API, и будет вам счастье.
Интереснее, что происходит на серверной стороне. Что API делает с токенами?
Очевидный подход. Пусть токен — это просто случайная строка достаточной длины. Сохраним токен в таблицу (или коллекцию) БД, запишем дату его выдачи (или дату окончания действия), и свяжем с нужными правами нужного пользователя. Всё. Работает. Просто, понятно.
Но на больших масштабах у этого подхода проявляются недостатки. На каждый запрос к API, требующий авторизации, независимо от сути самого запроса, требуется просмотр этой таблицы БД на предмет поиска и проверки токена. Если разных API у нас несколько, и сами API горизонтально отмасштабированы на кучу серверов, обеспечить для всех них актуальный доступ к одной таблице становится не очень простой задачей. К тому же, при миллионах пользователей, токенов может быть ещё больше. Сама таблица может получиться очень не маленькой.
Можно обойтись без всего этого. Пусть сам токен хранит в себе всё, что нужно для идентификации пользователя, выяснения и проверки его прав. Тогда большая таблица не понадобится. Не нужно будет делать в неё запрос. А каждый сервер с API может проверить токен самостоятельно.
А чтобы хитрый пользователь не подделал токен, мы будем его подписывать. В криптографическом смысле. Ключом, известным только серверу (серверам).
JWT and friends
Слава богу, ничего на этом поприще изобретать не надо. Ибо есть стандарт JWT — JSON Web Tokens.
JSON, потому что всякие служебные данные и сведения о пользователе представлены в виде JSON.
Типичный JWT токен выглядит так:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
Расшифровать можете на упомянутом jwt.io.
Это три «слова» в base64 кодировке, разделённые точкой.
Первое «слово» — заголовок. Описывает, какие алгоритмы мы используем для подписи и какого типа содержимое токена используем.
$ echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
{"alg":"HS256","typ":"JWT"}
В данном случае «HS256» означает HMAC SHA256, алгоритм подписи такой. А «JWT» означает, что тело токена — действительно JSON в понятиях JWT.
Второе «слово» — тело токена.
$ echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9" | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true}
Это JSON, который содержит некоторые свойства, которые в JWT называют claims, т.е. утверждения.
Некоторые claims определены стандартом JWT. Они, для компактности, трёхбуквенные. iss, issuer — идентификатор того, кто выдал токен. sub, subject — кому выдан токен, например, это идентификатор пользователя. exp, expiration time — время жизни токена, в виде unix timestamp в секундах. iat, issued at — момент выдачи токена, в виде unix timestamp в секундах.
Остальные claims зависят от данного API. Можно их зарегистрировать, чтобы ни с кем не пересечься. А можно просто набросать любых слов или uri.
Третье «слово» — подпись, в формировании которой используется некоторый секрет.
$ openssl dgst -sha256 -mac HMAC -macopt key:secret -binary \
  <( echo -n "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9"; ) \
  | base64
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ=
Надо же, openssl умеет.
JWS token structure
В мире Java с JWT токенами можно работать, например, с помощью jjwt.
Генерировать токен можно примерно так.
fun generateToken(username: String): String {
    return Jwts.builder()
            .setSubject(username)
            .setExpiration(Date(System.currentTimeMillis() + EXPIRATION_TIME))
            .signWith(SignatureAlgorithm.HS512, SECRET)
            .compact()
}
Поместите это в какой-нибудь контроллер, который отвечает за логин и выдачу токенов. Можно добавить и любые другие Claims при необходимости.
Проверять токены можно с помощью Spring Security, который нужно соответствующим образом сконфигурить.
@Configuration
@EnableWebSecurity
open class WebSecurityConfig : WebSecurityConfigurerAdapter() {

    override fun configure(http: HttpSecurity) {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
                .and()
                // And filter other requests to check the presence of JWT in header
                .addFilterBefore(JWTAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter::class.java)
    }

}
Проверять заголовок «Authorization» будет соответствующий фильтр.
class JWTAuthenticationFilter: GenericFilterBean() {

    private val SECRET = "secret"

    override fun doFilter(request: ServletRequest,
                          response: ServletResponse,
                          filterChain: FilterChain) {
        val authentication = getAuthentication(request as HttpServletRequest)

        SecurityContextHolder.getContext().authentication = authentication
        filterChain.doFilter(request, response)
    }

    private fun getAuthentication(request: HttpServletRequest): Authentication? {
        val token = request.getHeader("Authorization")
        if (token != null) {
            val user = Jwts.parser()
                    .setSigningKey(SECRET)
                    .parseClaimsJws(token.replace("Bearer ", ""))
                    .body
                    .subject

            return if (user != null)
                UsernamePasswordAuthenticationToken(user, null, listOf())
            else
                null
        }
        return null
    }

}
Ну а добраться до аутентифицированного пользователя можно стандартными для Spring Security средствами.
@RestController
open class UserController {

    @RequestMapping("/user")
    fun user(): UserResponse {
        val authentication = SecurityContextHolder.getContext().authentication
        val username = (authentication.principal as? UserDetails)?.username ?: authentication.principal.toString()
        return UserResponse(username)
    }

}
Проверяем.
$ curl http://localhost:8080/user
{"timestamp":1504417496948,"status":403,"error":"Forbidden","message":"Access Denied","path":"/user"}

$ curl http://localhost:8080/login
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTUwNDQyNDcxNn0.a9P1SroxiSTUraFdhYwUMjY0tFdhehIRa-R3oOfuW5Ov0INjKH0bS3b57PLF8rhj3uEYXzcPtplID-ncJkWJZg"}

$ curl http://localhost:8080/user \
    -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTUwNDQyNDcxNn0.a9P1SroxiSTUraFdhYwUMjY0tFdhehIRa-R3oOfuW5Ov0INjKH0bS3b57PLF8rhj3uEYXzcPtplID-ncJkWJZg'
{"usename":"admin"}
Больше примеров и кода можно найти тут и здесь.
Spring
Spring Security, конечно же, уродлив. С нормальным middleware всё выглядит значительно красивее (потому что больше магии).
from flask_jwt import JWT

app = Flask(__name__)

app.config['JWT_AUTH_URL_RULE'] = '/api/v1/auth'
app.config['JWT_EXPIRATION_DELTA'] = timedelta(hours=2)

from app.models import User

def authenticate(username, password):
    # тут отрабатывается /api/v1/auth, по логину паролю извлекаем юзера
    user = User.query.filter(User.username == username).first()
    if user and check_password_hash(user.password, password):
        return user

def load_user(payload):
    # тут извлекаем юзера по телу токена
    user = User.query.get(payload['identity'])
    # тут конечно плохо, что за юзером лезем в базу, лучше его всего в токен засунуть
    return user

jwt = JWT(app, authenticate, load_user)


from flask_jwt import jwt_required, current_identity as user

@app.route('/users/get_user_info')
@jwt_required()
def get_user_info():
    # тут магическим образом юзер (точнее current_identity) стали доступны
    return jsonify(
        user_name=user.username,
        user_id=user.id
    )
Flask
Обратите внимание, что JWT токен не зашифрован. Он только подписан. Такие токены называют JWS — JSON Web Signature. Владелец токена, т.е. сам юзер, вполне может прочитать, что вы там про него храните.
Если вы не хотите лишний раз смущать пользователя, или же хотите уменьшить последствия компроментации токена (одно дело, получить доступ к API и узнать ID данного пользователя, другое дело, узнать email пользователя или даже пароли от других сервисов), то можно и зашифровать тело токена. Такие токены называют JWE — JSON Web Encryption.
Скучные подробности можно почитать тут.
JWE token structure
jjwt не умеет JWE. А вот какой-нибудь сишарпный Jose — умеет.
private string EncodeToken(User user)
{
    var payload = new TokenData
    {
        Id = user.Id,
        Email = user.Email,
        Name = user.DisplayName,
        exp = NowTimestamp() + _tokenTtl
    };
    return Jose.JWT.Encode(payload, _tokenEncryptSecret,
        JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256CBC_HS512, JweCompression.DEF);
}

private User DecodeToken(string token)
{
    var payload = Jose.JWT.Decode<TokenData>(token, _tokenEncryptSecret);
    if (payload.exp < NowTimestamp())
    {
        throw new Jose.IntegrityException("Token expired");
    }
    return new User
    {
        Id = payload.Id,
        Email = payload.Email,
        DisplayName = payload.Name
    };
}
Явисты, не расстраивайтесь. Для Явы тоже всё есть, просто другая библиотека.
JOSE — это Javascript Object Signing and Encryption. Общий термин, объединяющий JWT, JWS, JWE, а также JWK (JSON Web Key). Сначала добавляли буковку S — Simple, потом X — eXtensible, теперь, похоже, наступила мода на J.
JWT is JWS and JWE
Ещё раз. JWT — это самодостаточные токены, которые не требуют обращения к какой-либо БД, чтобы удостовериться, что этот токен был выдан определённому пользователю. Соответственно, их самих нет нужды хранить в какой-либо БД и делать по ним поиск.
Из этого проистекает недостаток: JWT токены почти невозможно отозвать. Ведь нет БД, с которой можно сверяться по вопросам валидности.
Конечно, есть тяжёлая артиллерия. Достаточно сменить ключ, которым подписываются и проверяются токены, на всех серверах. И все имеющиеся в ходу токены автоматически станут невалидными. В крайних случаях можно (и, пожалуй, нужно) поступать именно так.
А для уменьшения последствий компроментации в обычной жизни JWT токены нужно делать короткоживущими. Несколько часов, максимум дней.
Короткоживущие токены — это неудобно. Ведь юзеру придётся при протухании токена перелогиниваться.
Поэтому в OAuth используют два токена. Основной access token, короткоживущий, который может быть JWT, сам в себе содержит нужные данные и может быть проверен без доступа к БД. И refresh token, долгоживущий, может храниться и проверяться в БД, используется для обновления и выдачи нового access token.
Получается, мы можем получить все преимущества самодостаточности JWT, аутентифицируя тысячи запросов без лишнего обращения к БД, и при этом получить долгую, удобную для юзеров, сессию, с временем жизни refresh токена. Если, конечно, обновлять токены будем автоматически.