Author Archive

Получение содержимого текстового файла (Spring Boot)

Вот уже несколько месяцев последние два проекта делаю с использованием Spring Boot.
Бывает так, что нужно интегрироваться с внешней системой, но эта система не готова по каким-то причинам, а нужно показать свой функционал, в этом случае пишем заглушку. Т.к. в моем случае внешний сервис давал данные в формате JSON, я решил положить пример ответа сервиса в ресурсы (resource) по пути files/cities.json.

Далее встал вопрос как его вытянуть. Погуглил, в итоге собрал такую функцию (сперва получаем контекст приложения, из которого получаем ресурс):

private static String getFileContent(String filePath) throws IOException {
    ApplicationContext appContext =
        new ClassPathXmlApplicationContext(new String[] {});
 
    Resource resource = appContext.getResource(filePath);
 
    StringBuilder sb = new StringBuilder();
    BufferedReader br = null;
    try{
        br = new BufferedReader(
            new InputStreamReader(resource.getInputStream(), "UTF-8"));
        String line;
        while ((line = br.readLine()) != null) {
            sb.append(line);
        }
    }finally {
        if(br != null) try {
            br.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    return sb.toString();
}

Пример вызова функции:

try {
    String text = getFileContent("files/cities.json");
} catch (IOException e) {
    e.printStackTrace();
}

Я использовал чтение файла для временной заглушки, но можно использовать и для других целей.
p.s. Обратите внимание, что в функции указана кодировка UTF-8. Можно её не указывать, тогда возьмется кодировка системы. Кодировку можно указать и при запуске приложения:

java -Dfile.encoding=UTF8

Совет: лучше в функции явно прописать UTF-8, если, конечно, она вам нужна.

Замер времени (Java)

Иногда возникает потребность измерить время работы определенного фрагмента кода в вашем проекте.
Можно, конечно, писать так:

long start = System.currentTimeMillis();
//тут замеряемый код
System.out.println(System.currentTimeMillis() - start);

Но такой подход быстро надоедает. Я выделил замер времени в отдельный класс TimeMeter, теперь можно удобно останавливать несколько раз таймер и измерять время, если нужно измерить промежуточные значения. Можно также использовать несколько экземпляров таймера.
Реализация:

public class TimeMeter {
    private long start;
	private long stop;
 
    public TimeMeter(){
        start = now();
    }
 
    public void stop(){
        stop = now();
    }
 
    @Override
    public String toString() {
        return getDuration() + " msec";
    }
 
    public long getDuration(){
        return (stop == 0 ? now() : stop) - start;
    }
 
    private long now(){
        return System.currentTimeMillis();
    }
}

Самый простой вариант использования:

TimeMeter timeMeter = new TimeMeter();
//тут замеряемый код
System.out.println(timeMeter);

Таймер с несколькими остановками и замерами времени:

TimeMeter timeMeter = new TimeMeter();
//тут замеряемый код 1
timeMeter.stop();
System.out.println(timeMeter);
//тут замеряемый код 2
timeMeter.stop();
System.out.println(timeMeter);

Глобальный и внутренние таймеры:

TimeMeter totalTimeMeter = new TimeMeter();
for (int i = 0; i < 100; i++) {
    TimeMeter timeMeter = new TimeMeter();
    //тут замеряемый код
    System.out.println("time " + i + ": " + timeMeter);
}
System.out.println("total time: " + totalTimeMeter);

p.s. Можете придумать и сделать свою версию, здесь простор для творчества.

$(…).size is not a function

Второй раз за последние месяцы прикручиваю тему Metronic и ловлю такую ошибку:

$(...).size is not a function

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

Всё дело в том, что Metronic использует 1-ю версию jQuery (v1.12.4), а в проекте bower брал последную 3-ю версию jQuery (v3.1.0).
В 3-ей версии почему-то убрали эту функцию.

Все решается использованием 1-й версии jQuery. В bower.json я исправил версию так:

"jquery": "1.12.4"

Запустил платное приложение Bilemin Cards в Google Play

Наконец-то, 31 октября 2016 я запустил своё первое платное приложение в Google Play под названием «Bilemin Cards».
Вот ссылка на приложение: https://play.google.com/store/apps/details?id=kz.bilemin.cards. Стоимость приложения 3$, что составляет 990 тенге.
Вообще, я настроен скептически, кажется, я вообще не заработаю денег на этом приложении, но посмотрим
В этой статье я буду через определенные промежутки времени писать о всём, что касается приложения, и что я буду делать для продвижения приложения.

Ноябрь, 2016
В ноябре я начал рекламировать приложение через adwords. Кампания длилась неделю, бюджет был 1$/день.

Показы

24 894

Взаимодействия

437 кликов

Коэффициент взаимодействия

1,76 % CTR

Средн. цена

0,02 $ за клик

Стоимость

9,60 $

Итого за 7 дней взяли 9,60$. Почему не 7$ спросите вы? Показ рекламы не может сразу остановиться, поэтому каждый день уходило более 1$.

Вывод: Реклама не сработала, никто не купил приложение. Любой бюджет можно скликать 🙂

01.12.2016
Отправил приложение на конкурс award.kz в номинацию «Мобильные приложения».
Было более 350 просмотров и один лайк.
Предварительные результаты голосования:

p.s. пока 11-е место, даже не в первой десятке 🙂
p.p.s. организаторы запросили 7 промо-кодов для оценки приложения, я же им передал 10 кодов! При своей оценке они не активировали ни одного кода!! Вообще, до этого я сгенерил 500 промо-кодов, теперь думаю как их распространить.
13.12.2016
Запустил бесплатную версию приложения: https://play.google.com/store/apps/details?id=kz.bilemin.freecards
p.s. Не пощупав, никто ничего не купит, по крайней мере, наши люди 🙂
15.12.2016
Состоялась первая покупка приложения. Неожиданно, если честно 🙂
20.12.2016
Освежил группу вконтакте по новогоднему: https://vk.com/bilemin
Создал запись: https://vk.com/bilemin?w=wall-44243064_8 и начал её рекламу через VK:

25.12.2016
На прошлой неделе я запускал рекламу ВКонтакте. Сперва рекламировал запись в сообществе:

Статистика следующая:

В итоге показы почему-то упали.
Далее замутил схему с промо-кодами, но рекламу не приняли модераторы.
В итоге решил рекламировать приложения напрямую:

Тут уже надо было платить не за показы, а за переходы.

В итоге всего 8 человек на сегодняшний день установили бесплатное приложение. Короче, реклама в VK не дала результатов.

25.12.2016
Решил рекламировать своими силами. Взял базу пользователей сайта bilemin.kz, что примерно 1100 человек и начал делать рассылку.
03.01.2017
Год завершился с такими показателями:

Если с 1000 отправок писем целевой аудитории будет хотя бы одна установка, то можно отправлять миллионы писем.
Сегодня с mail.ru выудил 438 email’ов из Казахстана и сделал рассылку.
05.01.2017
Разослал еще 4000 писем. Статистика еще не обновилась, что-то Google Play запаздывает.
Наконец-то, обновилась статистика:

14.01.2017
Что-то застопорились скачивания. Прошло более недели, а прирост мизерный.

24.01.2017
Прошло 10 дней. Несколько дней назад вновь запускал рассылку по 4000 адресам (другая выборка) и ждал пока обновиться статистика.

p.s. Уныло ждем 50 установок 🙂

Подбор окончания в соответствии с количеством

Шлифуя очередную программу и вкладывая в нее души, хочется выводить не:
«1 карточек», «2 карточек» и «5 карточек», а «1 карточка», «2 карточки» и 5 «карточек». Т.е. к слову, связанному с целым количественным числительным хочется подобрать правильное окончание.

Можно заменить, что для целых количественных числительных максимально можно выделить три различных окончания.
Эти рассуждения привели меня к написанию следующей функции:

public static String pickPhrase(int count, String word0,String word1,String word2) {
    int rem = count % 100;
    if(rem < 11 || rem > 14){
        rem = count % 10;
        if(rem == 1) return word1;
        if(rem >= 2 && rem <= 4) return word2;
    } return word0;
}

Функция pickPhrase принимает количество (count) и три вида окончаний (word0, word1, word2).
Мысленно можно выделить следующие группы:
0 карточек, 5 карточек, 6 карточек и т.д.
1 карточка, 21 карточка, 31 карточка и т.д.
2 карточки, 3 карточки, 4 карточки и т.д.
Для удобства я взял окончания для 0, 1, 2, т.к. у них троих различные окончания.
Вам только остается подобрать окончания для 0, 1, 2, а функция по переданную количеству подберет требуемое окончание.
word0 — соответствует окончанию для 0-го количества;
word1 — соответствует окончанию с количеством в 1 штуку;
word2 — соответствует окончанию с количеством в 2 штуки.

Например:
Пусть дано кол-во карточек от 0 до 100 включительно и нужно к слову «карточка» подобрать окончание в соответствии с количеством.
Это делается так:

for (int i = 0; i <= 100; i++) {
    System.out.println(i + " карточ" + pickPhrase(i, "ек", "ка", "ки"));
}

Мы выделили неизменяемую часть слова «карточ» и далее нашей функцией подобрали окончание.

0 карточек
1 карточка
2 карточки
3 карточки
4 карточки
5 карточек
6 карточек
7 карточек
8 карточек
9 карточек
10 карточек
11 карточек
12 карточек
...
96 карточек
97 карточек
98 карточек
99 карточек
100 карточек

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

for (int i = 0; i <= 100; i++) {
    System.out.println(i + " " + pickPhrase(i, "штук", "штука", "штуки"));
}
0 штук
1 штука
2 штуки
3 штуки
4 штуки
5 штук
6 штук
7 штук
8 штук
9 штук
10 штук
11 штук
12 штук
13 штук
...
90 штук
91 штука
92 штуки
93 штуки
94 штуки
95 штук
96 штук
97 штук
98 штук
99 штук
100 штук

Парсинг DN (Distinguished Names)

В ЭЦП данные о её владельце и изготовителе хранятся в виде Distinguished Names (DN).
Например:

CN=БЛОГОВ БЛОГ,SURNAME=БЛОГОВ,SERIALNUMBER=IIN123456789012,C=KZ,L=АСТАНА,S=АСТАНА,G=БЛОГОВИЧ,O=ТОО \"Рога\, копыта 24\\7\",OU=BIN000111222333

Атрибуты отделяются между собой запятыми, каждый атрибут представляет из себя ключ и значение, разделенные символом равно (=). Так же некоторые символы экранируются, например: \" \,
Предлагаю следующую функцию, которая принимает строку в формате DN и возвращается объект с соответствующими полями и значениями (я использовал angular’овский цикл, но можно заменить на обычный).

function parseDN(value){
    var parts = value
        .replace(/\\,/g,'<comma>')
        .replace(/,/g,'<delim>')
        .replace(/<comma>/g,',')
        .replace(/\\\\/g,'<backslash>')
        .replace(/\\/g,'')
        .replace(/<backslash>/g,'\\')
        .split('<delim>');
    var map = {};
    angular.forEach(parts, function(part){
        var split = part.split('=');
        map[split[0]] = split[1];
    });
    return map;
}

Пример использования:

    var map = parseDN("YOUR_DN_STRING");
    console.log(map.CN);
    console.log(map.SERIALNUMBER);
    console.log(map.O);

Интеграция Discourse с Keycloak

Статья подразумевает, что у вас уже установлены и настроены Discourse c Keycloak.
Интегрировать Discourse с Keycloak будем через плагин discourse-oauth2-basic.
Для этого нужно папку плагина discourse-oauth2-basic перенести в директорию plugins, которая находится в корневой папке Discourse.

После запуска Discourse c новым плагином, который автоматически подтянется из директории plugins, в админке появятся новые поля настроек. Их можно найти по ссылке http://localhost:3000/admin/site_settings/category/login, либо найти самостоятельно по пути: Админка -> Настройки -> Учетные записи.
Новые появившиеся поля нужно заполнить в соответствиями с настройками вашего клиента в Keycloak:

Название параметра

Значение параметра

oauth2 enabled

oauth2 client id

discourse-client

oauth2 client secret

3b0eab79-34df-4fd3-861e-6654f3bc4c13

oauth2 authorize url

http://localhost:18100/auth/realms/ltms/protocol/openid-connect/auth

oauth2 token url

http://localhost:18100/auth/realms/ltms/protocol/openid-connect/token

oauth2 user json url

http://localhost:18100/auth/realms/ltms/protocol/openid-connect/userinfo

oauth2 json user id path

sub

oauth2 json username path

name

oauth2 json name path

name

oauth2 json email path

email

oauth2 email verified

oauth2 send auth header

oauth2 button title

with OAuth2

Применив указанные выше настройки, в интерфейсе аутентификации появится кнопка для авторизации через OAuth2.
discourse-with-aouth2

После нажатия кнопки with OAuth2 (название можно поменять в настройках) открывается окно аутентификации Keycloak.
keycloak-auth
Успешно залогинившись, вы возвращаетесь в Discourse, который теперь знает о вашем присутствии 🙂

Подключение MongoDB(+GridFS) к Play2 (Scala)

В нашем новом проекте в качестве файлового хранилища мы решили использовать GridFS. GridFS эффективен для файлов размером свыше 16 МБ, поэтому мы решили хранить файлы менее 16 МБ в виде байтов в MongoDB. К тому же GridFS — это всего лишь надстройка над MongoDB, и размер документа MongoDB ограничен как раз 16 мегабайтами.
Для начала нужно в build.sbt добавить зависимость на стандартный драйвер MongoDB:

"org.mongodb" % "mongo-java-driver" % "3.3.0"

Далее в application.conf прописать переменные, который мы будем использовать для подключения к MongoDB.

mongodb.uri="mongodb://192.168.99.100:27017"
mongodb.db="your_database_name"

И, собственно, реализация класса для работы с MongoDB и GridFS:

package services.file
import javax.inject.{Inject, Singleton}
import com.mongodb.client.gridfs.{GridFSBuckets, GridFSBucket}
import com.mongodb.{MongoClient, MongoClientURI}
import play.api.{Logger, Configuration}
 
@Singleton
class MongoService @Inject() (conf: Configuration) {
  val uri = conf.underlying.getString("mongodb.uri")
  val db = conf.underlying.getString("mongodb.db")
  Logger.debug("mongodb.uri = " + uri)
  Logger.debug("mongodb.db = " + db)
  val mongoClient = new MongoClient(new MongoClientURI(uri))
  val database = mongoClient.getDatabase(db)
  val wholeFileCollection = database.getCollection("whole-files")
 
  //GridFS
  val gridFSBucket: GridFSBucket = GridFSBuckets.create(database, "chunked-files")
}

Тестовые ссылки с различными файлами

Часто бывает необходимо проверить какой-то функционал по получению файла через web. Мы либо ищем в интернете нужную ссылку, либо размещаем файлы где-то у себя на веб-сервере (который еще нужно поднять :-)).
Сегодня пришла идея сделать страницу, на которой будут размешаться ссылки на тестовые файлы, причем файлы будут храниться на этом же домене, поэтому пока работает этот домен, файлы будут доступны!

Наименование файла

Ссылка

Размер

MD5

fontan.mp4

http://kesh.kz/download/test/fontan.mp4

62080018 байт (59.2M)

ee873149a67990babe3206c5d06356eb

Гимн Казахстана.txt

http://kesh.kz/download/test/%D0%93%D0%B8%D0%BC%D0%BD%20%D0%9A%D0%B0%D0%B7%D0%B0%D1%85%D1%81%D1%82%D0%B0%D0%BD%D0%B0.txt

975 байт

edbf47380c466f267511a6893e954693

Конвертирование кортежа со списком в JSON (Scala)

Всем привет!
Меня перевели на новый проект, решили делать его на Play2(Scala).
Я джавист и тяжело с моим багажом перестраиваться, прежние подходы в разработке не работают.
Долго бился на конвертированием структуры в виде кортежа со списком (List). В итоге пришел к такому варианту:

implicit val questionReads = Json.reads[Question]
implicit val questionWrites = Json.writes[Question]
implicit val answerWrites = Json.writes[Answer]
implicit val answerReads = Json.reads[Answer]
 
implicit val questionWithAnswersWrites = new Writes[(Option[Question], List[Answer])] {
    override def writes(tuple: (Option[Question], List[Answer])) = Json.obj(
      "question" -> tuple._1,
      "answers" -> tuple._2
    )
  }
 
implicit val questionWithAnswerReads = new Reads[(Question, List[Answer])] {
    override def reads(json: JsValue) = JsSuccess((
      (json \ "question").as[Question],
      (json \ "answers").as[List[Answer]]
    ))
  }
 
def questionWithAnswers(id: Long) = Action {
    val answers: (Option[Question], List[Answer]) = questionService.questionWithAnswers(id)
    Ok(Json.toJson(answers))
  }
 
def saveWithAnswers = Action(BodyParsers.parse.json) { implicit request =>
    request.body.validate[(Question,List[Answer])].fold(
      error => {
        BadRequest(Json.obj("status" -> JsError.toJson(error)))
      },
      tuple => {
        questionService.update(tuple._1,tuple._2)
        Ok(Json.obj("status" -> "success"))
      }
    )
  }

Question и Answer — это case class’ы