ПР. Сокращатель ссылок

Кратко:

  • Создание сокращателя ссылок с использованием сервисов Yandex Cloud.
  • Создание сервисного аккаунта, добавление ролей и создание бекета в Object Storage.
  • Создание бессерверной базы данных YDB и создание таблицы с помощью SQL-скрипта.
  • Создание функции для обработки ссылок и вставка кода в файл index.py.
  • Настройка Yandex API Gateway с использованием спецификации for-serverless-shortener.yml.
  • Запуск и проверка работоспособности созданного приложения.
  • Возможность дальнейшего развития и расширения функциональности приложения.

Практическая работа. Сокращатель ссылок

В рамках этого курса вы изучили несколько ключевых сервисов Yandex Cloud, относящихся к группе Serverless. Давайте объединим их для решения ещё одной практической задачи и создадим сервис, который конвертирует длинные ссылки в короткие.

Шаг 1. Сервисный аккаунт

Создание аккаунта

Создайте сервисный аккаунт с именем serverless-shortener:
 export SERVICE_ACCOUNT_SHORTENER_ID=$(yc iam service-account create --name serverless-shortener \
  --description "service account for serverless" \
  --format json | jq -r .) 
Проверьте текущий список сервисных аккаунтов:
yc iam service-account list
После проверки запишите идентификатор созданного сервисного аккаунта в переменную SERVICE_ACCOUNT_SHORTENER_ID:
echo "export SERVICE_ACCOUNT_SHORTENER_ID=<идентификатор сервисного аккаунта>" >> ~/.bashrc && . ~/.bashrc
echo $SERVICE_ACCOUNT_SHORTENER_ID

Назначение ролей

Добавьте созданному сервисному аккаунту роли editor, storage.viewer и ydb.admin:
echo "export FOLDER_ID=$(yc config get folder-id)" >> ~/.bashrc && . ~/.bashrc
echo $FOLDER_ID

echo "export OAUTH_TOKEN=$(yc config get token)" >> ~/.bashrc && . ~/.bashrc
echo $OAUTH_TOKEN

echo "export CLOUD_ID=$(yc config get cloud-id)" >> ~/.bashrc && . ~/.bashrc
echo $CLOUD_ID

yc resource-manager folder add-access-binding $FOLDER_ID \
  --subject serviceAccount:$SERVICE_ACCOUNT_SHORTENER_ID \
  --role editor

yc resource-manager folder add-access-binding $FOLDER_ID \
  --subject serviceAccount:$SERVICE_ACCOUNT_SHORTENER_ID \
  --role ydb.admin

yc resource-manager folder add-access-binding $FOLDER_ID \
  --subject serviceAccount:$SERVICE_ACCOUNT_SHORTENER_ID \
  --role storage.viewer

Шаг 2. Создание бакета в Object Storage

Сделаем для нашего сервиса веб-интерфейс. Поскольку это будет статическая веб-страница, разместим её в объектном хранилище.
В консоли управления в вашем рабочем каталоге выберите сервис Object Storage. Нажмите кнопку Создать бакет.
На странице создания бакета:
  1. Введите имя бакета. В нашем примере это будет storage-for-serverless-shortener.
  2. Ограничьте максимальный размер бакета (например 1 ГБ).
  3. Выберите тип доступа Публичный во всех случаях.
  4. Выберите класс хранилища Стандартное.
Нажмите кнопку Создать бакет для завершения операции.
image
Создайте файл index.html и загрузите его в созданный бакет — это будет стартовая страничка для нашего сокращателя:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Сокращатель URL</title>
    <!-- предостережет от лишнего GET запроса на адрес /favicon.ico -->
    <link rel="icon" href="data:;base64,iVBORw0KGgo=">
</head>

<body>
<h1>Добро пожаловать</h1>
<form action="javascript:shorten()">
    <label for="url">Введите ссылку:</label><br>
    <input id="url" name="url" type="text"><br>
    <input type="submit" value="Сократить">
</form>
<p id="shortened"></p>
</body>

<script>
    function shorten() {
        const link = document.getElementById("url").value
        fetch("/shorten", {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: link
        })
            .then(response => response.json())
            .then(data => {
                const url = data.url
                document.getElementById("shortened").innerHTML = `<a href=${url}>${url}</a>`
            })
            .catch(error => {
                document.getElementById("shortened").innerHTML = `<p>Произошла ошибка ${error}, попробуйте еще раз</p>`
            })
    }
</script>

</html>

Шаг 3. Создание базы данных

Создадим бессерверную базу данных YDB с именем for-serverless-shortener. Чтобы не переключаться из терминала, снова воспользуемся CLI. Обязательно укажите флаг --serverless для выбора типа создаваемой базы данных.
yc ydb database create for-serverless-shortener \
  --serverless \
  --folder-id $FOLDER_ID

yc ydb database list
Далее выполните команду:
yc ydb database get --name for-serverless-shortener
В выводе вы увидите значение endpoint. Оно состоит из двух частей: собственно эндпоинта (обычно это ydb.serverless.yandexcloud.net:2135) и пути базы данных (он указывается после ключевого слова database и начинается с символа /, например /ru-central1/...).
Сохраним адрес эндпоинта в переменную YDB_ENDPOINT, а путь базы данных — в переменную YDB_DATABASE. Они пригодятся нам для подключения функции.
echo "export YDB_ENDPOINT=<YDB_ENDPOINT>" >> ~/.bashrc && . ~/.bashrc
echo $YDB_ENDPOINT

echo "export YDB_DATABASE=<YDB_DATABASE>" >> ~/.bashrc && . ~/.bashrc
echo $YDB_DATABASE
Для дальнейшей работы нам понадобится утилита интерфейса командной строки YDB CLI:
curl https://storage.yandexcloud.net/yandexcloud-ydb/install.sh | bash
С помощью CLI создадим авторизованный ключ сервисного аккаунта serverless-shortener:
yc iam key create \
--service-account-name serverless-shortener \
--output serverless-shortener.sa
Сохраним путь к файлу с ключом в переменную окружения:
echo "export SA_KEY_FILE=$PWD/serverless-shortener.sa" >> ~/.bashrc && . ~/.bashrc
echo $SA_KEY_FILE
Проверим работоспособность с помощью команды:
ydb \
  --endpoint $YDB_ENDPOINT \
  --database $YDB_DATABASE \
  --sa-key-file $SA_KEY_FILE \
  discovery whoami \
  --groups
  1. Сохраним в файл links.yql SQL-скрипт для создания таблицы:
    CREATE TABLE links
    (
        id Utf8,
        link Utf8,
        PRIMARY KEY (id)
    );
    COMMIT;
 
Запустите создание таблицы, а затем проверьте результат:
ydb \
  --endpoint $YDB_ENDPOINT \
  --database $YDB_DATABASE \
  --sa-key-file $SA_KEY_FILE \
  scripting yql --file links.yql

ydb \
  --endpoint $YDB_ENDPOINT \
  --database $YDB_DATABASE \
  --sa-key-file $SA_KEY_FILE \
  scheme describe links

Шаг 4. Создание функции

В рабочем каталоге создайте файл index.py:
import ydb
import urllib.parse
import hashlib
import base64
import json
import os


def decode(event, body):
    # тело запроса может быть закодировано
    is_base64_encoded = event.get('isBase64Encoded')
    if is_base64_encoded:
        body = str(base64.b64decode(body), 'utf-8')
    return body


def response(statusCode, headers, isBase64Encoded, body):
    return {
        'statusCode': statusCode,
        'headers': headers,
        'isBase64Encoded': isBase64Encoded,
        'body': body,
    }


def get_config():
    endpoint = os.getenv("endpoint")
    database = os.getenv("database")
    if endpoint is None or database is None:
        raise AssertionError("Нужно указать обе переменные окружения")
    credentials = ydb.construct_credentials_from_environ()
    return ydb.DriverConfig(endpoint, database, credentials=credentials)


def execute(config, query, params):
    with ydb.Driver(config) as driver:
        try:
            driver.wait(timeout=5)
        except TimeoutError:
            print("Connect failed to YDB")
            print("Last reported errors by discovery:")
            print(driver.discovery_debug_details())
            return None

        session = driver.table_client.session().create()
        prepared_query = session.prepare(query)

        return session.transaction(ydb.SerializableReadWrite()).execute(
            prepared_query,
            params,
            commit_tx=True
        )


def insert_link(id, link):
    config = get_config()
    query = """
        DECLARE $id AS Utf8;
        DECLARE $link AS Utf8;

        UPSERT INTO links (id, link) VALUES ($id, $link);
        """
    params = {'$id': id, '$link': link}
    execute(config, query, params)


def find_link(id):
    print(id)
    config = get_config()
    query = """
        DECLARE $id AS Utf8;

        SELECT link FROM links where id=$id;
        """
    params = {'$id': id}
    result_set = execute(config, query, params)
    if not result_set or not result_set[0].rows:
        return None

    return result_set[0].rows[0].link


def shorten(event):
    body = event.get('body')

    if body:
        body = decode(event, body)
        original_host = event.get('headers').get('Origin')
        link_id = hashlib.sha256(body.encode('utf8')).hexdigest()[:6]
        # в ссылке могут быть закодированные символы, например, %. это помешает работе api-gateway при редиректе,
        # поэтому следует избавиться от них вызовом urllib.parse.unquote
        insert_link(link_id, urllib.parse.unquote(body))
        return response(200, {'Content-Type': 'application/json'}, False, json.dumps({'url': f'{original_host}/r/{link_id}'}))

    return response(400, {}, False, 'В теле запроса отсутствует параметр url')


def redirect(event):
    link_id = event.get('pathParams').get('id')
    redirect_to = find_link(link_id)

    if redirect_to:
        return response(302, {'Location': redirect_to}, False, '')

    return response(404, {}, False, 'Данной ссылки не существует')


# эти проверки нужны, поскольку функция у нас одна
# в идеале сделать по функции на каждый путь в api-gw
def get_result(url, event):
    if url == "/shorten":
        return shorten(event)
    if url.startswith("/r/"):
        return redirect(event)

    return response(404, {}, False, 'Данного пути не существует')


def handler(event, context):
    url = event.get('url')
    if url:
        # из API-gateway url может прийти со знаком вопроса на конце
        if url[-1] == '?':
            url = url[:-1]
        return get_result(url, event)

    return response(404, {}, False, 'Эту функцию следует вызывать при помощи api-gateway')
Создайте файл requirements.txt со следующим содержимым:
ydb==2.13.3
Находясь в директории с исходными файлами, упакуйте их в zip-архив:
zip src.zip index.py requirements.txt
Создадим нашу функцию for-serverless-shortener, задав все необходимые переменные. В переменные окружения функции необходимо добавить:
  • endpoint — нужно указать протокол grpcs:// и добавить значение Эндпоинт из секции YDB эндпоинт, обычно получается grpcs://ydb.serverless.yandexcloud.net:2135;
  • database — это значение поля База данных из секции YDB эндпоинт (начинается с /ru-central1/....);
  • USE_METADATA_CREDENTIALS — выставите значение переменной в 1.
Также сразу сделаем функцию публичной.
yc serverless function create \
  --name for-serverless-shortener \
  --description "function for serverless-shortener"

yc serverless function version create \
  --function-name for-serverless-shortener \
  --memory=256m \
  --execution-timeout=5s \
  --runtime=python39 \
  --entrypoint=index.handler \
  --service-account-id $SERVICE_ACCOUNT_SHORTENER_ID \
  --environment USE_METADATA_CREDENTIALS=1 \
  --environment endpoint=grpcs://ydb.serverless.yandexcloud.net:2135 \
  --environment database=$YDB_DATABASE \
  --source-path src.zip

yc serverless function allow-unauthenticated-invoke for-serverless-shortener

Шаг 5. Конфигурирование Yandex API Gateway

Создадим спецификацию for-serverless-shortener.yml со следующим содержанием:
openapi: 3.0.0
info:
  title: for-serverless-shortener
  version: 1.0.0
paths:
  /:
    get:
      x-yc-apigateway-integration:
        type: object_storage
        bucket:             <bucket_name>        # <-- имя бакета
        object:             <html_file>          # <-- имя html-файла
        presigned_redirect: false
        service_account:    <service_account_id> # <-- идентификатор сервисного аккаунта
      operationId: static
  /shorten:
    post:
      x-yc-apigateway-integration:
        type: cloud_functions
        function_id:  <function_id>               # <-- идентификатор функции
      operationId: shorten
  /r/{id}:
    get:
      x-yc-apigateway-integration:
        type: cloud_functions
        function_id:  <function_id>               # <-- идентификатор функции
      operationId: redirect
      parameters:
        - description: id of the url
          explode: false
          in: path
          name: id
          required: true
          schema:
            type: string
          style: simple
Не забудьте подставить в спецификацию актуальные для вас значения переменных.
Используем спецификацию для инициализации:
yc serverless api-gateway create \
  --name for-serverless-shortener \
  --spec=for-serverless-shortener.yml \
  --description "for serverless shortener"
В результате успешного создания API-шлюза получим значение параметра domain:
yc serverless api-gateway list
yc serverless api-gateway get --name for-serverless-shortener
Чтобы проверить работоспособность API-шлюза и созданного приложения целиком, скопируйте служебный домен (вида https://<идентификатор API Gateway>.apigw.yandexcloud.net/) и вставьте адрес в браузер.
Добавляйте адреса сайтов в форму, они будут сохранятся в базу данных. А вам будет доступна ссылка, за которой будет скрываться оригинальный адрес. Ваше приложение полностью работоспособно. Теперь вы умеете использовать serverless-стек технологий Yandex Cloud.
image
Итак, вы создали приложение с использованием Cloud Functions, API Gateway, Object Storage и YDB. Конечно, вы можете развивать его и дальше, расширяя функциональность.
Вводный курс по serverless-разработке на этом завершён. Осталось пройти лишь заключительный тест, который проверит ваши знания по всем рассмотренным в курсе сервисам.