Pull to refresh

Рецепты nginx: виджет Государственного Адресного Реестра

Reading time9 min
Views3K

Для приготовления виджета Государственного Адресного Реестра, кроме базы, нам также понадобится nginx и его плагины postgres и json. Можно воспользоваться готовым образом.

В базе была определена большая функция gar_select от json и возвращающая json. Вот как раз она и нужна для использования через nginx.

Итак, сначала добавляем (в http-секцию вне server-секций) апстрим для подключения к базе:

upstream gar {
  	# подключаемся к хосту postgres
  	# на порт 5432
  	# к базе gar
  	# под пользователем gar
    postgres_server host=postgres port=5432 dbname=gar user=gar;
  	# задаём лог для апстрима
    postgres_log /var/log/nginx/gar.err;
  	# будем держать по одному постоянному соединению с базой
  	# (на каждый рабочий процеесс)
    postgres_keepalive 1;
  	# будем держать по одному подготовленному оператору
  	# (на каждое постоянное соединение с базой)
    postgres_prepare 1 overflow=deallocate;
}

а в server-секцию добавляем использование

location =/gar_select.json {
  	# задаём логи
    access_log /var/log/nginx/gar_select.json.log main;
    error_log /var/log/nginx/gar_select.json.err;
  	# направляем запрос на определённый выше апстрим
    postgres_pass gar;
  	# разрешаем использование подготовленных операторов
    postgres_prepare true;
  	# задаём собственно запрос
  	# (просто вызывая ту самую функцию gar_select,
  	# передавая ей в качестве аргумента GET-параметры
  	# в виде json)
  	# ** сразу хочу предупредить: здесь НЕТ sql-инъекций! **
    postgres_query "select gar_select($json_get_vars::json)";
  	# в результате выдаём json
    postgres_output json;
}

Т.о. получаем что-то типа REST интерфейса к таблице gar, который будем использовать из JavaScript.

Сначала загрузим стили и скрипты в index.html

<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <title>ГАР</title>
        <link rel="stylesheet" href="/static/bootstrap-5.1.1/css/bootstrap.css" />
        <link rel="stylesheet" href="/static/select2-4.1.0-rc.0/css/select2.css" />
        <script src="/static/jquery-3.4.1/jquery-3.4.1.js"></script>
        <script src="/static/bootstrap-5.1.1/js/bootstrap.js"></script>
        <script src="/static/select2-4.1.0-rc.0/js/select2.js"></script>
        <script src="/static/select2-4.1.0-rc.0/js/i18n/ru.js"></script>
        <script src="index.js"></script>
    </head>
    <body>
        <form action="gar_select.html" method="get"><table class="table"><tr>
            <td>gar-select:</td>
            <td><select id="id_label_single" class="gar-select" name="id" data-width="100%" data-placeholder="Адрес" data-initial="9ae64229-9f7b-4149-b27a-d1f6ec74b5ce" data-id="[b5701907-1537-4e52-b93a-d566a47086f7,daa79543-d4e1-4cf7-bd3a-5936e670aea8]"/></td>
            <td><input class="btn btn-primary" type="submit" value="OK"/></td>
        </tr></table></form>
    </body>
</html>

а вот сам скрипт index.js

// после загрузки страницы
$(document).ready(function() {
  	// для каждого селекта с соответствующим классом
    $('select.gar-select').each(function(number, select) {
      	// определяем функцию от элемента
        function data(item) {return {
          	// возвращающую строку для select2
            id: $.isArray(item) ? item[item.length - 1].id : item.id,
            item: item,
            text: $.isArray(item) ? item.map(function(item) {return item.text}).join(', ') : item.text,
        }}
      	// а также функцию от элемента для его применения в select2
        function select2(item) {$(select).data('select2').trigger('select', {data: data(item)})}
      	// инициализируем select2
        $(select).select2({
          	// будем делать запросы
            ajax: {
              	// формируем GET-параметры для функции gar_select
                data: function(params) {return {
                    full: !$.isEmptyObject(select.value) || ($.isEmptyObject(params.term) && $.isEmptyObject(select.value) && !$.isEmptyObject(select.dataset.id)),
                    id: $.isEmptyObject(params.term) && $.isEmptyObject(select.value) && !$.isEmptyObject(select.dataset.id) ? select.dataset.id : undefined,
                    limit: select.dataset.limit || 10,
                    offset: ((params.page || 1) - 1) * (select.dataset.limit || 10),
                    parent: !$.isEmptyObject(select.value) ? select.value : undefined,
                    text: params.term,
                }},
                dataType: 'json', // задаём тип результата
                delay: 300, // и задержку
              	// задаём функцию обработки результата
                processResults: function(response, params) {return {
                    results: response.data.map(data),
                    pagination: {more: response.offset < response.count}
                }},
              	// адрес можно задать прямо в теге
                url: select.dataset.url || 'gar_select.json',
            },
            allowClear: true, // показываем кнопку очистки
            closeOnSelect: false, // не закрываем при выборе
            dropdownParent: $(select).parent(), // располагаем в родителе
            language: 'ru', // использеум русский язык
        })
      	// при нажатии на кнопку очистки
        $(select).on('select2:clear', function(event) {
          	// получаем элемент
            var item = event.params.data[0].item
            if ($.isArray(item)) {
              	// и если ещё не дошли до верха
                item = item.slice(0, -1)
              	// то показываем родителя
                if (item.length) {select2(item)}
            }
          	// открываем выбор
            setTimeout(function() {$(select).select2('open')})
        })
      	// при выборе
        $(select).on('select2:select', function(event) {
          	// запускаем поиск
            $(select).data('select2').trigger('query', {})
          	// очищаем строку поиска
            $(select).parent().find('.select2-search__field').val('').trigger('focus')
        })
      	// если задан первоначальный уид
        if (select.dataset.initial) {$.ajax({
          	// выполняем запрос
            url: $(select).data('select2').options.options.ajax.url,
            dataType: $(select).data('select2').options.options.ajax.dataType,
            data: {id: select.dataset.initial}
        }).done(function(response) {
          	// и выбираем результат
            select2(response.data)
          	// закрываем выбор
            $(select).select2('close')
        })}
    })
})

Ещё, с помощью плагинов evaluate и mustach можно организовать html-интерфейс:

location =/gar_select.html {
  	# задаём логи
    access_log /var/log/nginx/gar_select.html.log main;
    error_log /var/log/nginx/gar_select.html.err;
  	# вычисляем соответсвующие под-запросы
    # в соотвествующие переменные
    evaluate $json /gar_select.json;
    evaluate $template /gar_select.template;
  	# задаём данные для шаблонизатора
    mustach_json $json;
  	# задаём шаблон
    mustach_template $template;
  	# задаём тип результата
    mustach_content text/html;
}
location =/gar_select.template {
    access_log /var/log/nginx/gar_select.template.log main;
    error_log /var/log/nginx/gar_select.template.err;
    internal;
}

Собственно сам шаблон gar_select.template

<html>
    <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <title>ГАР</title>
        <link rel="stylesheet" href="/static/bootstrap-5.1.1/css/bootstrap.min.css" />
        <script src="/static/jquery-3.4.1/jquery-3.4.1.min.js"></script>
        <script src="/static/bootstrap-5.1.1/js/bootstrap.min.js"></script>
        <script>
            var limit = {{limit}};
            var offset = {{offset}};
            var count = {{count}};
        </script>
        <script src="gar_select.js"></script>
    </head>
    <body>
        <form action="" method="get">
            <input type="hidden" name="child" value="true"/>
            <table class="table table-bordered table-striped table-hover table-condensed text-nowrap">
                <thead>
                    <th class="id" title="id"><input style="width:100%" class="offset" placeholder="id" name="id" value='{{query.id}}'/></th>
                    <th class="parent" title="parent"><input style="width:100%" class="offset" placeholder="parent" name="parent" value="{{query.parent}}"/></th>
                    <th class="name" title="name"><input style="width:100%" class="offset" placeholder="name" name="name" value="{{query.name}}"/></th>
                    <th class="short" title="short"><input style="width:100%" class="offset" placeholder="short" name="short" value="{{query.short}}"/></th>
                    <th class="type" title="type"><input style="width:100%" class="offset" placeholder="type" name="type" value="{{query.type}}"/></th>
                    <th class="post" title="post"><input style="width:100%" class="offset" placeholder="post" name="post" value="{{query.post}}"/></th>
                    <th class="object" title="object"><input style="width:100%" class="offset" placeholder="object" name="object" value="{{query.object}}"/></th>
                    <th class="region" title="region"><input style="width:100%" class="offset" placeholder="region" name="region" value="{{query.region}}"/></th>
                    <th class="text" title="text"><input style="width:100%" class="offset" placeholder="text" name="text" value="{{query.text}}"/></th>
                    <th class="child" title="child"><input style="width:100%" class="" type="submit" value="child"/></th>
                </thead>
                {{#data}}
                <tr>
                    <td class="id" title="id">{{id}}</td>
                    <td class="parent" title="parent"><a href="gar_select.html?child=true&id={{parent}}">{{parent}}</a></td>
                    <td class="name" title="name">{{name}}</td>
                    <td class="short" title="short">{{short}}</td>
                    <td class="type" title="type">{{type}}</td>
                    <td class="post" title="post">{{post}}</td>
                    <td class="object" title="text">{{object}}</td>
                    <td class="region" title="text">{{region}}</td>
                    <td class="text" title="text">{{text}}</td>
                    <td class="child" title="child"><input name="child" type="button" value="{{child}}" parent="{{id}}"/></td>
                </tr>
                {{/data}}
            </table>
            <table class="table"><tr>
                <td id="pagination"/>
                <td>limit: <input title="limit" class="offset" placeholder="limit" name="limit" value="{{limit}}"/></td>
                <td>offset: <input title="offset" class="" placeholder="offset" name="offset" value="{{offset}}"/></td>
                <td id="record"/><td>Всего {{count}}</td>
            </tr></table>
        </form>
    </body>
</html>

и скрипт для пагинации gar_select.js

// после загрузки страницы
document.addEventListener('DOMContentLoaded', function() {
  	// вычисляем количество страниц
    var pages = Math.ceil(count / limit)
    // вычисляем текущую страницу
    var page = Math.ceil(offset / limit) + 1
    // если все резульаты помещяются на одну страницу
    if (count <= limit) {
      	// то скрываем пагинацию
        $('#pagination').parent().parent().hide()
    }
  	// если есть результаты
    if (count > 0) {
      	// то добавляем информацию об этом
        $('#record').append('Записи с ' + (offset + 1) + ' по ' + Math.min(offset + limit, count))
    } else {
      	// иначе - скрываем
        $('#record').hide()
    }
  	// рассчитываем кнопки пагинации
    for (var pag = 1, more = false; pag <= pages; pag++) {
        if (pages <= 15 || pag <= 3 || pag >= pages - 2 || (page - 1 <= pag && pag <= page + 1)) {
            $('#pagination').append(
                $('<input>', {
                    type: 'button',
                    value: pag,
                    disabled: pag == page,
                }).click(function(event) {
                    $('input[name=offset]').val((this.value - 1) * limit)
                    $('form').submit()
                })
            )
            more = false
        } else if (!more && (pag == 4 || pag == pages - 3)) {
            $('#pagination').append($('<span>').text('...'))
            more = true
        }
    }
  	// при изменении в верхних фильтрах
    $('input.offset').change(function(event) {
      	// сбрасываем офсет
        $('input[name=offset]').val(0)
    })
  	// по клике на дочерних
    $('input[name=child]').click(function(event) {
      	// сбрасываем офсет
        $('input[name=offset]').val(0)
      	// и всё, кроме количества
        $('input.offset').not('[name=limit]').val('')
      	// задаём родителя
        $('input[name=parent]').val($(this).attr('parent'))
      	// и отправляем форму
        $('form').submit()
    })
})

Как пример того, что плагин evaluate может обрабатывать вложенные под-запросы, и в качестве примера работы плагина htmldoc можно html преобразовать в pdf:

location =/gar_select.pdf {
  	# задаём логи
    access_log /var/log/nginx/gar_select.pdf.log main;
    error_log /var/log/nginx/gar_select.pdf.err;
  	# вычисляем под-запрос в переменную
    evaluate $html /gar_select.html;
  	# преобразуем html в pdf
    html2pdf $html;
}

Т.о., можно в любом месте, где нужен выбор адреса использовать виджет. Его же можно ипользовать и для поиска. Например, если нужно "найти все услуги на такой-то улице", то пользователь выбирает улицу в виджете, бакенд получает уид этой улицы, а по нему с помощью готовой функции gar_select_child получет уиды всех дочерних элементов (домов, квартир, комнат, ...) с помощью которых уже может фильтровать в своей базе.

-- создаём или меняем функцию от ...
CREATE OR REPLACE FUNCTION gar_select_child(parent uuid, name text, short text, type text, post text, region text) RETURNS SETOF gar_view LANGUAGE sql STABLE AS $body$
    with _ as (
        with recursive _ as ( -- рекурсивно
          	-- начиная сверху или с заданного элемента
            select gar.*, 0 as i from gar where (gar_select_child.parent is null and parent is null) or id = gar_select_child.parent
            union
          	-- и продолжая вниз
            select gar.*, _.i + 1 as i from gar inner join _ on _.id = gar.parent
        ) select * from _ where i > 0
      	-- добавляя если нужно различные фильтры
        and (gar_select_child.name is null or name ilike gar_select_child.name||'%' or name ilike '% '||gar_select_child.name||'%' or name ilike '%-'||gar_select_child.name||'%' or name ilike '%.'||gar_select_child.name||'%')
        and (gar_select_child.short is null or short ilike gar_select_child.short)
        and (gar_select_child.type is null or case when gar_select_child.type ilike '{%}' then type = any(gar_select_child.type::text[]) else type ilike gar_select_child.type||'%' end)
        and (gar_select_child.post is null or post ilike gar_select_child.post||'%')
        and (gar_select_child.region is null or case when gar_select_child.region ilike '{%}' then region = any(gar_select_child.region::smallint[]) else region = gar_select_child.region::smallint end)
        order by i
    ) select id, parent, name, short, type, post, region, gar_text(name, short, type) AS text from _ order by to_number('0'||name, '999999999'), name;
$body$;
Tags:
Hubs:
Total votes 9: ↑6 and ↓3+5
Comments2

Articles