2018-09-29

Об URL

Понадобилось как-то распарсить вот такую строчку. Это первая строка типичного HTTP запроса.
POST /service?user=123 HTTP/2.0
Такие строчки встречаются в логах Elastic Load Balancer (ELB). Я специально засунул туда айдишник пользователя, чтобы отделять запросы одних пользователей от запросов других. Да, Chrome, которым мы в основном пользуемся, ходит в Амазоновый ELB по протоколу HTTP/2.
Разбить строку по пробелам — легко. Но в серединке у нас URL. И мне нужно извлечь из этого URL path и один из параметров запроса. Регулярные выражения для URL я писать не хочу, неблагодарное это дело. Ведь должны же быть стандартные парсеры для URL или URI.
URIs
Раз уж у нас Java/Kotlin, давайте попробуем java.net.URL.
val url = java.net.URL("/service?user=123")
> java.net.MalformedURLException: no protocol: /service?user=123
Ну да. У нас же немножко неполный, прямо говоря, относительный, URL, каким он обычно и бывает в атрибуте href или заголовке HTTP запроса.
Ну давайте попробуем java.net.URI.
val url = java.net.URI("/service?user=123")
url.getPath()
> /service
url.getQuery()
> user=123
Победа? Ещё нет. У java.net.URL и у java.net.URI есть метод getQuery(). Он выделяет query часть URL, но не парсит её. Далее StackOverflow рекомендует снова воспользоваться регулярными выражениями или хотя бы разбить строку по символам "&" и "=".
Но я не хочу писать свой парсер. В любом парсере рано или поздно найдутся ошибки или уязвимости. И лучше, чтобы это был не ваш парсер. Тем более, что для такого популярного случая, как URL, уж точно должно существовать готовое решение. Почему это Java должна быть обделена?
Тем, кто под Android, повезло. android.net.Uri делает то, что нужно. Небольшая сложность возникнет при конструировании этого Uri. Фабричный метод Uri.fromParts(String scheme, String ssp, String fragment) требует явного указания схемы. А дальше у нас есть getQueryParameter(String key).
В мире Spring всё тоже неплохо. Там есть UriComponentsBuilder. С его фабричными методами тоже нужно разобраться, их много. И делает он UriComponents. А там уже есть MultiValueMap getQueryParams(). Даже круче, чем нужно.
Но у меня не Android. И я не хочу тащить Spring. Потому что это Lambda. Чем меньше классов и зависимостей, тем лучше.
Схема. Почему схема нужна явно? Если копнуть, окажется очень интересно.
URI — это не только URL. Есть ещё URN, где (например, "urn:isbn:5170224575") нет никакого пути или какой-либо иерархии. Есть просто имя в определённом пространстве имён. В "mailto:[email protected]" есть такие части URL, как имя пользователя и адрес сервера, но больше нет ничего. "tel:+1-816-555-1212" вообще ничего общего с HTTP URL не имеет.
Только URL имеет и имя хоста, и иерархический путь, и query, и fragment. И то лишь схемы "http", "https" и "ftp". Даже у наиболее близкого "file" уже нет имени хоста.
URI & URL
В мире URI — полный бардак. Но хорошая новость в том, что c любым URI (и, соответственно, URL) можно понять, как разобраться, выяснив схему. То есть, прочитав ASCII символы до первого двоеточия. Схема — важна.
В моём случае URL относительный. В нём пропущена схема и доменное имя. Схему нужно указать. И в данном случае всё просто. Это либо "http", либо "https", без разницы.
Проблема разбора URI/URL действительно является проблемой. Поэтому возникают библиотечки со странными именами вроде galimatias. Либо HTTP библиотеки обзаводятся своими реализациями методов работы с URL. Мало кого удовлетворяет стандартная библиотека Java.
Я остановился на прекрасной библиотеке OkHttp. Это — мощный (но лёгкий) HTTP клиент. Который, кстати, стал дефолтной подкапотной реализацией HTTP в последних версиях Android.
Там есть свой HttpUrl, который может почти всё, что нужно. Но он работает только с "http" и "https" схемами URL. Потому что он заточен на такие URL, и умеет справляться с различными кодировками не-ASCII символов в доменной части и в пути. Ну и, конечно же, он корректно парсит query. Почитайте JavaDoc, там подробно расписано, почему так, и чем ещё плох java.net.URL (спойлер: метод equals() там ходит в сеть).
Так что делать с относительным URL в моём случае? Считать его относительным. И делать resolve() от некоторого базового URL.
val baseUrl = HttpUrl.get("https://example.com")  // пофиг какой сервер
val url = baseUrl.resolve("/service?user=123")
val pathSegments = url?.pathSegments()
val user = url?.queryParameter("user")
Это работает точно так же, как разрешение href ссылки в HTML. Если будет относительный URL вроде "/service?user=123", то это отрезолвится в "https://example.com/service?user=123". Если будет абсолютный URL вроде "https://example.net/service?user=123", то это отрезолвится в новый абсолютный URL "https://example.net/service?user=123". То, что нужно.