Пишем сокращатель ссылок при помощи AWS Lambda за 2 часа

Lambda

Рассказывает Ян Куи


Интересное требование возникло на работе, когда мы обсуждали потенциальную необходимость запуска собственного сокращателя URL, потому что механизм универсальных ссылок (в iOS 9 и выше) требует JSON-манифест на https://domain.com/apple-app-site-association.

Поскольку ОС не следует переадресациям, этот манифест должен быть размещен в корневом домене URL-сокращателя.

Из-за ограничения на AppsFlyer он не может сокращать URL, когда в приложении настроены универсальные ссылки. Мы могли бы перейти на другого поставщика, но это означало бы большую нагрузку для клиентских приложений.

Возник вопрос «Стоит ли создавать свой сокращатель ссылок?», который затем плавно перешел в «Насколько тяжело создать расширяемый сокращатель ссылок в 2017 году?».

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

Вот так выглядит сокращатель

Lambda во имя победы

Для нашего сокращателя нам необходимы следующие вещи:

  1. Конечная точка GET/{shortUrl}, которая будет перенаправлять на оригинальный URL.
  2. Конечная точка POST/, которая будет принимать оригинальный URL и возвращать сокращенный.
  3. index.html — страница, где каждый мог бы легко создать короткий URL.
  4. Конечная точка GET/apple-app-site-association, которая обслуживает статичный JSON-ответ.

Все эти вещи могут быть достигнуты с помощью API Gateway и Lambda.

Прим. перев. Чтобы иметь представление о всём спектре сервисов, предоставляемых платформой Amazon Web Services (AWS), советуем прочитать нашу шпаргалку.

У меня получилась следующая структура проекта:

  • используется шаблон aws-nodejs фреймворка Serverless;
  • каждая конечная точка имеет соответствующую функцию обработки;
  • файл index.html в статичной папке;
  • тесты написаны таким образом, что могут использоваться как в качестве интеграционных тестов, так и в качестве приемочных;
  • скрипт build.sh, облегчающий работу;
  • интеграционные тесты ./build.sh int-test {env} {region} {aws_profile};
  • приемочные тесты ./build.sh acceptance-test {env} {region} {aws_profile};
  • разворачивание ./build.sh deploy {env} {region} {aws_profile}.
Lambda

Структура проекта

Конечная точка GET/apple-app-site-association

Так как JSON статичный, есть смысл вычислить HTTP-ответ заранее и возвращать его каждый раз:

'use strict';

const payload = {...
};

const response = {
  statusCode: 200,
  body: JSON.stringify(payload)
};

module.exports.handler = (event, context, callback) => {
  callback(null, response);
};

Конечная точка POST/

Для алгоритма сокращения URL можно найти простое и элегантное решение на StackOverflow. Вам нужен только автоматически увеличивающийся ID, вроде того, что вы обычно получаете с помощью RDBMS.

Однако я заметил, что DynamoDB будет более подходящей базой данных по следующим причинам:

  • это управляемый сервис, поэтому не надо волноваться об инфраструктуре;
  • капитальные расходы лучше операционных;
  • есть возможность масштабировать пропускную способность чтения и записи, чтобы соответствовать уровню использования и обрабатывать любые скачки трафика.

Но в DynamoDB нет такого понятия, как автоматически увеличивающийся ID, который необходим для алгоритма. Вместо него вы можете использовать атомарный счетчик для симуляции автоматического увеличения за счет дополнительной единицы записи за запрос.

function* getNewId () {
  console.log('fetching the next auto-incremented ID');

  let params = {
    TableName: 'url_shortener_long_urls',
    Key: {
      shortUrl: "__id"
    },
    UpdateExpression: 'add #counter :n',
    ExpressionAttributeNames: {
      '#counter': 'counter'
    },
    ExpressionAttributeValues: {
      ':n': 1
    },
    ReturnValues: 'UPDATE_NEW'
  };

  let res = yield dynamodb.updateAsync(params);
  console.log(res);
  return res.Attributes.counter;
}
Lambda

Получится такая таблица

Конечная точка GET/{shortUrl}

Как только мы установили соответствие в таблице DynamoDB, конечная точка перенаправления стала простым взятием оригинального URL и возвращением его как части заголовка Location.

И не забудьте возвращать соответствующий статусу код HTTP, в данном случае 308 (обязательное перенаправление).

module.exports.handler = co.wrap(function* (event, context, callback) {
  console.log(JSON.stringify(event));

  try {
    let shortUrl = event.pathParameters.shortUrl;
    console.log('short url : ${shortUrl}');

    let longUrl = yield getLongUrl(shortUrl);
    console.log('long url : ${longUrl}');

    const response = {
      statusCode: 308,
      headers: { location: longUrl }
    };

    callback(null, response);
  } catch (err) {
    console.log(err);

    if (err.statusCode) {
      callback(null, err);
    } else {
      callback(null, {
        statusCode: 500,
        body: JSON.stringify(err)
      });
    }
  }
});

Страница GET/index

Наконец, для страницы index мы будем возвращать HTML (и другой тип контента вместе с HTML).

Я решил поместить HTML-файл в статичную папку, которая загружается и кэшируется в первый раз, когда вызывается функция.

const co = require('co');
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require("fs"));

var html;

function* loadHtml () {
  if (!html) {
    console.log('loading index.html...');
    html = fs.readFileAsync('static/index.html', 'utf-8');
    console.log('loaded');
  }

  return htnl;
}

module.exports.handler = co.wrap(function* (event, context, callback) {
  let html = yield loadHtml();
  let response = {
    statusCode: 200,
    headers: {
      'Content-Type': 'text/html; charset=UTF-8'
    },
    body: html
  };

  callback(null, response);
});

Подготовка к работе

К счастью, у меня было много опыта работы с AWS Lambda, поэтому я знаю, что для URL-сокращателя нам необходимо:

  • настроить автомасштабируемые параметры для таблицы DynamoDB (для которой у нас есть внутренняя система управления автоматическим масштабированием);
  • включить кэширование для API Gateway на производственном уровне.

Будущие улучшения

Если поместить один и тот же URL несколько раз, то вы будете получать разные короткие ссылки. Для оптимизации нужно возвращать одну и ту же ссылку, используя кэш.

Чтобы это сделать, вы можете:

  1. Добавить GSI в таблице DynamoDB к longUrl для поддержки эффективного обратного поиска.
  2. В функции shortUrl выполнять GET вместе с GSI для поиска существующих коротких URL.

Более подходящим вариантом будет добавление GSI, нежели создание новой таблицы. Это поможет избежать транзакций между несколькими таблицами.

Перевод статьи «AWS Lambda — build yourself a URL shortener in 2 hours»