Преамбула
Принципиальная схема работы сервера
Идея написания этой статьи возникла у меня после прочтения похожей статьи на Хабрахабр, где рассказывается о том, как создать собственный сервер на localhost с использованием Mathematica. Сам веб-сервер был написан с использованием Python и расширяемого сервера Tornado. Он обрабатывал запросы и отправлял ответы в формате json, а логика была реализована в Mathematica. При этом общение между Python и Mathematica происходило при помощи командной строки, а каждый запрос на сервер перезапускал ядро Математики. Остальные подробности можно прочитать в самой статье автора @Nilis. Здесь я хотел бы показать как написать простой код, который будет выполнять аналогичные функции — то есть создать http-сервер для обработки запросов и отправки ответов. Плюс хотелось бы показать некоторые интересные возможности Wolfram Language и его синтаксиса.
Пустая реализация сервера
Проще всего начать реализацию с основной функции, а затем уже продвигаться вглубь. Для того чтобы ее написать — нужно загрузить контекст SocketLink`, выполнив следующую команду:
Needs["SocketLink`"];
После этого становятся доступны функции этого контекста. Посмотреть какие именно функции он содержит можно так:
Information["SocketLink`*"];
SocketLink` | |
---|---|
CreateAsynchronousServer | CreateServerSocket |
CreateClientSocket | OpenSocketStreams |
Поясним, что из себя каждая представляет:
- CreateClientSocket[port] — создает сокет с использованием порта — port;
- CreateServerSocket[host, port] — создает клиентский сокет подключающийся к хосту — host и использующий порт — port;
- OpenSocketStreams[socket] — открывает поток ввода и вывода у сокета — socket;
- CreateAsynchronousServer[socket, handler] — создает "сервер" с указанным сокетом — socket и обработчиком — handler.
В наиболее простом случае нам понадобится всего две функции из этого контекста:
CreateAsynchronousServer и CreateServerSocket. Конечно на первый взгляд кажется, что уже все готово и никакого смысла в "написании" сервера нет. Но это не так. CreateAsynchronousServer — не умеет делать ничего кроме прослушивания указанного сокета. Значит реализовывать все остальное придется нам. Для начала неплохо было бы создать вспомогательную функцию, которая будет иметь ограничение на входные аргументы:
MathematicaSimpleServer[socket_Socket, handler_Handler] :=
CreateAsynchronousServer[socket, handler];
Пояснение для не пользователей Mathematica. Слева от знака ":=" (SetDelayed[]) находится шаблон, который после выполнения сохранится в памяти. Справа находится правило, которое будет выполняться при встрече шаблона. Указание в виде socket_Socket — говорит о том, что первый аргумент должен быть сокетом. Шаблон handler_Handler — говорит о том, что второй аргумент должен иметь тип Handler. Этого типа в данный момент не существует. Однако Mathematica не обращает на это внимание и позволяет в любом случае создать функцию, которая будет принимать в качестве одного из входных параметров несуществующий тип.
Если этого типа не существует — значит его нужно создать. Займемся этим. Следующий код показывает каким образом в Mathematica можно создать собственный простой тип данных с использованием TagSetDelayed[]:
(* getters for Handler *)
Handler /:
GetRequestParser[Handler[parser_RequestParser, _]] := parser;
Handler /:
GetResponseGenerator[Handler[_, generator_ResponseGenerator]] := generator;
(* setters for Handler *)
Handler /:
SetRequestParser[handler_Handler, parser_RequestParser] :=
Handler[handler, GetResponseGenerator[handler]];
Handler /:
SetResponseGenerator[handler_Handler, generator_ResponseGenerator] :=
Handler[GetRequestParser[handler], generator];
Пояснение синтаксиса для не пользователей Mathematica. Знак "/:" (TagSetDelayed[]) означает, что только для типа данных (Handler), который находится слева будет переопределена работа функции, определение которой целиком находится справа. Правило, которое будет выполняться при вызове функции находится как обычно справа от знака ":=". Этот способ будет работать в том числе и для защищенных системных функций. Так как в этом случае происходит изменение связанное не с именем самой функции, а с именем типа. Некоторая особенность заключается в том, что внутри шаблона между знаками "/:" и ":=" должен обязательно в явном виде где-то находится тип Handler (но не на верхнем уровне). Опять же стоит заметить, что четыре функции выше были определены с использованием еще не существующих типов данных: RequestParser, ResponseGenerator. Теперь остался последний штрих в определении обработчика — это создать шаблон, который будет выполняться при вызове обработчика внутри сервера. Он обязан принимать на вход список из двух элементов — потоков ввода и вывода. Действия с этими потоками можно выполнять практически любые. Как было сказано выше — обработчик должен читать поток ввода и записывать в поток вывода. Реализуем это следующим образом:
handler_Handler[{input_InputStream, output_OutputStream}] :=
Module[{
requestParser = GetRequestParser[handler],
responseGenerator = GetResponseGenerator[handler],
request = "", response = ""
},
(* read data from input stream of the socket *)
{request} =
If[# != {}, FromCharacterCode[#],
Print[DateString[], "\nERROR"]; Close[input]; Close[output]; Return[]]& @
Last[Reap[While[True, TimeConstrained[
Sow[BinaryRead[input]], 0.01,
(Close[input]; Break[])
];];]];
(* logging *)
Print[DateString[], "\nREQUEST:\n\n", request];
(* processing request *)
response = responseGenerator[requestParser[request]];
(* logging and writing data to the output stream *)
Switch[response,
_String,
Print[DateString[], "\nRESPONSE:\n\n", response];
BinaryWrite[output, ToCharacterCode[response]],
{__Integer},
Print[DateString[], "\nRESPONSE:\n\n", FromCharacterCode[response]];
BinaryWrite[output, response];
];
Close[output];
];
Как ни странно — но для Mathematica совершенно не обязательно создавать какое-то одно уникальное имя в левой части шаблона. Это может быть сложное выражение с заголовком и внутренним содержимым. Такой способ создания определений функций достаточно редко используется, но в нашем случае он будет очень полезен. Внутреннее содержимое обработчика определяет всю логиrу работы сервера. Теперь пустая реализация сервера готова. Она будет рабочей, но ничего полезного делать не будет. Ведь вся обработка запроса лежит на несуществующих функциях: requestParser, responseGenerator. В нашем случае на их вход передается строка, и результатом тоже должна быть строка (или список байт, на что намекает второй вариант выбора в переключателе Swich[]). Хотя никто не мешает возвращать после чтения запроса все что угодно, только при условии, что это "что угодно" будет корректно обрабатываться функцией для создания ответов.
Обработка запроса
Теперь, когда сервер готов, необходимо позаботиться о реализации типа RequestParser. Именно он будет использоваться первым. Точно таким же образом, как это было сделано выше для Handler, создадим самое простое определение:
requestParser_RequestParser[request_String] := request;
Согласно этому определению функция будет возвращать просто саму строку запроса. Для начала этого хватит. Теперь все тоже самое, но для генератора ответов:
responseGenerator_ResponseGenerator[parsed_String] :=
"HTTP/1.1 200 OK
Content-Length: 1024
Connection: close
<!DOCTYPE html>
<html>
<head>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />
<title>MathematicaSimpleServer</title>
</head>
<body>
Hello from mathematica simple server!
</body>
</html>"
Определение генератора тоже самое простое. Это просто строка ответа соединенная с html-разметкой отображаемой страницы. Теперь точно все готово! Можно попробовать запустить сервер и проверить, как он будет работать. Сделать это можно выполнив следующий код:
socket = CreateServerSocket[8888];
handler = Handler[RequestParser[], ResponseGenerator[]];
server = MathematicaSimpleServer[socket, handler];
Теперь откроем браузер и перейдем по адресу http://localhost:8888/. В браузере отобразится страница следующего вида:
Thu 19 Jan 2017 01:22:00
REQUEST:
GET / HTTP/1.1
Accept: text/html, application/xhtml+xml, image/jxr, */*
Accept-Language: ru-RU
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393
Accept-Encoding: gzip, deflate
Host: localhost:8888
Connection: Keep-Alive
Wed 18 Jan 2017 14:56:45
RESPONSE:
HTTP/1.1 200 OK
Content-Length: 1024
Connection: close
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>MathematicaSimpleServer</title>
</head>
<body>
Hello from mathematica simple server!
</body>
</html>
Ура! Наш сервер все таки работает. Интересно, а что будет если мы не будем его останавливать и попробуем изменить код отображаемой html-страницы, который возвращается во время вызова ResponseGenerator[]? Сделаем это — просто определим функцию еще раз:
responseGenerator_ResponseGenerator[parsed_String] :=
("HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: " <> ToString[StringLength[#]] <> "
Connection: close
" <> #)& @ "<!DOCTYPE html>
<html>
<head>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />
<title>MathematicaSimpleServer</title>
</head>
<body>
Hello from mathematica simple server! <br/>
we changed the server logic without stopping ;)
</body>
</html>"
После выполнения кода выше и обновления страницы в браузере отображается измененный контент. Получается, что можно не останавливать работу веб-сервера и продолжать добавлять новые определения для ResponseGenerator и RequestParser. Но тем не менее полезно знать каким образом можно его остановить. Достаточно выполнить код:
SocketLink`Sockets`CloseSocket[socket];
StopAsynchronousTask[server];
Расширение возможностей сервера
Небольшая подготовка. Чтобы в дальнейшем не возникло никаких проблем, сразу установим в качестве рабочей директорию расположения этого документа:
SetDirectory[NotebookDirectory[]];
Очевидно, что отображение двух строк в окне браузера — не очень хорошая демонстрация возможностей Mathematica. Ради тестовых целей создадим простую html-страницу index. Код страницы представлен ниже. Здесь уже имеется несколько ссылок на другие адреса, которые должен возвращать сервер. Так же добавлена функция, которая создает сам ответ целиком.
IndexPage[] :=
"<!DOCTYPE html>
<html>
<head>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />
<title>MathematicaSimpleServer</title>
<link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\"/>
</head>
<body>
Index page for the mathematica simple server
<ul>
<li><a href=\"/graphic?Sin\" >graphic</a></li>
<li><a href=\"/page.html\" >page</a></li>
<li><a href=\"notebook.nb\" >notebook</a></li>
</ul>
</body>
</html>";
ResponseString[page_String] :=
StringTemplate["HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: `length`
Connection: close
`page`"][<|"length" -> StringLength[page], "page" -> page|>];
ResponseString[page_String, length_Integer] :=
StringTemplate["HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: `length`
Connection: close
`page`"][<|"length" -> length, "page" -> page|>];
Функция, соединяющая строку ответа и код разметки страницы — ResponseString[] — имеет два определение: первое вычисляет размер страницы и заменяет значение заголовка Content-Length на полученный результат. Во втором определении можно самостоятельно указать размер тела ответа. Как уже было сказано выше — на главной странице имеется несколько ссылок. Предполагается что ссылки ведут на один из адресов, где выполняется какая-то своя серверная логика. Всего различных случаев четыре — это отображение самой главной страницы, ссылка на произвольный график, на html-страницу и на страницу, образованную из готового блокнота Математики. Каждый из случаев нужно рассматривать отдельно. Первый случай, загрузка основной страницы. Она выполняется только если был получен GET запрос по адресу "/" или "/index". Как проверить это адрес? Поступить можно различными способами. Ниже показан не самый популярный, но интересный. Сначала переопределим функции Keys и Part на типе данных RequestParser следующим образом:
RequestParser /:
Keys[RequestParser[rules___Rule]] := {rules}[[All, 1]];
RequestParser /:
Part[RequestParser[___, "Address" -> function: (_Symbol|_Function), ___], "Address"] :=
function;
Стоит пояснить синтаксис выражений выше. rules___Rule — представляет собой шаблон с произвольным (в том числе и ноль) количеством правил замены. Это значит, что RequestParser можно создавать используя любое количество обрабатывающих функций, а все имена этих функций можно будет получить просто использовав функцию Keys. Интересно, что Keys является системной функций с атрибутом Protected, который запрещает изменять эту функцию, но указание типа с помощью TagSetDelayed позволяет это сделать. Точно так же была переопределена встроенная функция Part. Как уже было сказано выше, предполагается, что RequestParser — это сложное выражение, которое внутри себя должно содержать набор правил. Каждое правило — это ключ и значение (функция для обработки). Зачем же все это понадобилось? Писать большое количество условий и проверок строки запроса достаточно тяжело. Тем более легко ошибиться в порядке определений, так как если плохо подобрать регулярное выражение или шаблон для проверки запроса, то некоторые участки кода окажутся недостижимыми. Гораздо проще обрабатывать один запрос сразу несколькими функциями и возвращать результат в виде ассоциации с парами: имя функции и результат. Ниже представлена реализация этого способа:
requestParser_RequestParser[request_String] /;
MatchQ[requestParser, RequestParser[_String -> (_Symbol|_Function)]] :=
Association[Map[Function[key, key -> (requestParser[[key]])[request]], Keys[requestParser]]];
Теперь необходимо создать те самые функции. У нас будет всего одна такая функция для получения адреса из метода запроса, потому что фантазия автора всю вариативность работы сервера сумела уложить только в различие адресов в первой строке запроса.
TakeAddress[request_String] :=
First[StringCases["GET " ~~ address___ ~~ " HTTP/" -> address][request]];
Для тех кто не знаком с Mathematica — для строковых выражений в языке имеется множество интересных конструкций. Конструкция выше интуитивно понятна, она просто выбирает весь текст, который находится между GET и HTTP в первой строке. В предыдущей реализации сервера генератор ответов обрабатывал строку, которую возвращал обработчик запроса. Но теперь на вход этой функции будет передаваться ассоциация из пар ключ-значение. Значит необходимо для генератора создать новое определение, которое сможет преобразовать полученную ассоциацию в ответ.
responseGenerator_ResponseGenerator[parsed_Association] /;
parsed[["Address"]] == "/" || parsed[["Address"]] == "/index" :=
ResponseString[IndexPage[]];
Перезапустим сервер с новым обработчиком. Теперь внутри RequestParser находится явное указание имени функции — "Address" — и самой функции — TakeAddress — с помощью которой обрабатываются все ответы.
socket = CreateServerSocket[8888];
handler = Handler[RequestParser["Address" -> TakeAddress], ResponseGenerator[]];
server = MathematicaSimpleServer[socket, handler];
Главная страница работает. Добавим правильную обработку запросов к остальным ресурсам. Первый из них — это попытка получения графика указанной функции. Сначала добавим определение генератору ответов. Еще одна интересная особенность Mathematica. Переопределить функцию можно не только указав другие типы аргументов или другое их число. Так же можно установить произвольное сложное условие выполнения функции с помощью знака "/;" (Condition[]). Условие удобнее всего писать между самим шаблоном (имя и аргументы/сигнатура функции) после знака "/;" и до знака ":=".
responseGenerator_ResponseGenerator[parsed_Association] /;
StringMatchQ[parsed[["Address"]], "/graphic?" ~~ ___] :=
Module[{function = ToExpression[StringSplit[parsed[["Address"]], "?"][[-1]]]},
ResponseString[ExportString[Plot[function[x], {x, -5, 5}], "SVG"]]
];
Можно проверить как это будет работать — перейти по первой ссылка на странице http://localhost:8888/index или просто по адресу http://localhost:8888/graphic?Sin. Теперь там отображается следующее:
Если же вместо ../graphic?Sin написать ../graphic?Cos или даже Log/Exp/Sqrt/Function[x,x^3]/т.д., то на странице отобразится соответствующий график. Теперь добавим в обработчик возможность отображать произвольную html-страницу, которая располагается в рабочей директории или в одной из поддиректорий. Для начала создадим эту страницу использовав код:
Export["page.html", TableForm[Table[{ToString[i] <> "!", i!}, {i, 1, 9}]], "HTML"];
К сожалению, любой выполнивший код из строки выше сразу же заметит, что экспорт выражений Mathematica в формат html занимает достаточно много времени. Получается, что для Математики не составит труда создать множество страниц или других элементов (картинок/таблиц), но экcпорт всех этих данных каждый раз будет занимать значительно большее время чем создание стандартными средствами. Что ж. Будем предполагать, что все страницы уж существуют и находятся на диске в рабочей директории сервера — там, куда мы только что сохранили тестовую страницу. Теперь попробуем отобразить ее. В этом случае страница и любые ее элементы импортируются в виде списка байт и соединяются со списком байт, которые соответствуют строке с заголовками ответа.
responseGenerator_ResponseGenerator[parsed_Association] /;
FileExistsQ[FileNameJoin[{Directory[], parsed[["Address"]]}]] &&
StringTake[parsed[["Address"]], -3] != ".nb" :=
Module[{path = FileNameJoin[{Directory[], parsed[["Address"]]}], data},
data = Import[path, "Byte"];
Join[ToCharacterCode[ResponseString["", Length[data]]], data]
];
Как и ожидалось, сервер вернул браузеру таблицу из значений факториала целых чисел. Последний случай. Отображение в браузере сохраненного блокнота. В рабочей директории создадим новый блокнот notebook.nb с помощью кода:
notebook = CreateDocument[{TextCell["Bubble sort","Section"],
ExpressionCell[Defer[list = RandomInteger[{0, 9}, 20]], "Input"],
ExpressionCell[Defer[list //. {firsts___, prev_, next_, lasts___} :>
{firsts, next, prev, lasts} /;
next < prev], "Input"]
}];
NotebookEvaluate[notebook, InsertResults -> True];
NotebookSave[notebook, FileNameJoin[{Directory[], "notebook.nb"}]]
NotebookClose[notebook];
Теперь выполняем действия похожие на те, что происходили при запросе html-страницы. Но перед возвращением html-кода сначала блокнот конвертируется в html.
responseGenerator_ResponseGenerator[parsed_Association] /;
FileExistsQ[FileNameJoin[{Directory[], parsed[["Address"]]}]] &&
StringTake[parsed[["Address"]], -3] == ".nb" :=
Module[{path, data, notebook},
path = FileNameJoin[{Directory[], parsed[["Address"]]}];
notebook = Import[path, "NB"];
Export[path <> ".html", notebook, "HTML"];
data = Import[path <> ".html", "Byte"];
Join[ToCharacterCode[ResponseString["", Length[data]]], data]
];
Точного отображения блокнота в браузере конечно же не будет. Все зависит от способа экспорта Математикой блокнота в html. Также для нашего сервера стоило бы добавить страницу ошибки, которую будет отображаться пользователю в случае отсутствия ресурса.
responseGenerator_ResponseGenerator[parsed_Association] /;
!FileExistsQ[FileNameJoin[{Directory[], parsed[["Address"]]}]] :=
Join[ToCharacterCode[StringReplace[ResponseString["Page not found"], "200 OK" -> "404 !OK"]]];
Очевидно, что показанная в этой статье реализация веб-сервера даже близко не является полной. Более того, она обладает большими недостатками. Как пример можно привести хотя бы то, что сервер в состоянии возвращать всего два кода: 200 и 404. Поэтому представленный код лучше всего рассматривать как экспериментальный/демонстративный.
Юридический аспект
Как стало известно автору — стандартная лицензия на Wolfram Mathematica не позволяет использовать ядро Математики внутри серверных приложений и для коммерческих и для личных целей. Это ограничение не распространяется для запланированных задач, которые не должны быть связаны с вебом. У Wolfram Research имеется собственная очень хорошая платформа для реализации больших (и не очень) серверных приложений — это webMathematica. Именно лицензия на webMathematica дает возможность избежать проблем с законом и использовать Wolfram Language на сервере, причем не важно будет приложение разработано на ней (webMathematica) или нет, лицензию приобрести все равно придется. Автор придерживается мнения, что его код никаких лицензионных соглашений не нарушает, так здесь показан просто текст программы позволяющий встроенными средствами Mathematica запустить веб-сервер на localhost. Ведь детектив, в котором описывается преступление сам по себе не является преступлением.
Вывод
В этой статье мне хотелось продемонстрировать в первую очередь интересные синтаксические возможности Wolfram Language. Различные способы создания функций, правил и условий при решении конкретной задачи нестандартным способом. А также я надеюсь, что данное руководство позволит энтузиастам попрактиковаться в веб-разработке комбинируя это с изучением Mathematica и ее возможностей. В свою очередь ожидаю всяческих советов по улучшению кода, возможные идеи для реализации в рамках этой задачи, а так же критику и замечания. Скачать блокнот содержащей данную работу можно по следующей ссылке. Всем спасибо за внимание!