Русский
Русский
English
Статистика
Реклама

Валидация

Валидация UTF-8 меньше чем за одну инструкцию на байт

06.04.2021 16:12:32 | Автор: admin


Даниэль Лемир профессор Заочного квебекского университета (TLUQ), придумавший способ очень быстро парсить double совместно с инженером Джоном Кайзером из Microsoft опубликовали ещё одну свою находку: валидатор UTF-8, обгоняющий библиотеку UTF-8 CPP (2006) в 48..77 раз, ДКА от Бьёрна Хёрманна (2009) в 20..45 раз, и алгоритм Google Fuchsia (2020) в 13..35 раз. Новость об этой публикации на хабре уже постили, но без технических подробностей; так что восполняем этот недочёт.

Требования UTF-8


Для начала вспомним, что Unicode допускает code points от U+0000 до U+10FFFF, которые кодируются в UTF-8 последовательностями от 1 до 4 байтов:

Число байтов в кодировке Число битов в code point Минимальное значение Максимальное значение
1 1..7 U+0000 = 00000000 U+007F = 01111111
2 8..11 U+0080 = 11000010 10000000 U+07FF = 11011111 10111111
3 12..16 U+0800 = 11100000 10100000 10000000 U+FFFF = 11101111 10111111 10111111
4 17..21 U+010000 = 11110000 10010000 10000000 10000000 U+10FFFF = 11110100 10001111 10111111 10111111

По правилам кодирования, старшие биты первого байта последовательности определяют общее количество байтов в последовательности; нулевой старший бит может быть только у однобайтных (ASCII) символов, единичные два старших бита обозначают многобайтного символа, единичный и нулевой продолжающий байт> многобайтного символа.

Какого рода ошибки могут быть в строке, закодированной таким образом?

  1. Незаконченная последовательность: на месте, где ожидался продолжающий байт, встретился ведущий байт или ASCII-символ;
  2. Неначатая последовательность: на месте, где ожидался ведущий байт или ASCII-символ, встретился продолжающий байт;
  3. Слишком длинная последовательность: ведущий байт 11111xxx соответствует пятибайтной или более длинной последовательности, запрещённой в UTF-8;
  4. Выход за границы Unicode: после расшифровки четырёхбайтной последовательности получился code point выше U+10FFFF.

Если в строке нет ни одной из этих четырёх ошибок, то её можно расшифровать в последовательность корректных code points. UTF-8, однако, требует большего чтобы каждая последовательность корректных code points кодировалась единственным образом. Это добавляет ещё два рода возможных ошибок:

  1. Неминимальная последовательность: для расшифрованного code point возможна более короткая кодировка;
  2. Суррогаты: code points в диапазоне от U+D800 до U+DFFF зарезервированы для UTF-16, и последовательность из двух таких суррогатов обозначает code point выше U+FFFF. UTF-8 требует, чтобы такие code points кодировались напрямую, а не как пары суррогатов.

В редко используемой кодировке CESU-8 последнее требование отменено (а в MUTF-8 ещё и предпоследнее), благодаря чему длина последовательности ограничена тремя байтами, но расшифровка и валидация строк усложняются. Например, смайлик U+1F600 GRINNING FACE представляется в UTF-16 парой суррогатов 0xD83D 0xDE00, и CESU-8/MUTF-8 переводят её в пару трёхбайтных последовательностей 0xED 0xA0 0xBD 0xED 0xB8 0x80; но в UTF-8 этот смайлик кодируется одной четырёхбайтной последовательностью 0xF0 0x9F 0x98 0x80.

Для каждого рода ошибки ниже перечислены последовательности битов, которые к ней приводят:
Незаконченная последовательность Недостаёт 2-ого байта 11xxxxxx 0xxxxxxx
11xxxxxx 11xxxxxx
Недостаёт 3-его байта 111xxxxx 10xxxxxx 0xxxxxxx
111xxxxx 10xxxxxx 11xxxxxx
Недостаёт 4-ого байта 1111xxxx 10xxxxxx 10xxxxxx 0xxxxxxx
1111xxxx 10xxxxxx 10xxxxxx 11xxxxxx
Неначатая последовательность Лишний 2-ой байт 0xxxxxxx 10xxxxxx
Лишний 3-ий байт 110xxxxx 10xxxxxx 10xxxxxx
Лишний 4-ый байт 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx
Лишний 5-ый байт 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
Слишком длинная последовательность 11111xxx
Выход за границы Unicode U+110000..U+13FFFF 11110100 1001xxxx
11110100 101xxxxx
U+140000 11110101
1111011x
Неминимальная последовательность 2-байтная 1100000x
3-байтная 11100000 100xxxxx
4-байтная 11110000 1000xxxx
Суррогаты 11101101 101xxxxx


Валидация UTF-8


При наивном подходе, использованном в библиотеке UTF-8 CPP серба Неманьи Трифуновича, валидация выполняется каскадом вложенных ветвлений:

const octet_difference_type length = utf8::internal::sequence_length(it);// Get trail octets and calculate the code pointutf_error err = UTF8_OK;switch (length) {    case 0:        return INVALID_LEAD;    case 1:        err = utf8::internal::get_sequence_1(it, end, cp);        break;    case 2:        err = utf8::internal::get_sequence_2(it, end, cp);    break;    case 3:        err = utf8::internal::get_sequence_3(it, end, cp);    break;    case 4:        err = utf8::internal::get_sequence_4(it, end, cp);    break;}if (err == UTF8_OK) {    // Decoding succeeded. Now, security checks...    if (utf8::internal::is_code_point_valid(cp)) {        if (!utf8::internal::is_overlong_sequence(cp, length)){            // Passed! Return here.

Внутри sequence_length() и is_overlong_sequence() тоже ветвления в зависимости от длины последовательности. Если во входной строке непредсказуемо чередуются последовательности разной длины, то предсказатель переходов не сможет избежать сброса конвеера по нескольку раз на каждом обрабатываемом символе.

Более эффективный подход к валидации UTF-8 заключается в использовании конечного автомата из 9 состояний: (состояние ошибки на диаграмме не показано)



Когда таблица переходов автомата составлена, то код валидатора получается очень простым:

uint32_t type = utf8d[byte];*codep = (*state != UTF8_ACCEPT) ?  (byte & 0x3fu) | (*codep << 6) :  (0xff >> type) & (byte);*state = utf8d[256 + *state + type];

Здесь для каждого обрабатываемого символа повторяются одни и те же действия, без условных переходов поэтому сбросов конвеера не потребуется; с другой стороны, на каждой итерации осуществляется дополнительный доступ к памяти (к таблице переходов utf8d) впридачу ко чтению входного символа.

Лемир и Кайзер взяли за основу своего валидатора этот же ДКА, и достигли ускорения в десятки раз за счёт применения трёх усовершенствований:

  1. Таблицу переходов удалось ужать с 364 байтов до 48, так что она целиком помещается в трёх векторных регистрах (по 128 бит), и обращения к памяти требуются только для чтения входных символов;
  2. Блоки по 16 соседних байтов обрабатываются параллельно;
  3. Если 16-байтный блок целиком состоит из ASCII-символов то он заведомо корректный, и нет нужды в более тщательной проверке. Этот срез пути ускоряет обработку реалистичных текстов, содержащих целые предложения латиницей, в два-три раза; но на случайных текстах, где латиница, иероглифы и смайлики равномерно перемешаны, это ускорения не даёт.

В реализации каждого из этих усовершенствований есть неочевидные тонкости, так что их стоит рассмотреть подробно.

Уменьшение таблицы переходов


Первое усовершенствование основывается на том наблюдении, что для обнаружения большинства ошибок (12 недопустимых последовательностей битов из 19 перечисленных в таблице выше) достаточно проверить 12 первых битов последовательности:
Незаконченная последовательность Недостаёт 2-ого байта 11xxxxxx 0xxxxxxx 0x02
11xxxxxx 11xxxxxx
Неначатая последовательность Лишний 2-ой байт 0xxxxxxx 10xxxxxx 0x01
Слишком длинная последовательность 11111xxx 1000xxxx 0x20
11111xxx 1001xxxx 0x40
11111xxx 101xxxxx
Выход за границы Unicode U+1[1235679ABDEF]xxxx 111101xx 1001xxxx
111101xx 101xxxxx
U+1[48C]xxxx 11110101 1000xxxx 0x20
1111011x 1000xxxx
Неминимальная последовательность 2-байтная 1100000x 0x04
3-байтная 11100000 100xxxxx 0x10
4-байтная 11110000 1000xxxx 0x20
Суррогаты 11101101 101xxxxx 0x08

Каждой из этих возможных ошибок исследователи присвоили один из семи битов, как показано в самом правом столбце. (Присвоенные биты различаются между их опубликованной статьёй и их кодом на GitHub; здесь взяты значения из статьи.) Для того, чтобы обойтись семью битами, два подслучая выхода за границы Unicode пришлось переразбить так, чтобы второй объединялся с 4-байтной неминимальной последовательностью; а случай слишком длинной последовательности разбит на три подслучая и объединён с подслучаями выхода за границы Unicode.

Таким образом с ДКА Хёрманна были произведены следующие изменения:

  1. Вход поступает не по байту, а по тетраде (полубайту);
  2. Автомат используется как недетерминированный обработка каждой тетрады переводит автомат между подмножествами всех возможных состояний;
  3. Восемь корректных состояний объединены в одно, зато одно ошибочное разделено на семь;
  4. Три соседние тетрады обрабатываются не последовательно, а независимо друг от друга, и результат получается как пересечение трёх множеств конечных состояний.

Благодаря этим изменениям, для описания всех возможных переходов достаточно трёх таблиц по 16 байт: каждый элемент таблицы используется как битовое поле, перечисляющее все возможные конечные состояния. Три таких элемента объединяются по AND, и если в результате есть ненулевые биты, значит, обнаружена ошибка.
Тетрада Значение Возможные ошибки Код
Старшая в первом байте 07 Лишний 2-ой байт 0x01
811 (нет) 0x00
12 Недостаёт 2-ого байта; 2-байтная неминимальная последовательность 0x06
13 Недостаёт 2-ого байта 0x02
14 Недостаёт 2-ого байта; 2-байтная неминимальная последовательность; суррогаты 0x0E
15 Недостаёт 2-ого байта; слишком длинная последовательность; выход за границы Unicode; 4-байтная неминимальная последовательность 0x62
Младшая в первом байте 0 Недостаёт 2-ого байта; лишний 2-ой байт; неминимальная последовательность 0x37
1 Недостаёт 2-ого байта; лишний 2-ой байт; 2-байтная неминимальная последовательность 0x07
23 Недостаёт 2-ого байта; лишний 2-ой байт 0x03
4 Недостаёт 2-ого байта; лишний 2-ой байт; выход за границы Unicode 0x43
57 0x63
810, 1215 Недостаёт 2-ого байта; лишний 2-ой байт; слишком длинная последовательность
11 Недостаёт 2-ого байта; лишний 2-ой байт; слишком длинная последовательность; суррогаты 0x6B
Старшая во втором байте 07, 1215 Недостаёт 2-ого байта; слишком длинная последовательность; 2-байтная неминимальная последовательность 0x06
8 Лишний 2-ой байт; слишком длинная последовательность; выход за границы Unicode; неминимальная последовательность 0x35
9 0x55
1011 Лишний 2-ой байт; слишком длинная последовательность; выход за границы Unicode; 2-байтная неминимальная последовательность; суррогаты 0x4D

Остались необработанными ещё 7 недопустимых последовательностей битов:
Незаконченная последовательность Недостаёт 3-его байта 111xxxxx 10xxxxxx 0xxxxxxx
111xxxxx 10xxxxxx 11xxxxxx
Недостаёт 4-ого байта 1111xxxx 10xxxxxx 10xxxxxx 0xxxxxxx
1111xxxx 10xxxxxx 10xxxxxx 11xxxxxx
Неначатая последовательность Лишний 3-ий байт 110xxxxx 10xxxxxx 10xxxxxx
Лишний 4-ый байт 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx
Лишний 5-ый байт 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

И здесь пригождается старший бит, предусмотрительно оставленный в таблицах переходов неиспользованным: он будет соответствовать последовательности битов 10xxxxxx 10xxxxxx, т.е. двум продолжающим байтам подряд. Теперь проверка трёх тетрад может либо обнаружить ошибку, либо дать результат 0x00 или 0x80. И вот этого результата первой проверки вместе с первой тетрадой нам уже достаточно:
Недостаёт 3-его байта 111xxxxx 10xxxxxx 0xxxxxxx 111xxxxx (0x00)
111xxxxx 10xxxxxx 11xxxxxx
Недостаёт 4-ого байта 1111xxxx 10xxxxxx 10xxxxxx 0xxxxxxx 1111xxxx (x) (0x00)
1111xxxx 10xxxxxx 10xxxxxx 11xxxxxx
Лишний 3-ий байт 110xxxxx 10xxxxxx 10xxxxxx 110xxxxx (0x80)
Лишний 4-ый байт 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx 1110xxxx (x) (0x80)
Лишний 5-ый байт 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx (x) (0x80)
Допустимые комбинации 111xxxxx (0x80)
1111xxxx (x) (0x80)

Значит, для завершения проверки достаточно убедиться, что каждый результат 0x80 соответствует одной из двух допустимых комбинаций.

Векторизация


Как обрабатывать блоки по 16 соседних байтов параллельно? Центральная идея состоит в том, чтобы использовать инструкцию pshufb как 16 одновременных подстановок в соответствии с 16-байтной таблицей. Для второй проверки нужно найти в блоке все байты вида 111xxxxx и 1111xxxx; поскольку на Intel нет беззнакового векторного сравнения, то оно заменяется вычитанием с насыщением (psubusb).

Исходники simdjson тяжеловато читаются из-за того, что весь код разбит на однострочные функции. Псевдокод всего валидатора целиком выглядит примерно так:
prev = vector(0)while !input_exhausted:    input = vector(...)    prev1 = prev<<120 | input>>8    prev2 = prev<<112 | input>>16    prev3 = prev<<104 | input>>24    # первая проверка    nibble1 = prev1.shr(4).lookup(table1)    nibble2 = prev1.and(15).lookup(table2)    nibble3 = input.shr(4).lookup(table3)    result1 = nibble1 & nibble2 & nibble3    # вторая проверка    test1 = prev2.saturating_sub(0xDF) # 111xxxxx => >0    test2 = prev3.saturating_sub(0xEF) # 1111xxxx => >0    result2 = (test1 | test2).gt(0) & vector(0x80)    # в result1 должны быть 0x80 на тех же местах, как и в result2,    # и нули на всех остальных    if result1 != result2:        return false    prev = inputreturn true

Если некорректная последовательность находится у правого края самого последнего блока, то она этим кодом не будет обнаружена. Чтобы не заморачиваться, можно дополнить входную строку нулевыми байтами так, чтобы в конце получился один полностью нулевой блок. В simdjson предпочли вместо этого реализовать особую проверку для последних байтов: для корректности строки нужно, чтобы самый последний байт был строго меньше 0xC0, предпоследний строго меньше 0xE0, и третий с конца строго меньше 0xF0.

Последнее из усовершенствований, придуманных Лемиром и Кайзером это срез пути для ASCII. Определить, что в текущем блоке есть только ASCII-символы, очень просто: input & vector(0x80) == vector(0). В этом случае достаточно убедиться, что нет некорректных последовательностей на границе prev и input, и можно переходить к следующему блоку. Эта проверка осуществляется аналогично проверке в конце входной строки; беззнаковое векторное сравнение с [..., 0xС0, 0xE0, 0xC0], которого нет на Intel, заменяется на вычисление векторного максимума (pmaxub) и его сравнение с тем же вектором.

Проверка на ASCII оказывается единственным ветвлением внутри итерации валидатора, и для успешного предсказания этого ветвления достаточно, чтобы во входной строке не чередовались блоки целиком из ASCII со блоками, содержащими не-ASCII-символы. Исследователи обнаружили, что ещё лучших результатов на реальных текстах удаётся добиться, проверяя на ASCII объединение по OR четырёх соседних блоков, и пропуская все четыре блока в случае ASCII. И действительно: можно ожидать, что если автор текста в принципе пользуется не-ASCII-символами, то они будут встречаться как минимум раз на 64 символа, чего достаточно для предсказания перехода.

Подробнее..

Перевод Как разобрать URL в JavaScript?

13.07.2020 16:22:43 | Автор: admin


Доброго времени суток, друзья!

Представляю Вашему вниманию перевод заметки How to Parse URL in JavaScript: hostname, pathname, query, hash автора Dmitri Pavlutin.

Унифицированный указатель ресурса или, сокращенно, URL это ссылка на веб-ресурс (веб-страницу, изображение, файл). URL определяет местонахождения ресурса и способ его получения протокол (http, ftp, mailto).

Например, вот URL данной статьи:

https://dmitripavlutin.com/parse-url-javascript

Часто возникает необходимость получить определенные элементы URL. Это может быть название хоста (hostname, dmitripavlutin.com) или путь (pathname, /parse-url-javascript).

Удобным способом получить отдельные компоненты URL является конструктор URL().

В этой статье мы поговорим о структуре и основных компонентах URL.

1. Структура URL


Изображение лучше тысячи слов. На представленном изображении Вы можете видеть основные компоненты URL:



2. Конструктор URL()


Конструктор URL() это функция, позволяющая разбирать (парсить) компоненты URL:

const url = new URL(relativeOrAbsolute [, absoluteBase])

Аргумент relativeOrAbsolute может быть абсолютным или относительным URL. Если первый аргумент относительная ссылка, то второй аргумент, absoluteBase, является обязательным и представляет собой абсолютный URL основу для первого аргумента.

Например, инициализируем URL() с абсолютным URL:

const url = new URL('http://example.com/path/index.html')url.href // 'http://example.com/path/index.html'

Теперь скомбинируем относительный и абсолютный URL:

const url = new URL('/path/index.html', 'http://example.com')url.href // 'http://example.com/path/index.html'

Свойство href экземпляра URL() возвращает переданную URL-строку.

После создания экземпляра URL(), Вы можете получить доступ к компонентам URL. Для справки, вот интерфейс экземпляра URL():

interface URL {  href:     USVString;  protocol: USVString;  username: USVString;  password: USVString;  host:     USVString;  hostname: USVString;  port:     USVString;  pathname: USVString;  search:   USVString;  hash:     USVString;  readonly origin: USVString;  readonly searchParams: URLSearchParams;  toJSON(): USVString;}

Здесь тип USVString означает, что JavaScript должен возвращать строку.

3. Строка запроса (query string)


Свойство url.search позволяет получить строку запроса URL, начинающуюся с префикса ?:

const url = new URL(    'http://example.com/path/index.html?message=hello&who=world')url.search // '?message=hello&who=world'

Если строка запроса отсутствует, url.search возвращает пустую строку (''):

const url1 = new URL('http://example.com/path/index.html')const url2 = new URL('http://example.com/path/index.html?')url1.search // ''url2.search // ''

3.1. Разбор (парсинг) строки запроса

Вместо получения исходной строки запроса, мы можем получать ее параметры.

Легкий способ это сделать предоставляет свойство url.searchParams. Значением данного свойства является экземпляр интерфейса URLSeachParams.

Объект URLSearchParams предоставляет множество методов для работы с параметрами строки запроса (get(param), has(param) и т.д.).

Давайте рассмотрим пример:

const url = new Url(    'http://example.com/path/index.html?message=hello&who=world')url.searchParams.get('message') // 'hello'url.searchParams.get('missing') // null

url.searchParams.get('message') возвращает значение параметра message строки запроса.

Доступ к несуществующему параметру url.searchParams.get('missing') возвращает null.

4. Название хоста (hostname)


Значением свойства url.hostname является название хоста URL:

const url = new URL('http://example.com/path/index.html')url.hostname // 'example.com'

5. Путь (pathname)


Свойство url.pathname содержит путь URL:

const url = new URL('http://example.com/path/index.html?param=value')url.pathname // '/path/index.html'

Если URL не имеет пути, url.pathname возвращает символ /:

const url = new URL('http://example.com/');url.pathname; // '/'

6. Хеш (hash)


Наконец, хеш может быть получен через свойство url.hash:

const url = new URL('http://example.com/path/index.html#bottom')url.hash // '#bottom'

Если хеш отсутствует, url.hash возвращает пустую строку (''):

const url = new URL('http://example.com/path/index.html')url.hash // ''

7. Проверка (валидация) URL


При вызове конструктора new URL() не только создается экземпляр, но также осуществляется проверка переданного URL. Если URL не является валидным, выбрасывается TypeError.

Например, http ://example.com не валидный URL, поскольку после http имеется пробел.

Попробуем использовать этот URL:

try {    const url = new URL('http ://example.com')} catch (error) {    error // TypeError, "Failed to construct URL: Invalid URL"}

Поскольку 'http ://example.com' неправильный URL, как и ожидалось, new URL('http ://example.com') выбрасывает TypeError.

8. Работа с URL


Такие свойства, как search, hostname, pathname, hash доступны для записи.

Например, давайте изменим название хоста существующего URL с red.com на blue.io:

const url = new URL('http://red.com/path/index.html')url.href // 'http://red.com/path/index.html'url.hostname = 'blue.io'url.href // 'http://blue.io/path/index.html'

Свойства origin, searchParams доступны только для чтения.

9. Заключение


Конструктор URL() является очень удобным способом разбора (парсинга) и проверки (валидации) URL в JavaScript.

new URL(relativeOrAbsolute, [, absoluteBase] в качестве первого параметра принимает абсолютный или относительный URL. Если первый параметр является относительным URL, вторым параметром должен быть абсолютный URL основа для первого аргумента.

После создания экземпляра URL(), Вы можете получить доступ к основным компонентам URL:

  • url.search исходная строка запроса
  • url.searchParams экземпляр URLSearchParams для получения параметров строки запроса
  • url.hostname название хоста
  • url.pathname путь
  • url.hash значение хеша
Подробнее..

React за 60 секунд валидация формы

02.02.2021 08:07:36 | Автор: admin


Доброго времени суток, друзья!

В этом небольшом туториале я хочу продемонстировать вам пример клиент-серверной валидации формы.

Клиент будет реализован на React, сервер на Express.

Мы не будем изобретать велосипеды, а воспользуемся готовыми решениями: для валидации формы на стороне клиента будет использоваться react-hook-form (+: используются хуки, русский язык), а на стороне сервера express-validator.

Для стилизации будет использоваться styled-components (CSS-in-JS или All-in-JS, учитывая JSX).

Исходный код примера находится здесь.

Поиграть с кодом можно здесь.

Без дальнейших предисловий.

Клиент


Создаем проект с помощью create-react-app:

yarn create react-app form-validation# илиnpm init react-app form-validation# илиnpx create-react-app form-validation

В дальнейшем для установки зависимостей и выполнения команд я буду использовать yarn.

Структура проекта после удаления лишних файлов:

public  index.htmlsrc  App.js  index.js  styles.jsserver.js...

Устанавливаем зависимости:

# для клиентаyarn add styled-components react-hook-form# для сервера (производственные зависимости)yarn add express express-validator cors# для сервера (зависимость для разработки)yarn add -D nodemon# для одновременного запуска серверовyarn add concurrently

Поскольку styled-components не умеет импотировать шрифты, нам придется добавить их в public/index.html:

<head>  ...  <link rel="preconnect" href="http://personeltest.ru/aways/fonts.gstatic.com" />  <link    href="http://personeltest.ru/aways/fonts.googleapis.com/css2?family=Comfortaa&display=swap"    rel="stylesheet"  /></head>

Наша форма будет состоять из трех полей: имя пользователя, его адрес электронной почты и пароль. Условия, которым должны удовлетворять данные:

  • Имя
    • от 2 до 10 символов
    • кириллица

  • Email
    • особых требований не предъявляется

  • Пароль
    • 8-12 символов
    • латиница: буквы в любом регистре, цифры, нижнее подчеркивание и дефис


Начнем со стилизации (src/styles.js; для подстветки синтаксиса я использую расширение для VSCode vscode-styled-components):

// импорт инструментовimport styled, { createGlobalStyle } from 'styled-components'// глобальные стилиconst GlobalStyle = createGlobalStyle`  body {    margin: 0;    min-height: 100vh;    display: grid;    place-items: center;    background-color: #1c1c1c;    font-family: 'Comfortaa', cursive;    font-size: 14px;    letter-spacing: 1px;    color: #f0f0f0;  }`// заголовокconst StyledTitle = styled.h1`  margin: 1em;  color: orange;`// формаconst StyledForm = styled.form`  margin: 0 auto;  width: 320px;  font-size: 1.2em;  text-align: center;`// подписьconst Label = styled.label`  margin: 0.5em;  display: grid;  grid-template-columns: 1fr 2fr;  align-items: center;  text-align: left;`// проект поля для ввода данныхconst BaseInput = styled.input`  padding: 0.5em 0.75em;  font-family: inherit;  font-size: 0.9em;  letter-spacing: 1px;  outline: none;  border: none;  border-radius: 4px;`// обычное полеconst RegularInput = styled(BaseInput)`  background-color: #f0f0f0;  box-shadow: inset 0 0 2px orange;  &:focus {    background-color: #1c1c1c;    color: #f0f0f0;    box-shadow: inset 0 0 4px yellow;  }`// поле для отправки данных на серверconst SubmitInput = styled(BaseInput)`  margin: 1em 0.5em;  background-image: linear-gradient(yellow, orange);  cursor: pointer;  &:active {    box-shadow: inset 0 1px 3px #1c1c1c;  }`// проект сообщения с текстомconst BaseText = styled.p`  font-size: 1.1em;  text-align: center;  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);`// сообщение об ошибкеconst ErrorText = styled(BaseText)`  font-size: ${(props) => (props.small ? '0.8em' : '1.1em')};  color: red;`// сообщение об успехеconst SuccessText = styled(BaseText)`  color: green;`// экспорт стилизованных компонентовexport {  GlobalStyle,  StyledTitle,  StyledForm,  Label,  RegularInput,  SubmitInput,  ErrorText,  SuccessText}

Импортируем и подключаем глобальные стили в src/index.js:

import React from 'react'import ReactDOM from 'react-dom'// импортируем глобальные стилиimport { GlobalStyle } from './styles'import App from './App'ReactDOM.render(  <React.StrictMode>    {/* подключаем глобальные стили */}    <GlobalStyle />    <App />  </React.StrictMode>,  document.getElementById('root'))

Переходим к основному файлу клиента (src/App.js):

import { useState } from 'react'// импорт хука для валидации формыimport { useForm } from 'react-hook-form'// импорт стилизованных компонентовimport {  StyledTitle,  StyledForm,  Label,  RegularInput,  SubmitInput,  ErrorText,  SuccessText} from './styles'// компонент заголовкаfunction Title() {  return <StyledTitle>Валидация формы</StyledTitle>}// компонент формыfunction Form() {  // инициализируем начальное состояние  const [result, setResult] = useState({    message: '',    success: false  })  // извлекаем средства валидации:  // регистрация проверяемого поля  // ошибки и обработка отправки формы  const { register, errors, handleSubmit } = useForm()  // общие валидаторы  const validators = {    required: 'Не может быть пустым'  }  // функция отправки формы  async function onSubmit(values) {    console.log(values)    const response = await fetch('http://localhost:5000/server', {      method: 'POST',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify(values)    })    const result = await response.json()    // обновляем состояние    setResult({      message: result,      success: response.ok    })  }  // нажатие кнопки сброса полей в исходное состояние приводит к перезагрузке страницы  function onClick() {    window.location.reload()  }  return (    <>      <StyledForm onSubmit={handleSubmit(onSubmit)}>        <Label>          Имя:          <RegularInput            type='text'            name='name'            // поля являются неуправляемыми            // это повышает производительность            ref={register({              ...validators,              minLength: {                value: 2,                message: 'Не менее двух букв'              },              maxLength: {                value: 10,                message: 'Не более десяти букв'              },              pattern: {                value: /[А-ЯЁ]{2,10}/i,                message: 'Только киррилица'              }            })}            defaultValue='Иван'          />        </Label>        {/* ошибки */}        <ErrorText small>{errors.name && errors.name.message}</ErrorText>        <Label>          Email:          <RegularInput            type='email'            name='email'            ref={register({              ...validators,              pattern: {                value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,                message: 'Неправильный адрес электронной почты'              }            })}            defaultValue='email@example.com'          />        </Label>        <ErrorText small>{errors.email && errors.email.message}</ErrorText>        <Label>          Пароль:          <RegularInput            type='password'            name='password'            ref={register({              ...validators,              pattern: {                value: /^[A-Z0-9_-]{8,12}$/i,                message:                  'От 8 до 12 символов: латиница, цифры, нижнее подчеркивание и дефис'              }            })}            defaultValue='password'          />        </Label>        <ErrorText small>          {errors.password && errors.password.message}        </ErrorText>        <SubmitInput type='submit' defaultValue='Отправить' />        {/* обратите внимание на атрибут "as", он позволяет превратить "инпут" в кнопку с аналогичными стилями */}        <SubmitInput as='button' onClick={onClick}>          Сбросить        </SubmitInput>      </StyledForm>      {/* результат отправки формы */}      {result.success ? (        <SuccessText>{result.message}</SuccessText>      ) : (        <ErrorText>{result.message}</ErrorText>      )}    </>  )}export default function App() {  return (    <>      <Title />      <Form />    </>  )}

Метод register() хука useForm() поддерживает все атрибуты тега input. Полный список таких атрибутов. В случае с именем, мы могли бы ограничиться регулярным выражением.

Запускаем сервер для клиента с помощью yarn start и тестируем форму:



Замечательно. Валидация на стороне клиента работает, как ожидается. Но ее всегда можно отключить. Поэтому нужна валидация на сервере.

Сервер


Приступаем к реализации сервера (server.js):

const express = require('express')// body читает тело запроса// validationResult - результат валидацииconst { body, validationResult } = require('express-validator')const cors = require('cors')const app = express()const PORT = process.env.PORT || 5000app.use(cors())app.use(express.json())app.use(express.urlencoded({ extended: false }))// валидаторыconst validators = [  body('name').trim().notEmpty().isAlpha('ru-RU').escape(),  body('email').normalizeEmail().isEmail(),  // кастомный валидатор  body('password').custom((value) => {    const regex = /^[A-Z0-9_-]{8,12}$/i    if (!regex.test(value)) throw new Error('Пароль не соответствует шаблону')    return true  })]// валидаторы передаются в качестве middlewareapp.post('/server', validators, (req, res) => {  // извлекаем массив с ошибками из результата валидации  const { errors } = validationResult(req)  console.log(errors)  // если массив с ошибками не является пустым  if (errors.length) {    res.status(400).json('Регистрация провалилась')  } else {    res.status(201).json('Регистрация прошла успешно')  }})app.listen(PORT, () => {  console.log(`Сервер готов. Порт: ${PORT}`)})

Полный список доступных валидаторов можно посмотреть здесь.

Добавим в package.json парочку скриптов server для запуска сервера и dev для одновременного запуска серверов:

"scripts": {  "start": "react-scripts start",  "build": "react-scripts build",  "server": "nodemon server",  "dev": "concurrently \"yarn server\" \"yarn start\""}

Выполняем yarn dev и тестируем отправку формы:





Прекрасно. Кажется, у нас все получилось.

Мы с вами рассмотрели очень простой вариант клиент-серверной валидации формы. Вместе с тем, более сложные варианты предполагают лишь увеличение количества валидаторов, общие принципы остаются такими же. Также стоит отметить, что валидацию формы на стороне клиента вполне можно реализовать средствами HTML (GitHub, CodeSandbox).

Благодарю за внимание и хорошего дня.
Подробнее..

Избавляемся от мистических строк в системе реактивного связывания на Unity

17.12.2020 10:10:32 | Автор: admin
Любая система, которая часто используется в проекте, со временем обречена на эволюцию. Так случилось и с нашей системой реактивного связывания reactive bindings.

Что это за система? Она позволяет нам связывать данные на префабе с данными в коде. У нас есть ViewModel, лежащая на префабе. В ней есть некие ключи с разными типами. Соответственно, вся остальная логика, которая у нас привязана к UI, привязана к этим ключам и их изменениям. То есть, если у нас есть некая логическая переменная, меняя ее в коде, мы можем менять любые состояния UI автоматически.



Использование reactive bindings принесло нам как множество новых возможностей, так и ряд зависимостей. Для связи переменных кода и ViewModel, лежащей на префабе, нам необходимо было соответствие строковых имен. Это приводило к тому, что в результате неосторожной правки префаба или ошибки мерджа могли быть утеряны какие-то из этих связей, а ошибка замечалась уже на этапе поздних тестов в виде отвалившегося UI-функционала.

Росла частота использования системы росло число подобных сложностей.

Два основных неудобства, с которыми мы столкнулись:

  • Строковые ключи в коде;
  • Нет проверки соответствия ключей в коде и ключей в модели.

Эта статья о том, как мы дополнили систему и тем самым закрыли эти потребности.

Но обо всем по порядку.

В наших reactive bindings доступ к полям происходит по связке тип поля-строковый путь во ViewModel. Отсюда повсеместно мы имели подобный код:

Посмотреть код
public static class AbilitiesPresenter{  private static readonly PropertyName MechAbilities = "mech/abilities";  private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";  private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";  private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";  public static void Present(IViewModel viewModel, List<AbilityInfo> data)  {     var collection = viewModel.GetMutableCollection(MechAbilities);     collection.Fill(data, SetupAbilityItem);  }  private static void SetupAbilityItem(AbilityInfo data, IViewModel model)  {     model.GetString(MechAbilitiesIcon).Value = data.Icon;     model.GetString(MechAbilitiesName).Value = data.Name;     model.GetString(MechAbilitiesDescription).Value = data.Desc;  }}

То есть, посредством GetString/GetInteger/GetBoolean и т. д. мы получаем ссылку на поле в модели и пишем/читаем значения.

В чем проблема этой системы? А в том, что чем больше полей в модели тем больше строк в коде. Читать и поддерживать подобный стиль становится весьма проблематично.

Контролировать соответствие типов/путей в коде и в реальной ViewModel та еще боль. Если c UI-префабом работает больше одного человека, может возникнуть неявный мердж, в результате которого какие-то ключи могут потеряться. Об этом мы узнаем только на этапе поздних тестов, когда UI перестает работать корректно.

Задача заключалась в том, чтобы получить систему, которая уберет из нашего кода ненужные строковые константы и предоставит явный контракт взаимодействия.

Второй подзадачей являлось получение инструмента, который позволит валидировать эти значения, чтобы мы могли быть на 100% уверены, что текущая ViewModel на префабе соответствует текущему контракту и содержит все необходимые поля.

Желаемый формат работы выглядел примерно так:

  • Для работы с ViewModel создается некий контракт, в котором описаны все поля и строковые связи;
  • Далее нам нужно вызвать некий механизм инициализации этого контракта;
  • В редакторе во ViewModel мы должны иметь явные сообщения об ошибках при отсутствии каких-то полей в модели или во вложенной коллекции.

В проекте есть отличный механизм для валидирования и вывода информации о возможных ошибках в редакторе модуль Validate. И есть возможность использовать кодогенерацию (T4). Все это мы задействовали, чтобы решить поставленную задачу.

Теперь ближе к коду.

Раньше описание работы с элементами у нас было в следующем стиле:

Посмотреть код
public static class AbilitiesPresenter{  private static readonly PropertyName MechAbilities = "mech/abilities";  private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";  private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";  private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";  public static void Present(IViewModel viewModel, List<AbilityInfo> data)  {     var collection = viewModel.GetMutableCollection(MechAbilities);     collection.Fill(data, SetupAbilityItem);  }  private static void SetupAbilityItem(AbilityInfo data, IViewModel model)  {     model.GetString(MechAbilitiesIcon).Value = data.Icon;     model.GetString(MechAbilitiesName).Value = data.Name;     model.GetString(MechAbilitiesDescription).Value = data.Desc;  }}

Здесь нужно понимать, что презентер взаимодействует только со своей частью полей, которые нужны конкретно для его задачи. И для проверки валидности ViewModel необходимо проверить соответствие полей у каждого из презентеров, которые используют данную ViewModel, а это та еще задачка.

Стало же все выглядеть так:

Посмотреть код
namespace DroneDetails{  public class DroneDetailScreenView : UIScreenViewWith3D<DroneViewUI3DScreen>  {     [ExpectReactiveContract(typeof(DroneInfoViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneInfoModel;     [ExpectReactiveContract(typeof(DroneScreenMainEventsModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneScreenMainEventsModel;     [ExpectReactiveContract(typeof(DroneScreenInfoModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneScreenInfoModel;     [ExpectReactiveContract(typeof(DroneSpawnInfoViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneSpawnInfoViewModel;     [ExpectReactiveContract(typeof(ScrollListViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _scrollListViewModel;//.   }}

ViewModel приписывается атрибут ExpectReactiveContract, который получает параметры контракта. Пример контракта выглядит следующим образом:

public struct ConnectionStatusViewModel : IBindViewModel{//пример описания полей  [Bind("connection/is-lost")]  public IMutableProperty<string>IsConnectionLost;  [Bind("mech/slots-count")]   public IMutableProperty<int> SlotsCount;//задание контракта для элементов вложенной коллекции  [Bind("current-drone-info/scheme-slots-info")]     [SchemaContract(typeof(SchemeSlotInfoViewModel))]  public IMutableCollection SchemeSlotsInfo;}

В этом варианте есть явное типизированное поле. Сверху атрибутом Bind описывается строка, которая связывает это поле с ViewModel.

private void OnPreviewDrone(int index){  _droneDetailModel.DroneScrollStateModel.SaveState(index);  var droneId = _dronesListModel.GetDroneIdByIndex(index);  _view.DroneInfoViewModel.DroneId.Value = droneId;  //...}

Способ использования теперь стал каноничным: мы берем структуру (контракт) и устанавливаем новое значение одному из полей (в примере это DroneId).

В результате в основном коде у нас нет никаких лишних строковых констант, нет дополнительной логики связывания полей, которая бы смущала при написании. Он стал намного чище, ведь мы работаем с обычными структурами. Все это получается за счет существования контракта, который сам знает, каким образом из той ViewModel, которая была ему передана, получить данные поля и, привязываясь к ней, попутно выполнить валидацию.

Для описания контракта используются два основных атрибута: Bind и SchemaContract. Bind отвечает за связывание поля структуры с полем во ViewModel. Атрибут получает ключ и опциональное поле IsRequired, говорящее о том, действительно ли во ViewModel необходимо иметь конкретный ключ или ничего не произойдет, если его упустить.

При помощи Bind мы передаем строковые ключи и используем этот атрибут для передачи информации в кодогенератор:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |               AttributeTargets.GenericParameter)]public class BindAttribute : Attribute{  public string ViewModelPath { get; }  public bool IsRequired { get; }  public BindAttribute(string value, bool isRequired = true)  {     ViewModelPath = value;     IsRequired = isRequired;  }}

Атрибут SchemaContract служит с целью указания контракта для элементов коллекции:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |  AttributeTargets.GenericParameter)]public class SchemaContractAttribute : Attribute{  public System.Type[] BindViewModelContracts;  public SchemaContractAttribute(params System.Type[]contracts)  {     BindViewModelContracts = contracts;  }}

Итак, теперь у нас есть контракт. Дело осталось за малым: написать механизм, который позволит его полноценно использовать. Необходимо как-то заполнить значения всех полей. Для этого мы реализуем специализированный класс резолвер.

Резолвер класс, который может проинициализировать поля структуры (контракта). Он и выполняет роль связывания между контрактом и ViewModel на префабе.

Резолверы имеют простую структуру и хорошо подходят для кодогенерации:

Посмотреть код
// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using PS.ReactiveBindings;using Test;namespace BindViewModel{   public partial struct BindViewModelResolver   {       private static ConnectionStatusViewModel ResolveConnectionStatusViewModel(IViewModel viewModel)           => new ConnectionStatusViewModel           {               IsConnectionLost = LookupProperty<IMutableProperty<string>>(               "ConnectionStatusViewModel",               viewModel,                PropertyType.String,                "connection/is-lost",                true),               SomeCollection = LookupProperty<IMutableCollection>(               "ConnectionStatusViewModel",       viewModel,                PropertyType.Collection,                "mech/tempCollection",                true),           };   }}

Темплейт для генерации:

Посмотреть код
<#@ template debug="false" hostspecific="false" language="C#" #><#@ parameter name ="m_GenerationInfo" type="WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo"#>// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using PS.ReactiveBindings;using <#=m_GenerationInfo.Namespace #>;namespace BindViewModel{   public partial struct BindViewModelResolver   {       private static <#=m_GenerationInfo.ClassName #> Resolve<#=m_GenerationInfo.ClassName #>(IViewModel viewModel)           => new <#=m_GenerationInfo.ClassName #>           {<#  foreach (var property in m_GenerationInfo.PropertiesInfo)  {     var requiredString = property.Required.ToString().ToLower();#>               <#=property.Name #> = LookupProperty<<#=property.PropertyTypeName #>>("<#=m_GenerationInfo.ClassName #>",viewModel, <#=property.ReactivePropertyTypeName #>, "<#=property.ViewModelPath #>", <#=requiredString #>),<#  }#>           };   }}

Класс BindViewModelResolver partial и имеет генерируемую часть. Задача метода resolve найти нужный резолвер для контракта и с его помощью выполнить связывание между логической и префабной частью.

Также есть метод ResolveWithReflection (fallback), который выполняет данное связывание через рефлексию. Это сделано на случай, если у нас отсутствует сгенерированный резолвер. Рефлексия работает медленнее, поэтому мы стараемся ее избегать.

Посмотреть код
public partial struct BindViewModelResolver{  private static Dictionary<System.Type, IResolver> _resolvers;  static partial void InitResolvers();  public static T Resolve<T>(IViewModel viewModel) where T : struct, IBindViewModel  {     InitResolvers();     if (_resolvers != null && _resolvers.ContainsKey(typeof(T)))     {        var resolver = (Resolver<T>) _resolvers[typeof(T)];        return resolver.Resolve(viewModel);     }     return ResolveWithReflection<T>(viewModel);  }  private class CanNotResolvePropertyException : System.Exception  {     public CanNotResolvePropertyException(string message) : base(message)     {     }  }  private interface IResolver  {  }  private struct Resolver<T> : IResolver     where T : struct, IBindViewModel  {     public delegate T ResolveDelegate(IViewModel viewModel);     public ResolveDelegate Resolve;  }  private static Resolver<T> FromDelegate<T>(Resolver<T>.ResolveDelegate resolveDelegate)     where T : struct, IBindViewModel     => new Resolver<T> {Resolve = resolveDelegate};  private static T LookupProperty<T>(     string holderName,     IViewModel viewModel,     PropertyType type,     PropertyName id,     bool required)     where T : class, IReactive  {     T obj = viewModel.LookupProperty(id, type) as T;     if (obj == null)     {        if (required)        {           throw new CanNotResolvePropertyException(              $"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}"           );        }        Debug.LogWarning($"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}");     }     return obj;  }  private static T ResolveWithReflection<T>(IViewModel viewModel)  {     var type = typeof(T);     var fields = type.GetFields();     var resolvedStruct = System.Activator.CreateInstance(type);     foreach (var field in fields)     {        var bindAttribute = field.GetCustomAttribute<BindAttribute>();        if (bindAttribute != null)        {           var viewModelPath = bindAttribute.ViewModelPath;           var result = ResolveFieldValue(type.Name, field.FieldType, viewModelPath, viewModel, bindAttribute.IsRequired);           field.SetValue(resolvedStruct, result);        }     }     return (T) resolvedStruct;  }

Сами резолверы лежат в словаре по типам. Этот список резолверов и описан в сгенерированной части. А сама она выглядит так:

Посмотреть код
public partial struct BindViewModelResolver{   static partial void InitResolvers()   {        if (_resolvers != null) return;       _resolvers = new Dictionary<System.Type, IResolver>       {           {typeof(DroneInfoViewModel), FromDelegate(ResolveDroneInfoViewModel)},           {typeof(DroneSchemeMetaphorViewModel), FromDelegate(ResolveDroneSchemeMetaphorViewModel)},           {typeof(DroneScreenInfoModel), FromDelegate(ResolveDroneScreenInfoModel)},           {typeof(DroneScreenMainEventsModel), FromDelegate(ResolveDroneScreenMainEventsModel)},           {typeof(DroneSpawnInfoViewModel), FromDelegate(ResolveDroneSpawnInfoViewModel)},           {typeof(DroneStoreItemViewModel), FromDelegate(ResolveDroneStoreItemViewModel)},           {typeof(HangarSlotViewModel), FromDelegate(ResolveHangarSlotViewModel)},           {typeof(SchemeSlotInfoViewModel), FromDelegate(ResolveSchemeSlotInfoViewModel)},           {typeof(ScrollListViewModel), FromDelegate(ResolveScrollListViewModel)},           {typeof(StateItemViewModel), FromDelegate(ResolveStateItemViewModel)},           {typeof(ConnectionStatusViewModel), FromDelegate(ResolveConnectionStatusViewModel)},           {typeof(TitanStateViewModel), FromDelegate(ResolveTitanStateViewModel)},           {typeof(MechStateViewModel), FromDelegate(ResolveMechStateViewModel)},           {typeof(ChipOfferItemViewModel), FromDelegate(ResolveChipOfferItemViewModel)},           {typeof(DroneOfferItemViewModel), FromDelegate(ResolveDroneOfferItemViewModel)},       };   }}

Темплейт для генерируемой части:

Посмотреть код
<#@ template debug="false" hostspecific="false" language="C#" #><#@ parameter name ="m_GenerationInfos" type="System.Collections.Generic.List<WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo>"#><#@ import namespace="System.Collections.Generic" #><#@ import namespace="BindViewModel" #>// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using System.Collections.Generic;<#List<string> namespaces = new List<string>();  foreach (var generationInfo in m_GenerationInfos)  {     if (!namespaces.Contains(generationInfo.Namespace))     {#>using <#=generationInfo.Namespace #>;<#        namespaces.Add(generationInfo.Namespace);     }  }#>namespace BindViewModel{   public partial struct BindViewModelResolver   {       static partial void InitResolvers()       {            if (_resolvers != null) return;           _resolvers = new Dictionary<System.Type, IResolver>           {<#  foreach (var generationInfo in m_GenerationInfos)  {#>               {typeof(<#=generationInfo.ClassName #>), FromDelegate(Resolve<#=generationInfo.ClassName #>)},<#  }#>           };       }   }}

Итак, теперь у нас есть инструмент создания резолверов. Осталось создать инструмент для его вызова. А это задача генератора.

Генератор проходится по assemblies и выискивает контракты-наследники IBindViewModel. Найдя контракт, он проходит по нему и заполняет информацию для генерации. Текущая информация состоит из имени переменной, типа, пути для связывания и прочего. Затем подготовленная информация передается непосредственно в T4-генератор.

Код для сбора информации:

Посмотреть код
List<GenerationInfo> generationInfos = new List<GenerationInfo>();Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();foreach (var assembly in assemblies){   var types = assembly.GetTypes();  var iBindViewModelType = typeof(IBindViewModel);  foreach (Type type in types)  {     if (type.IsValueType && iBindViewModelType.IsAssignableFrom(type))     {        GenerationInfo generationInfo = new GenerationInfo {ClassName = type.Name, Namespace = type.Namespace};        var props = new List<PropertyInfo>();        var fields = type.GetFields();        foreach (var field in fields)        {           var bindAttribute = field.GetCustomAttribute<BindAttribute>();           if (bindAttribute != null)           {              var propertyInfo = new PropertyInfo();              propertyInfo.Name = field.Name;              propertyInfo.ViewModelPath = bindAttribute.ViewModelPath;              var propertyTypeNames = GetPropertyTypeName(field.FieldType);              propertyInfo.ReactivePropertyTypeName = propertyTypeNames.ReactivePropertyTypeName;              propertyInfo.PropertyTypeName = propertyTypeNames.PropertyTypeName;              propertyInfo.ValueTypeName = propertyTypeNames.ValueTypeName;              propertyInfo.Required = bindAttribute.IsRequired;              props.Add(propertyInfo);           }        }        generationInfo.PropertiesInfo = props;        generationInfos.Add(generationInfo);     }  }}

Передача информации и запуск T4-генератора:

Посмотреть код
foreach (var gInfo in generationInfos){  var viewModelBindingsTemplateGenerator = new ViewModelBindingsTemplate  {     Session = new Dictionary<string, object> {["_m_GenerationInfoField"] = gInfo}  };  viewModelBindingsTemplateGenerator.Initialize();  var generationData = viewModelBindingsTemplateGenerator.TransformText();  File.WriteAllText(fullOutputPath + gInfo.ClassName + ".cs", generationData);}var viewModelResolverTemplateGenerator = new ViewModelResolverTemplate(){  Session = new Dictionary<string, object> {["_m_GenerationInfosField"] = generationInfos}};viewModelResolverTemplateGenerator.Initialize();var generationResult = viewModelResolverTemplateGenerator.TransformText();File.WriteAllText(fullOutputPath + "BindViewModelResolverGenerated.cs", generationResult);

Как результат теперь мы можем инициализировать контракт следующим образом:

Var DroneInfoViewModel = BindViewModelResolver.Resolve<DroneInfoViewModel>(_droneInfoModel);

Пример сгенеренного резолвера для DroneInfoViewModel:

Посмотреть код
public partial struct BindViewModelResolver{   private static DroneInfoViewModel ResolveDroneInfoViewModel(IViewModel viewModel)       => new DroneInfoViewModel       {           OnTopInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-top-info-click", true),           OnBottomInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-bottom-info-click", true),           DroneName = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-name", true),           DroneTier = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-tier", true),           VoltageCurrent = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-current", true),           VoltageMax = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-max", true),           VoltageRange = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/voltage-range", true),           SpawnChargeCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-charge-cost", true),           SpawnHardCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-hard-cost", true),           BuyCurrency = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/buy-currency", true),           BuyPriceValue = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/buy-price-value", true),           SchemeSlotsInfo = LookupProperty<IMutableCollection>("DroneInfoViewModel",viewModel, PropertyType.Collection, "current-drone-info/scheme-slots-info", true),           DroneId = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-id", true),           InLoadingState = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/in-loading-state", true),           DroneExist = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-exist", true),           DroneNoSlot = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-slot", true),           DroneNoDrone = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-drone", true),           IsDroneBlueprint = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-blueprint", true),       };}

Напоследок в паре слов о валидаторах.

Чтобы включить валидацию для модели, нужно всего лишь прописать атрибут ExpectReactiveContract:

[ExpectReactiveContract(typeof(DroneInfoViewModel))]private ViewModel _droneInfoModel;

При наличии ошибок в редакторе будет выведено предупреждение вида:



Валидатор работает на основе рефлексии, пробегая по Bind-полям и проверяя их наличие в модели.

Наличие валидации принесло нам ряд преимуществ:

  • уменьшилось время ручного тестирования;
  • поиск ошибок стал проще;
  • упростилась дальнейшая поддержка/переработка UI;
  • стало стабильнее и легче переиспользование классов, описывающих работу с UI.

На текущий момент только одна большая фича в проекте сделана полностью на основе описанного метода и еще несколько старых функциональностей переведено на новые рельсы. Данный инструмент новый и точно еще подвергнется дополнениям и изменениям.
Подробнее..

Интеграция и серверная валидация инаппов для стора Google Play как защититься от читеров

25.05.2021 20:20:29 | Автор: admin

Онлайн-проекты рано или поздно сталкиваются со взломом внутреннего стора, когда читеры накручивают себе игровые предметы, оружие или валюту. Классика. Наш PvP-шутер не стал исключением брешь мы в итоге закрыли, хотя и пришлось повозиться.

В этой статье расскажу про интеграцию и серверную валидацию инаппов с точки зрения клиента: какой плагин использовать для Google Play и на что обращать внимание независимо от платформы, а моя коллега поделится кодом серверной части.

Как уже говорилось в блоге, наш флагманский проект это мобильный PvP-шутер с DAU около 1 млн пользователей, большинство из которых на Android. В игре сотни видов оружия и предметов. И чтобы защититься от взлома, естественно, нужна валидация покупок. Пойдем по порядку.

В Google Play наш проект использует consumable in-apps, которые после успешной покупки и валидации начисляются игроку и сразу потребляются. По историческим причинам для Google Play мы используем плагин от Prime31.

Отмечу, что если бы мы сегодня добавляли встроенные покупки с нуля на эти платформы, то взяли бы Unity IAP (а, например, на Huawei публиковались бы через Unity Distribution Portal).

В игре много спецпредложений, но мы не стали заводить отдельный id инаппа под каждую акцию, вместо этого используем один набор айдишников инаппов для разных предметов и предложений. В момент нажатия на конкретную покупку мы запоминаем контент, который нужно выдать игроку при успешной покупке. При завершении покупки выдаем его.

Перейдем к коду покупки и валидации инаппов.

На старте приложения подписываемся на события покупки:

// GoogleIABManager  класс из плагина Prime31GoogleIABManager.purchaseSucceededEvent += HandleGooglePurchaseSucceeded;

Когда игрок нажимает на инапп в интерфейсе запускаем покупку:

// GoogleIAB  класс из плагина Prime31GoogleIAB.purchaseProduct(productId);

В обработчике успешного завершения покупки оборачиваем платформо-специфичную покупку в объект, реализующий IMarketPurchase. IMarketPurchase мы используем на всех платформах, чтобы сделать код валидации кроссплатформенным. В этот интерфейс мы оборачиваем классы из плагинов конкретных магазинов.

public interface IMarketPurchase{  string ProductId { get; }   string OrderId { get; }  string PurchaseToken { get; }  object NativePurchase { get; }}class GoogleMarketPurchase : IMarketPurchase{  internal GoogleMarketPurchase(GooglePurchase purchase)  {     _purchase = purchase;  }  public string ProductId => _purchase.productId;  public string OrderId => _purchase.orderId;  public string PurchaseToken => _purchase.purchaseToken;  public object NativePurchase => _purchase;  private GooglePurchase _purchase;}internal static class MarketPurchaseFactory{// GooglePurchase  класс из плагина Prime31  internal static IMarketPurchase CreateMarketPurchase(GooglePurchase purchase)  {     return new GoogleMarketPurchase(purchase);  }}private void IapManagerOnBuyProductSuccess(PurchaseResultInfo purchaseResult){  var purchaseData = new InAppPurchaseData(purchaseResult.InAppPurchaseData);  IMarketPurchase marketPurchase = MarketPurchaseFactory.CreateMarketPurchase(purchaseData);  ValidatePurchase( marketPurchase );}

Отправляем покупку на наш сервер на валидацию:

private void ValidatePurchase(IMarketPurchase purchase){  var request = new InappValidationRequest  {     orderId = purchase.OrderId,     productId = purchase.ProductId,     purchaseToken = purchase.PurchaseToken,     OnSuccess = () => ProvidePurchase(purchase),     OnFail = () => Consume(purchase)  };   WebSocketCallbacks.Subscribe(ServerEventNames.PurchasePrevalidate, PrevalidatePurchaseHandler);   Dictionary<object, object> data = new Dictionary<object, object>();  data.Add("orderId", request.orderId);  data.Add("productId", request.productId);  data.Add("data", request.purchaseToken);  int reqId = WebSocketManager.Instance.Send(ServerEventNames.PurchasePrevalidate, data);   _valdationRequests.Add(reqId, request);}

Если валидация проходит неуспешно потребляем (Consume) продукт без начисления пользователю.

Если все хорошо потребляем продукт с начислением пользователю:

void ProvidePurchase(IMarketPurchase purchase){  GiveInGameCurrencyAndItems(purchase);  Consume(purchase);}

Важный момент: метод Consume перед отправкой в магазин запроса на потребление запоминает, что мы уже начислили покупку игроку. Это нужно, если из-за проблем с сетью (или каких-то других) запрос на консьюм не дойдет до магазина. В таком случае, когда после перезапуска приложения нам придут незаконсьюмленные покупки, мы увидим, за какие из них уже начисляли игроку валюту и предметы.

Обработчик ответа с сервера:

private const int ERROR_CODE_SERVER_ERROR = 30;private const int ERROR_CODE_VALIDATION_ERROR = 31;private void PrevalidatePurchaseHandler(Dictionary<string, object> response){  int reqId = Convert.ToInt32(response["req_id"], CultureInfo.InvariantCulture);  _valdationRequests.TryGetValue(reqId, out InappValidationRequest request);  if (request == null)     return;  _valdationRequests.Remove(reqId);  if (response["status"].Equals("ok"))  {     request.OnSuccess();  }  else  {     int code = Convert.ToInt32(response["err_code"], CultureInfo.InvariantCulture);     switch (code)     {        case ERROR_CODE_VALIDATION_ERROR:           request.OnFail();           break;        case ERROR_CODE_SERVER_ERROR:           CoroutineRunner.DeferredAction(5f, () => TryValidateAgain());           break;        default:           // неизвестная ошибка, начисляем инапп (поступаем в пользу игрока)           request.OnSuccess(null);           break;     }  }}

В случае, если сервер вернул OK в статусе валидации, производим начисление и консьюм покупки. Если сервер вернул неизвестную ошибку, трактуем результат валидации в пользу игрока.

Для следующего раздела передаю слово нашему серверному программисту Ире Поповой.

Серверная валидация

Валидация на сервере состоит из двух этапов:

  • превалидация когда данные по платежу отправляются на сервер соответствующей платформы для проверки валидности;

  • начисление в случае успешно пройденной валидации купленных позиций.

Сначала сервер получает в качестве входных параметров данные, необходимые для проведения валидации. В Android это id позиции и токен. Методы валидации являются платформо-зависимыми. Но, как правило, включают в себя логику отправки данных на сервер валидации соответствующей платформы, обработку полученного результата и возврат соответствующего ответа на клиент. Дополнительно результат валидации записывается в redis для последующей быстрой проверки при начислении.

def validate_receipt(self, uid, data, platform):    InAppSlot = PlayerProgress.first(f"player_id={uid} AND slot_id='35'")    if not InAppSlot:        raise RuntimeError(f"Fail get slot purchases: not found player:{uid} data:{data}")    tid = data.get("tid")    params = []    orders_data = []    valid_orders = []    if not tid or tid in InAppSlot.content:        return False    params = str(tid).split(self.IN_APP_ID_SEPARATOR)    if platform == "ios":        transaction_id = params[0]        product_id = params[1]        orders_data = self._get_receipt_ios(data.get("data"), data.get("test") == 1, transaction_id, product_id)        error("[VALIDATION] {} {} {}".format(transaction_id, product_id, orders_data))    elif platform == "android":        product_id = params[1]        purchase_token = data.get("data")        orders_data = self._get_receipt_android(product_id, purchase_token)    elif platform == "amazon":        receipt_sku = params[0]        user_id = params[1]        orders_data = self._get_receipt_amazon(user_id, receipt_sku)    elif platform == "huawei":        product_id = params[1]        orders_data = self._get_receipt_huawei(product_id, tid, data.get("data", ""), data.get("account_flag", 0))    elif platform == "udp":        product_id = params[1]        orders_data = self._get_receipt_udp(product_id, params[0], data.get("data", ""))    elif platform == "samsung":        product_id = params[1]        transaction_id = params[0]        product_id = params[1]        orders_data = self._get_receipt_samsung(data.get("data", ""), product_id)    else:        error("[InAppValidator] unknown platform")        return False    if not orders_data:        error(f"[InAppValidator] fail get receipt {platform} player:{uid} data:{data}")        return False    key = f"inapp:{uid}:{tid}"    for order in orders_data:        if not  order.is_success():            continue        valid_orders.append(order)        try:            self.inapp_redis.setex(key, order.to_json(), 86400)        except Exception as ex:            exception(f"[InAppValidator] fail save inapp to redis: {ex}")    if not valid_orders:        warning(f"[InAppValidator] not valid receipt {orders_data[0].order_id}")       return False    return True

Пример получения данных с соответствующего сервера валидации для Android. Для обращения к серверу Google были использованы пакеты Google для Python apiclient и oauth2client.

def _get_receipt_android(self, product_id, token):    if not self.android_authorized:        self._android_auth()    debug(f"[InAppValidator] android product_id: {product_id}, token: {token}")    try:        product = self.android_publisher.purchases().products().get(            packageName=config.GOOGLE_SERVICE_ACCOUNT['package_name'], productId=product_id, token=token).execute()            except client.AccessTokenRefreshError:        self.android_authorized = False        return self._get_receipt_android(product_id, token)    except google_errors.HttpError as ex:        if ex.resp.status == 401 or ex.resp.status == 503:            self.android_authorized = False            return self._get_receipt_android(product_id, token)        return False    if not product:        warning("[InAppValidator] android product is NONE")        return None    order_id = product.get('orderId')    if not order_id:        warning(f"order_id is NONE: {product}")        return None    return [Receipt(order_id, product.get('purchaseState', -1), product_id)]class Receipt:    def __init__(self, order_id, status, product_id, user_id=None, expire=0, trial=0, refund=0, latest_receipt=''):        self.order_id = order_id        self.status = status        self.product_id = product_id        self.user_id = user_id        self.expire = expire        if str(trial) == 'true':            self.trial = 1        else:            self.trial = 0        self.refund = refund        self.latest_receipt = latest_receipt    def is_success(self):        return self.status == 0    def is_canceled(self):        return self.status == 3    def is_valid(self):        return self.order_id and self.product_id    def to_dict(self):        return {"id": self.order_id, "s": self.status, "p": self.product_id, "u": self.user_id, "e":self.expire, "t":self.trial,"r":self.refund,"lr":self.latest_receipt}    def to_json(self):        return json.dumps({"id": self.order_id, "s": self.status, "p": self.product_id, "u": self.user_id, "e":self.expire, "t":self.trial,"r":self.refund,"lr":self.latest_receipt})

Отдельной командой/набором команд происходит начисление купленных позиций. Одна позиция может содержать разнотипные итемы (например, деньги и оружие), и для каждого типа итема на сервере существует отдельная команда начисления.

Чтобы логически объединить несколько команд, привязанных к одному действию игрока, на клиенте и на сервере введено понятие снапшота специальной конструкции, представляющей собой объединение команд, в которой ни одна команда не выполнится, если хотя бы какая-то не пройдет проверку. Можно сказать, что это некий аналог транзакций в БД. В данном случае снапшот включает специальную команду валидации и команды начисления купленных позиций.

Команда валидации:

def validate_receipt(self, data):    neededSlotsNames = [self.slotName]    self.slots = self.get_slots_data(*neededSlotsNames)    InAppSlot = self.slots.get(self.slotName, [])    tid = data.get("tid")    platform = data.get("pl")    params = []    orders_data = []    valid_orders = []    if not tid:        self.ThrowFail("not found required parameter")    elif tid in InAppSlot:        self.ThrowFail("already in slot")    if not self.IsFail():        params = str(tid).split(self.IN_APP_ID_SEPARATOR)    if not self.IsFail():        inapp_storage = InappStorage.get_instance()        if inapp_storage.exists_transaction(self.platform, params[0]):            self.ThrowFail("already_purchased {0} d".format(params[0]),                           VALIDATOR_RESULT_CODE.ALREADY_PURCHASED)            self.FinalizeRequest({self.slotName: InAppSlot}, data)            return        # Try get from redis        player_platform = self.platform        if platform is not None and int(platform) == 4:            player_platform = "udp"        _prevalidate_order = self.inapp_redis.check_tid(self._player_id, tid)        if _prevalidate_order:            orders_data = Receipt.from_json(_prevalidate_order)        elif player_platform == "ios":            transaction_id = params[0]            product_id = params[1]            if not transaction_id or not product_id:                self.ThrowFail(f"fail get receipt {self.platform}")            else:                orders_data = self._get_receipt_ios(data.get("data"), data.get("test") == 1, transaction_id, product_id)        elif player_platform == "android":            product_id = params[1]            purchase_token = data.get("data")            orders_data = self._get_receipt_android(product_id, purchase_token)        elif player_platform == "amazon":            receipt_sku = params[0]            user_id = params[1]            orders_data = self._get_receipt_amazon(user_id, receipt_sku)        elif player_platform == "huawei":            product_id = params[1]            orders_data = self._get_receipt_huawei(product_id, tid, data.get("data", ""),                                                   data.get("account_flag", 0), data.get("subscribe"))        elif platform == "udp":            product_id = params[1]            orders_data = self._get_receipt_udp(product_id, params[0], data.get("data", ""))        elif platform == "samsung":            product_id = params[1]            transaction_id = params[0]            product_id = params[1]            orders_data = self._get_receipt_samsung(data.get("data", ""), product_id)        else:            self.ThrowFail("unknown platform")    if not orders_data:        self.ThrowFail(f"fail get receipt {player_platform} {self.platform}")    if not self.IsFail():        for order in orders_data:            if order.is_success():                valid_orders.append(order)        if not valid_orders:            self.ThrowFail("already_purchased {0}".format(orders_data[0].order_id),                           VALIDATOR_RESULT_CODE.ALREADY_PURCHASED)        else:            InAppSlot.append(tid)            self.SetRequestSuccessful()    if self._player_id in LOG_PLAYER_IDS:        HashLog.error(f"[INAPP] id:{self._player_id} receipt:{data}")    self.FinalizeRequest({self.slotName: InAppSlot}, data)

Команда валидации проверяет транзакцию если есть данные превалидации, то используются они. В противном случае, данные отправляются на сервер валидации для соответствующей платформы.

В случае успешного начисления, id транзакции сохраняется в соответствующий слот игрока запись в БД, которая хранит данные по платежным транзакциям данного игрока. Во избежание взлома платежки методом, когда одну валидную транзакцию используют для многократного начисления, в рамках валидации осуществляется проверка на существование данного id транзакции.

Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.

На что еще обратить внимание

Вне зависимости от платформы, для которой реализуются встроенные покупки, важно проверить и обработать следующие ситуации:

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

  2. На большинстве платформ в процессе взаимодействия с окнами платформенного магазина приложение уходит в бэкграунд, и при завершении покупки выводится из бэкграунда. За это время игра вполне может дисконнектнуться от серверов. Если для валидации или начисления покупки нужен коннект с сервером, то после возвращения в приложение нужно будет соединиться с ним вновь, и только потом производить валидацию или начисление.

  3. Нужно тестировать сценарий, когда во время покупки и валидации игрок запускает новую покупку. Мы после тестирования этого сценария обнаружили баги и добавляли запрет запуска покупки, пока идет покупка другого инаппа.

Дополнительные ссылки

И последнее: когда мы реализовывали валидацию инаппов для Google Play несколько лет назад, нам оказалось полезной статья на Хабре, вам она тоже может пригодиться.

Также использовали решения, предложенные здесь и здесь. Ссылка на документацию по API серверной валидации Google здесь.

Подробнее..

Еще пять инструментов против читеров на мобильном проекте с DAU 1 млн пользователей

27.04.2021 20:11:49 | Автор: admin

Когда-то нам пришлось полностью переработать защиту популярного PvP-шутера. Результатом стал ряд инструментов, которые мы готовили и зарелизили одновременно, чтобы не дать читерам возможность постепенно отслеживать апдейты.

Про первые пять решений обфускацию, хранение данных, миграцию прогресса и систему бана шла речь в этойстатье. Сегодня расскажу про остальные, а именно:

  • Защита от измененных версий.

  • Photon Plugin.

  • Серверная валидация инаппов.

  • Защита от взлома оперативной памяти.

  • Собственная аналитика.

И немного про то, почему так важен был одновременный релиз всех решений.

Чтобы не возникло путаницы, продолжим нумерации из прошлой статьи, где уже были опубликованы пять пунктов.

Решение 6. Защита от измененных версий

В дополнительные места мы расставили защиту от переподписывания версий, лаунчеров (на Android) и твиков (на iOS), спрятав уже в обфусцированном коде.

Проверка на твики (iOS)

На устройствах с Jailbreak с помощью Cydia пользователи могут устанавливать твики, которые способны внедрять свой код в системные и установленные приложения. Каждый твик имеет информацию (файл *.plist), с какими бандлами они должны работать.

Механизм детекта осуществляется проверкой этих файлов в папке /Library/MobileSubstrate/DynamicLibraries/ (на наличие внутри нашего бандла).

Большинство твиков, скрывающих Jailbreak, способно спрятать эту папку от просмотра, поэтому есть способ просматривать ее через созданный нами симлинк.

string finalPath = string.Empty;string substratePath = "/Library/MobileSubstrate/DynamicLibraries/";bool bySymlink = false;if (!Directory.Exists(substratePath)) //Если папки не существует (скрыт твиком xCon), то пытаемся получить доступ к файлам через созданный нами симлинк{string symlinkPath = CreateSymlimk(substratePath);if (!string.IsNullOrEmpty(symlinkPath)){bySymlink = true;finalPath = symlinkPath;}}else{finalPath = substratePath;}bool detected = false;string detectedFile = string.Empty;try{if (!string.IsNullOrEmpty(finalPath)){string[] plistFiles = Directory.GetFiles(finalPath, "*.plist"));foreach (var plistFile in plistFiles){if (File.Exists(plistFile)){StreamReader file = File.OpenText(plistFile);string con = file.ReadToEnd();string bundle = "app_bundle"; if (con.Contains(bundle)){detectedFile = plistFile;detected = true;break;}}}}}catch (Exception ex){Debug.LogError(ex.ToString());}

Но также есть твики, которые запрещают создание симлинков по проверяемому нами пути (KernBypass, A-Bypass). При их наличии мы не можем осуществить проверку, поэтому считаем это за возможное читерство.

Общего механизма детекта таких твиков нет, тут нужен индивидуальный подход.

Детект KernBypass (который был активен в отношении нашего бандла):

if (File.Exists("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist") {StreamReader file = File.OpenText("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist"); string con = file.ReadToEnd();if (con.Contains("app_bundle") {//detected}}

Определение запуска через лаунчер (Android)

Запуск приложения через лаунчер это, по сути, запуск вашего приложения внутри другого приложения (по типу Parallel Space). Некоторые реализации взломов используют такой механизм для внедрения своего кода, и для этого на устройстве не требуется root-доступ. Обычно они имитируют всю среду: выделяют папку под файлы приложения, возвращают фейковый Application Info и так далее.

При таком запуске у нас все равно сохраняется доступ ко всем файлам, к которым имеет доступ сам лаунчер. Самый простой способ детекта это проверить доступ к материнской папке от нашего приложения (dataDir в applicationInfo) через функцию access (в нативном коде). В обычном случае операционная система не предоставит доступ, а в случае лаунчера это будет папка, которая все еще находится внутри Persistent Data приложения.

Код для плагина на C:

JavaVM*java_vm;jint JNI_OnLoad(JavaVM* vm, void* reserved) {        java_vm = vm;    return JNI_VERSION_1_6;}int CheckParentDirectoryAccess(){    JNIEnv* jni_env = 0;    (*java_vm)->AttachCurrentThread(java_vm, &jni_env, NULL);    jclass uClass = (*jni_env)->FindClass(jni_env, "com/unity3d/player/UnityPlayer");    jfieldID activityID = (*jni_env)->GetStaticFieldID(jni_env, uClass, "currentActivity", "Landroid/app/Activity;");    jobject obj_activity = (*jni_env)->GetStaticObjectField(jni_env, uClass, activityID);    jclass classActivity = (*jni_env)->FindClass(jni_env, "android/app/Activity");        jmethodID mID_func = (*jni_env)->GetMethodID(jni_env, classActivity,                                                      "getPackageManager", "()Landroid/content/pm/PackageManager;");        jobject pm = (*jni_env)->CallObjectMethod(jni_env, obj_activity, mID_func);        jmethodID pmmID = (*jni_env)->GetMethodID(jni_env, classActivity,                                                      "getPackageName", "()Ljava/lang/String;");        jstring pName = (*jni_env)->CallObjectMethod(jni_env, obj_activity, pmmID);    jclass pm_class = (*jni_env)->GetObjectClass(jni_env, pm);    jmethodID mID_ai = (*jni_env)->GetMethodID(jni_env, pm_class, "getApplicationInfo","(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;");    jobject ai = (*jni_env)->CallObjectMethod(jni_env, pm, mID_ai, pName, 128);    jclass ai_class = (*jni_env)->GetObjectClass(jni_env, ai);        jfieldID nfieldID = (*jni_env)->GetFieldID(jni_env, ai_class,"dataDir","Ljava/lang/String;");    jstring nDir = (*jni_env)->GetObjectField(jni_env, ai, nfieldID);        const char *nDirStr = (*jni_env)->GetStringUTFChars(jni_env, nDir, 0);    char parentDir[200];    snprintf(parentDir, sizeof(parentDir), "%s/..", nDirStr);    if (access(parentDir, W_OK) != 0)    {         return 1;    }else{ return 0;}}

Защита от переподписи apk (Android)

При любом вмешательстве внутрь apk-файла пакет необходимо переподписать, иначе система не позволит его установить. Поэтому можно определить модифицированную игру, если хеш подписи не совпадает с нашим.

Однако такую системную защиту можно отключить, если на устройстве есть root-доступ, и устанавливать пакеты с невалидной подписью. Поэтому данный пункт не является каким-то особенно важным, но и не будет лишним в общей массе.

Получение хеша подписи в С# через обращение в Java-код:

Lazy<byte[]> defaultResult = new Lazy<byte[]>(() => new byte[20]);            if (Application.platform != RuntimePlatform.Android)                return defaultResult.Value;#if UNITY_ANDROIDvar unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");if (unityPlayer == null)throw new InvalidOperationException("unityPlayer == null");var _currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");if (_currentActivity == null)throw new InvalidOperationException("_currentActivity == null");            var packageManager = _currentActivity.Call<AndroidJavaObject>("getPackageManager");            if (packageManager == null)                throw new InvalidOperationException("getPackageManager() == null");            // http://developer.android.com/reference/android/content/pm/PackageManager.html#GET_SIGNATURES            const int getSignaturesFlag = 64;            var packageInfo = packageManager.Call<AndroidJavaObject>("getPackageInfo", PackageName, getSignaturesFlag);            if (packageInfo == null)                throw new InvalidOperationException("getPackageInfo() == null");            var signatures = packageInfo.Get<AndroidJavaObject[]>("signatures");            if (signatures == null)                throw new InvalidOperationException("signatures() == null");            using (var sha1 = new SHA1Managed())            {                var hashes = signatures.Select(s => s.Call<byte[]>("toByteArray"))                    .Where(s => s != null)                    .Select<byte[], byte[]>(sha1.ComputeHash);                var result = hashes.FirstOrDefault() ?? defaultResult.Value;                return result;            }#else            return defaultResult.Value;#endif

Решение 7. Photon Plugin

Для организации сетевого взаимодействия между пользователями в игровых комнатах мы используем Photon Cloud, который сам по себе предполагает отсутствие серверной логики и отвечает только за пересылку пакетов между пользователями. А для защиты кора нам нужна была именно серверная логика.

Переделывать всё на Photon Server было бы достаточно долгой и сложной задачей, так как в игре уже было много различных режимов, механик и прочего. Поэтому, пообщавшись с ребятами из Photon, мы решили попробовать Photon Plugin.

Photon Plugin доступен на тарифе Enterprise Cloud и пишется на С#. Он запускается на серверах Photon и позволяет мониторить пересылаемый между пользователями игровой трафик, добавлять серверную логику, которая может:

  • блокировать или добавлять сетевые сообщения;

  • контролировать изменения свойств комнат и игроков;

  • кикать из комнаты;

  • взаимодействовать при помощи http-запросов со сторонними серверами.

Мы переписали сетевое взаимодействие игроков так, чтобы можно было отследить тех, у кого действия выходят за рамки допустимого (например, количество здоровья, урон, скорость перемещения, использование запрещенных предметов и так далее), или код изменен так, что нарушается логика режимов.

Про это решение хотелось рассказать подробнее, но тогда бы статья разрослась слишком сильно. Поэтому эту тему оставили для отдельного материала.

Решение 8. Серверная валидация иннапов

В нашей игре сотни видов оружия и предметов. Несмотря на баланс и большое влияние личного скилла игроков на результат матча, все инаппы нужно защитить от взлома. Для этого мы ввели валидацию покупок.

Валидация на сервере состоит из двух этапов:

  1. Превалидация. Когда данные по платежу отправляются на сервер соответствующей платформы для проверки валидности.

  2. Начисление. В случае успешно пройденной валидации купленных позиций.

Сначала сервер получает в качестве входных параметров данные, необходимые для проведения валидации (например, на Android это id инаппа и токен).

Отдельной командой/набором команд происходит начисление купленных позиций. Одна позиция может содержать разнотипные итемы (например, деньги и оружие) и для каждого типа итема на сервере существует отдельная команда начисления.

Чтобы логически объединить несколько команд, привязанных к одному действию игрока, на клиенте и на сервере мы ввели понятие снапшота. Это специальная конструкция из последовательности команд, которые одновременно выполняются или не выполняются на сервере.

Команда валидации проверяет транзакцию в случае, если есть данные превалидации, то используются они. В противном случае данные отправляются на сервер валидации для соответствующей платформы.

В случае успешного начисления id транзакции сохраняется в соответствующий слот игрока запись в БД, которая хранит данные по платежным транзакциям данного игрока. Во избежание взлома платежки методом, когда одну валидную транзакцию используют для многократного начисления, в рамках валидации осуществляется проверка на существование данного id транзакции.

Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.

Подробнее про интеграцию инаппов и серверную валидацию вместе с кодом расскажем в отдельной статье.

Решение 9. Защита от взлома оперативной памяти

Локальные данные, которые не защищены валидацией с сервера, можно взломать в памяти (например, с помощью GameGuardian на Android). Механизм взлома заключается в поиске значений в памяти путем отсеивания. Ищется текущее значение, затем оно изменяется в игре, а среди найденных адресов в памяти они отсеиваются по новому значению, пока не будет найден нужный адрес.

Для их защиты они засаливаются при помощи случайно сгенерированной соли:

 internal int Value{get { return _salt ^ _saltedValue; }set { _saltedValue = _salt ^ value; }}

Когда пользователь не в состоянии изменить искомое значение для отсеивания, он может попытаться заменить все найденные значения на свои. Для их детекта используется простая ловушка. Следующий пример показывает, как можно определить вмешательство в память с числами от 0 до 1000 (заранее храним массив чисел, которые никогда не должны измениться, кроме как после редактирования памяти).

private static int[] refNumbers;internal static void Start(){refNumbers = new int[1000];for (int i = 0; i < refNumbers.Length; i++) {refNumbers[i] = i;}}internal static bool Check(){for (int i = 0; i < 1000; i++) {if (!refNumbers [i].Equals(i))return true;}}

Решение 10. Собственная аналитика

Изначально мы пользовались платным решением от devtodev и бесплатным от Flurry. Основная проблема была в отсутствии детализации происходящих в игре событий. Мы собирали только агрегированные данные и поверхностные метрики.

Но с ростом экспертизы внутри команды стала очевидной необходимость писать собственное решение с нужными именно нам фичами. Основная цель была в повышении продуктовых метрик и повороте компании в сторону Data-Driven подхода. Но в итоге аналитика также стала незаменимым инструментом в борьбе с читерами.Например, раньше система не привязывалась к пользователю, а просто считала ивенты. То есть мы знали, что 500 человек совершили какие-то действия, но кто эти 500 человек, и что они делали до этого нет. Сейчас можно посмотреть все действия каждого конкретного игрока и, соответственно, отследить подозрительные операции.

Все пользовательские ивенты отправляются в одну большую SQL-евскую базу. Там есть как элементарные ивенты (игрок залогинился, сколько раз в день он залогинился и так далее), так и другие. Например, прилетает ивент, что игрок покупает оружие за столько-то монет, а вместо суммы написано 0. Очевидно, что он сделал что-то неправомерное.Большинство выгрузки с подозрительными действиями нарабатываются с опытом. Например, у нас есть скрипт, который показывает, что столько-то людей с конкретными id получили определенное большое количество монет. Но это не всегда читеры обязательно нужно проверять.

Также читеров опознаем по несоответствию значений начисления валют. Аналитик знает, что за покупку инаппа начисляется конкретное количество гемов. У читеров часто это количество бывает 9999 значит, что-то взломали в памяти. Еще бывают игроки с аномальными киллрейтами. По ним у нас тоже есть специально обученное поле, и когда появляется пользователь, у которого киллрейт 15 или 30, становится понятно, что, скорее всего, это читер.В основном отслеживанием занимается один скрипт, который пачкой прогоняет по детектам и сгружает все в таблицу. Аналитики получают id и видят игроков, которые залогинились утром с огромным количеством голды, в соседнем листе лежат игроки, открывшие 1000 сундуков, в следующем игроки с тысячей гач и так далее. Затем вариантов несколько.

Предполагаемых читеров можно прогнать по флагам, а так как подобные способы читерства задетекчены и пофиксены, то подобные игроки уже могли ранее получить значок читера и попасть в отдельную базу. Если они там есть, то бан прилетает автоматически с помощью скрипта.

Если нет детекта, данные передаются разработчикам. Они думают, как читер смог обойти защиту и решают, как закрыть эти дыры, как таких игроков детектить и банить.А про причины перехода на собственную аналитическую систему и внутренние фичи тоже расскажем в отдельном материале.

Одновременный релиз всех решений

Чтобы достичь максимального эффекта от всех новых защит, было решено выкатить их одномоментно. Так значительно повышался входной порог для тех взломщиков, которые уже были хорошо знакомы с нашим необфусцированным кодом.

Кроме того, мы не просто применили обфускацию приложения, но и изменили структуру большинства значимых частей приложения. В корне была изменена работа внутри, и все это свело к минимуму имеющиеся к тому моменту у читеров наработки. А разбираться в новом стало куда гораздо сложнее.

Всего на глобальный ввод большинства защит ушло около семи месяцев.

Самым масштабным пунктом была реализация системы хранения на наших серверах именно она определяла запуск в продакшен всех решений из списка. Кроме аналитики, которая развивалась самостоятельно.

Также было важно развивать приложение в целом и релизить апдейты для игроков, чтобы не потерять их интерес, пока глобальные решения против читеров складывались отдельно в ожидании своего момента.

Сейчас игра надежно защищена и не имеет распространенных методов взлома. Иногда встречаются единичные случаи, но они быстро отслеживаются благодаря введенным инструментам.

Подробнее..

Валидация в PHP. Красота или лапша?

30.09.2020 02:10:03 | Автор: admin
Выбирая лучший PHP-валидатор из десятка популярных, я столкнулся с дилеммой. Что для меня важнее? Следование всем SOLID / ООП-канонам или удобство работы и наглядность кода? Что предпочтут пользователи фреймворка Comet? Если вы считаете, что вопрос далеко не прост добро пожаловать под кат в длинное путешествие по фрагментам кода :)


Помимо озабоченности вопросами быстродействия для REST API и микросервисов, я очень переживаю за читаемость кода, который мы ежедневно набиваем для решения рабочих задач, в том числе валидации данных.

Хочу показать куски кода из собственных бенчмарков, чтобы вы смогли оценить широту подходов к решению одной и той же проблемы. Представим, что к нам прилетел следующий набор данных:

$form = [    'name'           => 'Elon Mask',     'name_wrong'     => 'Mask',    'login'          => 'mask',     'login_wrong'    => 'm@sk',     'email'          => 'elon@tesla.com',     'email_wrong'    => 'elon@tesla_com',     'password'       => '1q!~|w2o<z',     'password_wrong' => '123456',    'date'           => '2020-06-05 15:52:00',    'date_wrong'     => '2020:06:05 15-52-00',    'ipv4'           => '192.168.1.1',    'ipv4_wrong'     => '402.28.6.12',    'uuid'           => '70fcf623-6c4e-453b-826d-072c4862d133',    'uuid_wrong'     => 'abcd-xyz-6c4e-453b-826d-072c4862d133',    'extra'          => 'that field out of scope of validation',    'empty'          => ''];

Наша цель прогнать этот массив через набор правил валидации, получив на выходе список всех полей с ошибками и стандартные сообщения для демонстрации пользователю.

Отраслевой стандарт и икона чистого ООП конечно же Symfony



use Symfony\Component\Validator\Constraints\Length;use Symfony\Component\Validator\Constraints\NotBlank;use Symfony\Component\Validator\Validation;use Symfony\Component\Validator\Constraints as Assert;use Symfony\Component\Translation\MessageSelector;$validator = Validation::createValidator();$constraint = new Assert\Collection([        'name' => new Assert\Regex('/^[A-Za-z]+\s[A-Za-z]+$/u'),       'login' => new Assert\Regex('/^[a-zA-Z0-9]-_+$/'),    'email' => new Assert\Email(),    'password' => [        new Assert\NotBlank(),        new Assert\Length(['max' => 64]),        new Assert\Type(['type' => 'string'])    ],    'agreed' => new Assert\Type(['type' => 'boolean'])]);$violations = $validator->validate($form, $constraint);$errors = [];if (0 !== count($violations)) {    foreach ($violations as $violation) {        $errors[] = $violation->getPropertyPath() . ' : ' . $violation->getMessage();    }} return $errors;

Вырвиглазный код на чистом PHP


$errors = [];if (!preg_match('/^[A-Za-z]+\s[A-Za-z]+$/u', $form['name']))    $errors['name'] = 'should consist of two words!';if (!preg_match('/^[A-Za-z]+\s[A-Za-z]+$/u', $form['name_wrong']))    $errors['name_wrong'] = 'should consist of two words!';if (!preg_match('/^[a-zA-Z0-9-_]+$/', $form['login']))    $errors['login'] = 'should contain only alphanumeric!';if (!preg_match('/^[a-zA-Z0-9]-_+$/', $form['login_wrong']))    $errors['login_wrong'] = 'should contain only alphanumeric!';if (filter_var($form['email'], FILTER_VALIDATE_EMAIL) != $form['email'])    $errors['email'] = 'provide correct email!';if (filter_var($form['email_wrong'], FILTER_VALIDATE_EMAIL) != $form['email_wrong'])    $errors['email_wrong'] = 'provide correct email!';if (!is_string($form['password']) ||    $form['password'] == '' ||    strlen($form['password']) < 8 ||    strlen($form['password']) > 64 )    $errors['password'] = 'provide correct password!';if (!is_string($form['password_wrong']) ||    $form['password_wrong'] == '' ||    strlen($form['password_wrong']) < 8 ||    strlen($form['password_wrong']) > 64 )    $errors['password_wrong'] = 'provide correct password!';if (!preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $form['date']))    $errors['date'] = 'provide correct date!';if (!preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $form['date_wrong']))    $errors['date_wrong'] = 'provide correct date!';if (filter_var($form['ipv4'], FILTER_VALIDATE_IP) != $form['ipv4'])    $errors['ipv4'] = 'provide correct ip4!';if (filter_var($form['ipv4_wrong'], FILTER_VALIDATE_IP) != $form['ipv4_wrong'])    $errors['ipv4_wrong'] = 'provide correct ip4!';if (!preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $form['uuid']))    $errors['uuid'] = 'provide correct uuid!';if (!preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $form['uuid_wrong']))    $errors['uuid_wrong'] = 'provide correct uuid!';if (!isset($form['agreed']) || !is_bool($form['agreed']) || $form['agreed'] != true)    $errors['agreed'] = 'you should agree with terms!';return $errors;

Решение на базе одной из самых популярных библитек Respect Validation


use Respect\Validation\Validator as v;use Respect\Validation\Factory;Factory::setDefaultInstance(    (new Factory())        ->withRuleNamespace('Validation')        ->withExceptionNamespace('Validation'));$messages = [];try {    v::attribute('name', v::RespectRule())        ->attribute('name_wrong', v::RespectRule())        ->attribute('login', v::alnum('-_'))        ->attribute('login_wrong', v::alnum('-_'))        ->attribute('email', v::email())        ->attribute('email_wrong', v::email())        ->attribute('password', v::notEmpty()->stringType()->length(null, 64))        ->attribute('password_wrong', v::notEmpty()->stringType()->length(null, 64))        ->attribute('date', v::date())        ->attribute('date_wrong', v::date())        ->attribute('ipv4', v::ipv4())        ->attribute('ipv4_wrong', v::ipv4())        ->attribute('uuid', v::uuid())        ->attribute('uuid_wrong', v::uuid())        ->attribute('agreed', v::trueVal())        ->assert((object) $form);} catch (\Exception $ex) {    $messages = $ex->getMessages();}return $messages;

Еще одно известное имя: Valitron


use Valitron\Validator;Validator::addRule('uuid', function($field, $value) {    return (bool) preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $value);}, 'UUID should confirm RFC style!');$rules = [    'required'  => [ 'login', 'agreed' ],    'regex'     => [ ['name', '/^[A-Za-z]+\s[A-Za-z]+$/'] ],    'lengthMin' => [ [ 'password', '8'], [ 'password_wrong', '8'] ],    'lengthMax' => [ [ 'password', '64'], [ 'password_wrong', '64'] ],    'slug'      => [ 'login', 'login_wrong' ],    'email'     => [ 'email', 'email_wrong' ],    'date'      => [ 'date', 'date_wrong' ],    'ipv4'      => [ 'ipv4', 'ipv4_wrong' ],    'uuid'      => [ 'uuid', 'uuid_wrong' ],    'accepted'  => 'agreed'];$validator = new Validator($form);$validator->rules($rules);$validator->rule('accepted', 'agreed')->message('You should set {field} value!');$validator->validate();return $validator->errors());

Прекрасный Sirius


$validator = new \Sirius\Validation\Validator;$validator    ->add('name', 'required | \Validation\SiriusRule')    ->add('login', 'required | alphanumhyphen', null, 'Only latin chars, underscores and dashes please.')    ->add('email', 'required | email', null, 'Give correct email please.')    ->add('password', 'required | maxlength(64)', null, 'Wrong password.')    ->add('agreed', 'required | equal(true)', null, 'Where is your agreement?');$validator->validate($form);$errors = [];foreach ($validator->getMessages() as $attribute => $messages) {    foreach ($messages as $message) {        $errors[] = $attribute . ' : '. $message->getTemplate();    }}return $errors;

А вот так валидируют в Laravel


use Illuminate\Validation\Factory as ValidatorFactory;use Illuminate\Translation\Translator;use Illuminate\Translation\ArrayLoader;use Symfony\Component\Translation\MessageSelector;use Illuminate\Support\Facades\Validator as FacadeValidator;$rules = array(    'name' => ['regex:/^[A-Za-z]+\s[A-Za-z]+$/u'],    'name_wrong' => ['regex:/^[A-Za-z]+\s[A-Za-z]+$/u'],    'login' => ['required', 'alpha_num'],    'login_wrong' => ['required', 'alpha_num'],    'email' => ['email'],    'email_wrong' => ['email'],    'password' => ['required', 'min:8', 'max:64'],    'password_wrong' => ['required', 'min:8', 'max:64'],    'date' => ['date'],    'date_wrong' => ['date'],    'ipv4' => ['ipv4'],    'ipv4_wrong' => ['ipv4'],    'uuid' => ['uuid'],    'uuid_wrong' => ['uuid'],    'agreed' => ['required', 'boolean']);$messages = [    'name_wrong.regex' => 'Username is required.',    'password_wrong.required' => 'Password is required.',    'password_wrong.max' => 'Password must be no more than :max characters.',    'email_wrong.email' => 'Email is required.',    'login_wrong.required' => 'Login is required.',    'login_wrong.alpha_num' => 'Login must consist of alfa numeric chars.',    'agreed.required' => 'Confirm radio box required.',);$loader = new ArrayLoader();$translator = new Translator($loader, 'en');$validatorFactory = new ValidatorFactory($translator);$validator = $validatorFactory->make($form, $rules, $messages);return $validator->messages();

Неожиданный бриллиант Rakit Validation


$validator = new \Rakit\Validation\Validator;$validator->addValidator('uuid', new \Validation\RakitRule);$validation = $validator->make($form, [    'name'           => 'regex:/^[A-Za-z]+\s[A-Za-z]+$/u',    'name_wrong'     => 'regex:/^[A-Za-z]+\s[A-Za-z]+$/u',    'email'          => 'email',    'email_wrong'    => 'email',    'password'       => 'required|min:8|max:64',    'password_wrong' => 'required|min:8|max:64',    'login'          => 'alpha_dash',    'login_wrong'    => 'alpha_dash',    'date'           => 'date:Y-m-d H:i:s',    'date_wrong'     => 'date:Y-m-d H:i:s',    'ipv4'           => 'ipv4',    'ipv4_wrong'     => 'ipv4',    'uuid'           => 'uuid',    'uuid_wrong'     => 'uuid',    'agreed'         => 'required|accepted']); $validation->setMessages([    'uuid'     => 'UUID should confirm RFC rules!',    'required' => ':attribute is required!',    // etc]);$validation->validate();return $validation->errors()->toArray();

Ну так что? Какой из примеров кода наиболее наглядный, идиоматичный, корректный и вообще правильный? Мой личный выбор в доках на Comet: github.com/gotzmann/comet

В заключение небольшой опрос для потомков.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru