Перейти к содержимому

Бэкенд

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

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

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

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

alt text

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

В какой вкладке инструментов разработчика можно увидеть запросы, который выполняет браузер на веб-странице?

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

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

Добавим туда папку backend. Загрузим базу данных из репозитория с полным кодом карты и положим в созданную папку backend. Cоздадим в этой папке файл с нашим бэкендом app.py.

  • Директорияbackend/
    • app.py
    • cities_index.sqlite
  • index.html
  • style.css
  • main.js
Что это за база данных такая — SQLite

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

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

Терминал
pip install Flask

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

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. А этот наш. И в нашем мы можем сделать так, как будет удобнее нам!

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 и выполняем команду

Терминал
flask run --debug

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

Терминал
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.

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

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.

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

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 напрямую, мы с ней не сталкивались.

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, который выводится на карту.

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

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

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>

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

style.css
#map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: -1;
}

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

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}`) // меняем источник данных
}
)
})

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

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

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

За что обычно отвечает первый блок .then в цепочке fetch...then при запросе данных с сервера?

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

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 = '';
});
})

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

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

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

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

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

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

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

  1. Создайте метод, который вернёт список доступных годов
  2. Выведите модальное окно слева и выполните подлёт к точке клика
  3. Вы могли заметить, что данные, которые мы получаем запросом подробных сведений о городе, уже содержатся в полном списке городов: вам предлагается избавиться от этой избыточности

Есть два варианта — подумайте над ними. Это тест на то, что вам ближе, бэкенд или фронтенд. Когда подумаете, можно посмотреть разгадку 👀 Бэкендер: можно убрать из метода для списка городов лишние атрибуты
Фронтендер: на клик по объекту можно не обращаться к серверу, а использовать данные из атрибутов объекта

  1. При клике по городу выведите информацию об Индексе качества городской среды за все доступные в базе данных года
  1. Сформулируйте запрос для получения всех данных из таблицы cities базы данных cities.sqlite
  2. На каком языке программирования сформулирован этот запрос?
  3. Почему <year> пишется в угловых скобках в конструкции @app.route("/cities/<year>")?
  4. В каком формате бэкенд возвращает подробные сведения о городе по идентификатору?
  5. Для чего к ответу добавляется заголовок "Access-Control-Allow-Origin": "*"?
  1. Что такое CORS / Дока [↗]
  2. Безопасность веб-приложений и распространённые атаки / Дока [↗]