Проблема глубинных ссылок в HATEOAS

    Внешнее связывание (глубинное связывание) — в интернете, это помещение на сайт гиперссылки, которая указывает на страницу, находящуюся на другом веб-сайте, вместо того, чтобы указать на начальную (домашнюю, стартовую) страницу того сайта. Такие ссылки называются внешними ссылками (глубинными ссылками).
    Википедия
    Дальше будет использоваться термин «глубинные ссылки», как наиболее близкий к англоязычному «deep links». Речь в данной статье пойдет про REST API, поэтому под глубинными ссылками будут подразумеваться ссылки на HTTP-ресурсы. Например, глубинная ссылка habr.com/ru/post/426691 указывает на конкретную статью на сайте habr.com.

    HATEOAS – компонент REST-архитектуры, позволяющий предоставлять клиентам API информацию через гипермедиа. Клиенту известен единственный фиксированный адрес, точка входа API; все возможные действия он узнает из ресурсов, полученных от сервера. Представления ресурсов содержат ссылки на действия или другие ресурсы; клиент взаимодействует с API, динамически выбирая действие из доступных ссылок. Подробнее о HATEOAS можно прочитать на Википедии или в этой замечательной статье на Хабре.

    HATEOAS – следующий уровень REST API. Благодаря использованию гипермедиа, он отвечает на многие вопросы, возникающие при разработке API: как управлять доступом к действиям на стороне сервера, как избавиться от жесткой связности между клиентом и сервером, как изменять адреса ресурсов в случае необходимости. Но он не дает ответа на вопрос о том, как должны выглядеть глубинные ссылки на ресурсы.

    В «классической» реализации REST клиенту известна структура адресов, он знает, как по идентификатору получить ресурс в REST API. Например, пользователь переходит по глубинной ссылке на страницу книги в Интернет-магазине. В адресной строке браузера отображается URL https://domain.test/books/1. Клиент знает, что «1» – это идентификатор ресурса книги, и для его получения надо подставить этот идентификатор в URL REST API https://api.domain.test/api/books/{id}. Таким образом, глубинная ссылка на ресурс этой книги в REST API выглядит так: https://api.domain.test/api/books/1.

    В HATEOAS же клиент не знает об идентификаторах ресурсов или структуре адресов. Он не хардкодит, а «обнаруживает» ссылки. Более того, структура URL-ов может измениться без ведома клиента, HATEOAS это позволяет. Из-за этих отличий не получится реализовать глубинные ссылки аналогично классическому REST API. Удивительно, но поиск в Интернете рецептов реализации таких ссылок в HATEOAS не дал большого количества результатов, только несколько недоуменных вопросов на Stackoverflow. Поэтому рассмотрим несколько возможных вариантов и попробуем выбрать лучший.

    Нулевой вариант вне конкурса – не реализовывать глубинные ссылки. Это может подойти каким-нибудь админкам или мобильным приложениям, которым не требуется возможность прямого перехода на внутренние ресурсы. Это полностью в духе HATEOAS, пользователь может открывать страницы только последовательно, начиная с точки входа, потому что клиент не знает, как перейти на внутренний ресурс напрямую. Но этот вариант плохо подходит для веб-приложений – мы ожидаем, что ссылку на внутреннюю страницу можно добавить в закладки, а обновление страницы не перебросит нас обратно на главную страницу сайта.

    Итак, первый вариант: хардкод URL-ов HATEOAS API. Клиент знает структуру адресов ресурсов, для которых нужны глубинные ссылки, и знает, как получить идентификатор ресурса для подстановки. Например, сервер в качестве ссылки на ресурс книги возвращает адрес https://api.domain.test/api/books/1. Клиент знает, что «1» – это идентификатор книги и может сформировать этот URL самостоятельно при переходе по глубинной ссылке. Это безусловно рабочий вариант, но нарушающий принципы HATEOAS. Структуру адреса и идентификатор ресурса изменить уже нельзя, иначе клиент сломается, налицо жесткая связность. Это не HATEOAS, а значит, вариант нам не подходит.

    Второй вариант – подстановка URL REST API в URL клиента. Для примера с книгой глубинная ссылка будет выглядеть так: https://domain.test/books?url=https://api.domain.test/api/books/1. Здесь клиент берет полученную от сервера ссылку на ресурс и подставляет ее целиком в адрес страницы. Это уже больше похоже на HATEOAS, клиент не знает про идентификаторы и структуру адресов, он получает ссылку и использует ее как есть. При переходе по такой глубинной ссылке клиент получит нужный ресурс по ссылке REST API из параметра url. Казалось бы, решение рабочее, и вполне в духе HATEOAS. Но если добавить такую ссылку в закладки, в будущем мы уже не сможем изменить адрес ресурса в API (или придется вечно поддерживать переадресацию на новый адрес). Опять же теряется одно из преимуществ HATEOAS, этот вариант тоже не идеален.

    Таким образом, мы хотим иметь постоянные ссылки, которые, тем не менее, могут измениться. Такое решение существует и широко используется в Интернете – многие сайты предоставляют короткие ссылки на внутренние страницы, которыми можно поделиться. Помимо краткости, их преимущество в том, что сайт может изменить реальный адрес страницы, но такие ссылки не сломаются. Например, Microsoft использует в Windows ссылки на страницы справки вида http://go.microsoft.com/fwlink/?LinkId=XXX. За эти годы сайты Microsoft были несколько раз переработаны, но ссылки в старых версиях Windows продолжают работать.

    Осталось только приспособить это решение к HATEOAS. И это третий вариант – использование уникальных идентификаторов глубинных ссылок в REST API. Теперь адрес страницы с книгой будет выглядеть так: https://domain.test/books?deepLinkId=3f0fd552-e564-42ed-86b6-a8e3055e2763. При переходе по такой глубинной ссылке клиент должен спросить у сервера: какая ссылка на ресурс соответствует такому идентификатору deepLinkId? Сервер вернет ссылку https://api.domain.test/api/books/1 (ну или сразу ресурс, чтобы два раза не ходить). Если адрес ресурса в REST API изменится, сервер просто вернет другую ссылку. В БД сохранена запись, что идентификатору ссылки 3f0fd552-e564-42ed-86b6-a8e3055e2763 соответствует идентификатор сущности книги 1.

    Для этого ресурсы должны содержать поле deepLinkId с идентификаторами их глубинных ссылок, а клиент должен подставлять их в адрес страницы. Такой адрес можно спокойно сохранять в закладки и отправлять друзьям. Не совсем хорошо, что клиент самостоятельно работает с некими идентификаторами, но это позволяет сохранить преимущества HATEOAS для API в целом.

    Пример


    Эта статья была бы неполной без примера реализации. Для проверки концепции рассмотрим пример сайта-каталога гипотетического Интернет-магазина с бэкендом на Spring Boot/Kotlin и SPA-фронтендом на Vue/JavaScript. Магазин торгует книгами и карандашами, на сайте есть два раздела, в которых можно посмотреть список товаров и открыть их страницы.

    Раздел «Книги»:



    Страница одной книги:



    Для хранения товаров определены сущности Spring Data JPA:

    enum class EntityType { PEN, BOOK }
    
    @Entity
    class Pen(val color: String) {
    	@Id
    	@Column(columnDefinition = "uuid")
    	val id: UUID = UUID.randomUUID()
    	@OneToOne(cascade = [CascadeType.ALL])
    	val deepLink: DeepLink = DeepLink(EntityType.PEN, id)
    }
    
    @Entity
    class Book(val name: String) {
    	@Id
    	@Column(columnDefinition = "uuid")
    	val id: UUID = UUID.randomUUID()
    	@OneToOne(cascade = [CascadeType.ALL])
    	val deepLink: DeepLink = DeepLink(EntityType.BOOK, id)
    }
    
    @Entity
    class DeepLink(
    		@Enumerated(EnumType.STRING)
    		val entityType: EntityType,
    		@Column(columnDefinition = "uuid")
    		val entityId: UUID
    ) {
    	@Id
    	@Column(columnDefinition = "uuid")
    	val id: UUID = UUID.randomUUID()
    }
    

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

    REST API сервера организовано по концепции HATEOAS, точка входа API содержит ссылки на коллекции товаров, а также ссылку #deepLink для формирования глубинных ссылок подстановкой идентификатора:

    GET http://localhost:8080/api
    
    {
        "_links": {
            "pens": {
                "href": "http://localhost:8080/api/pens"
            },
            "books": {
                "href": "http://localhost:8080/api/books"
            },
            "deepLink": {
                "href": "http://localhost:8080/api/links/{id}",
                "templated": true
            }
        }
    }
    

    Клиент при открытии раздела «Книги» запрашивает коллекцию ресурсов по ссылке #books в точке входа:

    GET http://localhost:8080/api/books
    
    ...
    {
        "name": "Harry Potter",
        "deepLinkId": "4bda3c65-e5f7-4e9b-a8ec-42d16488276f",
        "_links": {
            "self": {
                "href": "http://localhost:8080/api/books/1272e287-07a5-4ebc-9170-2588b9cf4e20"
            }
        }
    },
    {
        "name": "Cryptonomicon",
        "deepLinkId": "a23d92c2-0b7f-48d5-88bc-18f45df02345",
        "_links": {
            "self": {
                "href": "http://localhost:8080/api/books/5d04a6d0-5bbc-463e-a951-a9ff8405cc70"
            }
        }
    }
    ...
    

    В SPA используется Vue Router, для которого определен путь к странице книги { path: '/books/:deepLinkId', name: 'book', component: Book, props: true }, а ссылки в списке книг выглядят так: <router-link :to="{name: 'book', params: {link: book._links.self.href, deepLinkId: book.deepLinkId}}">{{ book.name }}</router-link>.

    То есть при открытии страницы конкретной книги вызывается компонент Book, которому передаются два параметра: link (ссылка на ресурс книги в REST API, значение поля href ссылки #self) и deepLinkId из ресурса).

    const Book = {
        template: `<div>{{ 'Book: ' + book.name }}</div>`,
        props: {
            link: null,
            deepLinkId: null
        },
        data() {
            return {
                book: { name: "" }
            }
        },
        mounted() {
            let url = this.link == null ? '/api/links/' + this.deepLinkId : this.link;
            fetch(url).then((response) => {
                return response.json().then((json) => {
                    this.book = json
                })
            })
        }
    }
    

    Значение deepLinkId Vue Router устанавливает в адрес страницы /books/:deepLinkId, а компонент запрашивает ресурс по прямой ссылке из свойства link. При принудительном обновлении страницы Vue Router устанавливает свойство компонента deepLinkId, получая его из адреса страницы. Свойство link остается равным null. Компонент проверяет: если есть прямая ссылка, полученная из коллекции, ресурс запрашивается по ней. Если же доступен только идентификатор deepLinkId, он подставляется в ссылку #deepLink из точки входа для получения ресурса по глубинной ссылке.

    На бэкенде метод контроллера для глубинных ссылок выглядит так:

    @GetMapping("/links/{id}")
    fun deepLink(@PathVariable id: UUID?, response: HttpServletResponse?): ResponseEntity<Any> {
        id!!; response!!
        val deepLink = deepLinkRepo.getOne(id)
        val path: String = when (deepLink.entityType) {
            EntityType.PEN -> linkTo(methodOn(MainController::class.java).getPen(deepLink.entityId))
            EntityType.BOOK -> linkTo(methodOn(MainController::class.java).getBook(deepLink.entityId))
        }.toUri().path
        response.sendRedirect(path)
        return ResponseEntity.notFound().build()
    }
    

    По идентификатору находится сущность глубинной ссылки. В зависимости от типа прикладной сущности формируется ссылка на метод контроллера, который возвращает ее ресурс по entityId. Запрос редиректится на этот адрес. Таким образом, если в будущем ссылка на контроллер сущности изменится, можно будет просто изменить логику формирования ссылок в методе deepLink.

    Полный исходный код примера доступен на Github.
    Поделиться публикацией

    Похожие публикации

    Комментарии 8

      0

      Почему бы тогда не оставить лишь урлы вида https://domain.test/3f0fd552-e564-42ed-86b6-a8e3055e2763 и не парить ни клиента, ни сервера редиректами, разными типами ссылок, сменой формата ссылок и прочей ерундой? Получили гуид, нашли запись в базе, узнали её тип, вызвали нужный обработчик.

        0
        SEO
          0
          Что SEO?
          0
          Это хороший вариант, но требует дополнительного обращения к БД при обработке каждого запроса для получения реального адреса по его GUID. И не очень удобен при анализе HTTP-логов для разработки, отладки или поддержки.
            0
            Это уже реальный адрес, просто лезете в базу и достаёте запись по этому гуиду… там же можете и в логи писать.
              0
              В реляционной БД для этого придется держать отдельную таблицу со всеми GUID'ами сущностей?
              Тут опять же накладывается ограничение на структуру адреса, которую потом нельзя будет изменить.
                0
                В реляционной БД для этого придется держать отдельную таблицу со всеми GUID'ами сущностей?

                Или сменить субд, на графовую.


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

                И зачем его менять? Там только гуид и всё.

          0
          Более того, структура URL-ов может измениться без ведома клиента, HATEOAS это позволяет

          Не совсем. В REST (уровнем ниже) URL не может поменяться когда разработчику захотелось. Время жизни URL совпадает со временем жизни ресурса. Если вы пошли смотреть книжку, а URL вернул 404, значит книжки больше нет (семантически). Не сервер ушел, не разработчик передумал, а книжка — пффф — и исчезла. Ссылка отражает состояние и наоборот. Объяснить клиенту что "ну ты на URL не смотри, вообще-то ничего не поменялось" в рамках принципов REST нельзя никак.


          Ну как же! А может редирект вернуть? Можно, но не стоит. Формально семантика редиректа — "мы переехали". Это костыль уровня протокола. Который, кстати, уже много раз засовывали куда не следует вроде авторизации и трекинга. Предполагаемое поведение клиента — повторить запрос по новому адресу и все. Для браузера это норм (у него время жизни страницы ограничено чем-то разумным), а вот для API — не годится: что клиенту дальше делать с ответом (в котором могут быть ссылки на другие ресурсы) — вообще говоря не определено. Например — автор редиректнутой книжки — это тот же самый автор или другой? Адрес другой, значит и автор другой? Или автор тот же самый, потому что адреса совпали с точностью до суффикса? Давайте разбирать URL и искать в нем уникальные идентификаторы на клиенте? Или вытащим ресурс "автор" и посмотрим на контент? Нет.


          А как быть если мы накосячили, или внезапно решили что схема URL больше не отражает прекрасное? На этот случай в HATEOAS есть целое метафизическое болото про версионирование — приятного чтения, дружок, надеюсь тебе нравятся шутки про "ложки нет, а суслик есть".


          TLDR: "постоянные ссылки которые могут измениться" могут измениться только через изменение версии схемы URL, а это решается версиями.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое