---
title: Бэкенд
---

import { Card, FileTree, LinkCard, TabItem, Tabs } from '@astrojs/starlight/components';
import MultipleChoice from '../../components/MultipleChoice.astro';
import Option from '../../components/Option.astro';

В этой главе мы рассмотрим

- понятия бэкенда и фронтенда
- SQL
- Flask
- CORS

В рамках практической части создадим карту индекса качества городской среды по базе данных SQLite с бэкендом на Python. При желании посмотрите [полный код](https://github.com/gtitov/flask-maplibre-map) и [возможный результат](https://gtitov.github.io/flask-maplibre-map/).

## Бэкенд

Мы уже знаем, что веб-приложения можно разделить на клиентскую и серверную части. Разработку клиентской части называют фронтендом. Разработку серверной части называют бэкендом. Фронтенд общается с бэкендом через API. Бэкенд предоставляет методы API, а фронтенд к ним обращается.

![alt text](../../assets/image-17.png)

Когда происходит вызов метода API -- запрос определённого URL, -- выполняется соответствующая серверная функция. Для программирования серверных функций могут использоваться различные языки программирования Python, Go, Rust и даже JavaScript (NodeJS).

<Card title='В какой вкладке инструментов разработчика можно увидеть запросы, который выполняет браузер на веб-странице?'>
    <MultipleChoice>
      <Option>
        Элементы (Elements)
      </Option>
      <Option isCorrect>
        Сеть (Network)
      </Option>
      <Option >
        Консоль (Console)
      </Option>
    </MultipleChoice>
</Card>

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

## Подготовка

Создадим папку с заготовкой для нашей карты.

Добавим туда папку `backend`. Загрузим [базу данных из репозитория с полным кодом карты](https://github.com/gtitov/flask-maplibre-map/raw/refs/heads/main/backend/cities_index.sqlite) и положим в созданную папку `backend`. Cоздадим в этой папке файл с нашим бэкендом `app.py`.

<FileTree>
- backend/
  - app.py
  - cities_index.sqlite
- index.html
- style.css
- main.js
</FileTree>

<details>
<summary>Что это за база данных такая -- SQLite</summary>

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

</details>

## Разработка бэкенда

### Установка Flask

Установим Python и после установки через терминал загрузим в Python библиотеку Flask.

```sh title=Терминал
pip install Flask
```

Теперь откроем ранее созданный нами файл `backend/app.py`. Подключим необходимые библиотеки и создадим объект `app`, в рамках которого мы будем определять доступные методы API.

```py title="app.py"
from flask import Flask, Response
import sqlite3
import json
import time


app = Flask(__name__)

DB_LOCATION = "cities_index.sqlite"
```

### Список городов по году

Добавим первый метод API. Он возвращает пользователю все города за выбранный год. При обращении к этому методу бэкенд выполняет запрос к базе данных и формирует на основе ответа GeoJSON-файл, который мы сможем сразу отправить на карту.

> Мы могли бы отправить на фронтенд и неподготовленный файл. Собрать GeoJSON на клиентской стороне, как в карте вакансий, когда Google возвращал нам CSV. Но то был чужой API. А этот наш. И в нашем мы можем сделать так, как будет удобнее нам! 

```py title=app.py
...
@app.route("/cities/<year>") # путь API, к которому обращается пользователь 
def cities_by_year(year): # функция, которая будет выполняться при обращении
    # start_time = time.time()
    db = sqlite3.connect(DB_LOCATION) # подключение к базе данных
    db.row_factory = sqlite3.Row # указание, что в строках мы будем сохранять название колонки и значение
    cursor = db.execute("SELECT * FROM cities WHERE year = ?", (year,)) # выполняем запрос к базе, подставляя год, введённый пользователем
    cities = cursor.fetchall() # забираем результат запроса
    cursor.close() # закрываем запрос
    db.close() # закрываем подключение
    geojson = { # приводим к формату GeoJSON
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "geometry": {
                    "type": "Point",
                    "coordinates": [row["longitude"], row["latitude"]],
                },
                "properties": dict(row),
            }
            for row in cities
        ],
    }
    r = Response( # формируем ответ
        json.dumps(geojson, ensure_ascii=False), # ensure_ascii=False, чтобы нормально отображалась кириллица
        mimetype="application/json" # указываем тип данных
    )
    # print("--- %s seconds ---" % (time.time() - start_time))
    return r
```

Запускаем бэкенд на локальном сервере для проверки. Открываем терминал в папке `backend` и выполняем команду

```sh title=Терминал
flask run --debug
```

После чего увидим что-то вроде

```sh title=Терминал
flask run --debug
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 106-994-678
```

Теперь мы можем обратиться к нашему бэкенду через API по адресу [`http://127.0.0.1:5000/cities/2020`](http://127.0.0.1:5000/cities/2020).

### Сведения о городе по идентификатору

Перейдём ко второму методу. Получим подробные сведения о городе по его идентификатору.

```py title=app.py
@app.route("/city/<id>")
def city_by_id(id):
    start_time = time.time()
    db = sqlite3.connect(DB_LOCATION)
    db.row_factory = sqlite3.Row
    cursor = db.execute("SELECT * FROM cities WHERE id = ?", (id,))
    city = cursor.fetchone()
    cursor.close()
    db.close()
    r = Response(
        json.dumps(dict(city), ensure_ascii=False),
        mimetype="application/json",
    )
    print("--- %s seconds ---" % (time.time() - start_time))
    return r
```

Проверим этот метод [`http://127.0.0.1:5000/city/1000`](http://127.0.0.1:5000/city/1000).

## Разработка фронтенда

### Подключение данных и CORS

Наш бэкенд возвращает данные в формате GeoJSON, поэтому мы можем сразу подключить их в нашу карту. Однако на карте мы не увидим искомых городов. Чтобы узнать почему, проверим вкладку "Сеть" в инструментах разработчика. У запроса к списку городов мы увидим надпись **Ошибка CORS**.

```js title=main.js
map.on("load", () => {
    map.addSource('cities', {
        type: 'geojson',
        data: "http://localhost:5000/cities/2020" // бэкенд должен быть запущен
    });

    map.addLayer({
        'id': 'cities-layer',
        'source': 'cities',
        'type': 'circle',
        'paint': {
            'circle-stroke-width': 1,
            'circle-stroke-color': '#FFFFFF',
            // SELECT MIN(total_points), MAX(total_points) FROM cities
            'circle-color': [
                'interpolate', ['linear'],
                ['get', 'total_points'],
                50, '#d7191c',
                150, '#ffffbf',
                250, '#1a9641'
            ],
            'circle-opacity': 0.8,
            // SELECT DISTINCT group_name FROM cities
            'circle-radius': [
                "match",
                ['get', 'group_name'],
                'Малый город', 3,
                'Средний город', 6,
                'Большой город', 6,
                'Крупный город', 8,
                'Крупнейший город', 12,
                0 // остальные
            ]
        }
    });
})
```

Механизм CORS -- Cross-Origin Resource Sharing -- призван повысить безопасность веб-страницы. Чтобы избежать ошибки CORS, укажем, что API может отвечать на запросы любых веб-страниц.

> CORS -- это история про *веб-страницы*, поэтому, выполняя запросы к API напрямую, мы с ней не сталкивались.

```diff lang=py title=app.py
...
@app.route("/cities/<int:year>") # путь API, к которому обращается пользователь 
def cities_by_year(year): # функция, которая будет выполняться при обращении
    ...
    r = Response( # формируем ответ
        json.dumps(geojson, ensure_ascii=False), # ensure_ascii=False, чтобы нормально отображалась кириллица
        mimetype="application/json", # указываем тип данных
+        headers={"Access-Control-Allow-Origin": "*"}
    )
    # print("--- %s seconds ---" % (time.time() - start_time))
    return r

@app.route("/city/<int:id>")
def city_by_id(id):
    ...
    r = Response(
        json.dumps(dict(city), ensure_ascii=False),
        mimetype="application/json",
+        headers={"Access-Control-Allow-Origin": "*"}
    )
    # print("--- %s seconds ---" % (time.time() - start_time))
    return r
```

После добавления заголовков о том, что API готов отдавать данные на любые веб-страницы, в ответ мы получаем наш список городов в формате GeoJSON, который выводится на карту.

### Выбор года

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

Разметим элемент с выпадающим списком.

```diff lang=html title=index.html
<body>
  <div id='map'></div>
+  <div>
+    <p>Год</p>
+    <select id="year-selector">
+      <option value="2020" selected>2020</option>
+      <option value="2019">2019</option>
+      <option value="2018">2018</option>
+    </select>
+  </div>
  <script src="main.js"></script>
</body>
```

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

```diff lang=css title=style.css
#map {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
+    z-index: -1;
}
```

Запрограммируем реакцию карты на выбор года. Асинхронный запрос за нас выполнит картографическая библиотека.

```js title=main.js
map.on("load", () => {
    ...
    document.getElementById("year-selector").addEventListener(
        'change',
        (e) => {
            const year = e.target.value // фиксируем выбранный год
            map.getSource('cities').setData(`http://localhost:5000/cities/${year}`) // меняем источник данных
        }
    )
})
```

### Сведения о городе

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

```diff lang=html title=index.html
<body>
  <div id='map'></div>
  ...
+  <dialog id="city-details-modal" onmousedown="this.close()"></dialog>
  <script src="main.js"></script>
</body>
```

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

<Card title='За что обычно отвечает первый блок <code>.then</code> в цепочке <code>fetch...then</code> при запросе данных с сервера?'>
    <MultipleChoice>
      <Option>
        логирование данных в консоль
      </Option>
      <Option>
        обработка возможных ошибок
      </Option>
      <Option isCorrect>
        извлечение данных из ответа сервера
      </Option>
    </MultipleChoice>

    <details>
      <summary>Узнать ответ</summary>
      В первом блоке `then` нам доступен специальный объект ответа (Response), но мы ещё не имеем доступа к данным, поэтому мы должны извлечь их, например, методом `response.json()` или `response.text()`.
    </details>
</Card>

```js title=main.js
map.on("load", () => {
    ...
    map.on('click', 'cities-layer', (e) => {
        // console.log(e.features[0].properties.id)
        fetch(`http://localhost:5000/city/${e.features[0].properties.id}`)
            .then(response => response.json())
            .then(cityProperties => {
                // console.log(cityProperties)
                document.getElementById("city-details-modal").innerHTML = `<h1>${cityProperties.name}</h1>
                            <img src="${cityProperties.emblem_url}" height="200">
                            <h3>Численность населения</h3><h2>${cityProperties.people_count} тыс. чел</h2>
                            <h3>Индекс качества городской среды</h3><h2>${cityProperties.total_points} / 360</h2>
                            <hr>
                            <h3>Жилье и прилегающие пространства</h3><h2>${cityProperties.house_points} / 60</h2>
                            <h3>Озелененные пространства</h3><h2>${cityProperties.park_points} / 60</h2>
                            <h3>Общественно-деловая инфраструктура</h3><h2>${cityProperties.business_points} / 60</h2>
                            <h3>Социально-досуговая инфраструктура</h3><h2>${cityProperties.social_points} / 60</h2>
                            <h3>Улично-дорожная</h3><h2>${cityProperties.street_points} / 60</h2>
                            <h3>Общегородское пространство</h3><h2>${cityProperties.common_points} / 60</h2>`
                document.getElementById("city-details-modal").showModal() // showModal() -- встроенный метод элемента <dialog>
            })
    })

    map.on('mouseenter', 'cities-layer', () => {
        map.getCanvas().style.cursor = 'pointer';
    });

    map.on('mouseleave', 'cities-layer', () => {
        map.getCanvas().style.cursor = '';
    });
})
```

> Если вы уже заметили кое-какую нестыковку в наших запросах, посмотрите упражнения в конце главы

## Что мы получили

Наши карты становятся всё лучше!

При желании посмотрите [полный код](https://github.com/gtitov/flask-maplibre-map) и [возможный результат](https://gtitov.github.io/flask-maplibre-map/).

В первой карте мы брали данные с сервера *как есть*, поэтому карта относилась к статическим. Во второй карте мы использовали готовый бэкенд и обращались к нему по API.

Для этой карты мы сами разработали логику работы серверной части, другими словами, разработали бэкенд:

1. Фронтенд обращается к методу API, который предоставляет данные о городах (этот метод мы объявили самостоятельно).
1. Обращение к методу API инициирует выполнение серверной функции (эту функцию мы написали самостоятельно).
1. В рамках функции мы обращаемся к базе данных (здесь похвастаться нечем, базу данных мы загрузили готовую) и перерабатываем полученные данные в формат GeoJSON.
1. Формируем ответ и отправляем с сервера на клиентскую часть, в браузер. Так как полученный ответ имеет формат GeoJSON, он подаётся на вход карте в качестве источника данных сразу в противовес предыдущему упражнению, где данные от внешнего API приходится [трансформировать](/2-api/#преобразование-данных), прежде чем использовать как источник данных для карты.

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

## Упражнения

1. Создайте метод, который вернёт список доступных годов
2. Выведите модальное окно слева и выполните подлёт к точке клика
3. Вы могли заметить, что данные, которые мы получаем запросом подробных сведений о городе, уже содержатся в полном списке городов: вам предлагается избавиться от этой избыточности
<details>
<summary>Есть два варианта -- подумайте над ними. Это тест на то, что вам ближе, бэкенд или фронтенд. Когда подумаете, можно посмотреть разгадку 👀</summary>
Бэкендер: можно убрать из метода для списка городов лишние атрибуты<br/>Фронтендер: на клик по объекту можно не обращаться к серверу, а использовать данные из атрибутов объекта
</details>
4. При клике по городу выведите информацию об Индексе качества городской среды за все доступные в базе данных года

## Контрольные вопросы

1. Сформулируйте запрос для получения всех данных из таблицы `cities` базы данных `cities.sqlite`
1. На каком языке программирования сформулирован этот запрос?
1. Почему `<year>` пишется в угловых скобках в конструкции `@app.route("/cities/<year>")`?
1. В каком формате бэкенд возвращает подробные сведения о городе по идентификатору?
1. Для чего к ответу добавляется заголовок `"Access-Control-Allow-Origin": "*"`?

## Чтение

1. Что такое CORS / Дока [[↗]](https://doka.guide/tools/cors/)
1. Безопасность веб-приложений и распространённые атаки / Дока [[↗]](https://doka.guide/tools/web-security/)