Не так давно я взялся за изучение Common Lisp. Как может показаться, изучение нового языка программирования — дело весьма не простое, тем более если он совсем непохож на все те языки, с которыми приходилось сталкиваться ранее. Поэтому я решил начать с книги Land Of Lisp. Книга весьма неплохая, с интересными картинками и очень хорошо подходит для начинающих. В одной из глав было описание создания веб-сервера на Common Lisp. Я решил слегка развить эту тему, и в итоге у меня получилось не совсем то, что было описано в этой главе, а весьма интересный веб-сервер. Исходные коды можно посмотреть тут.
Для его написания нам понадобится Linux с установленными emacs, sbcl, slime и quicklisp. Описывать, как это всё устанавливать, настраивать и как этим пользоваться, я не стану — в интернете есть множество статей об этом. Весь наш веб-сервер будет находиться в одном пакете, называемом myweb. Создайте у себя папку с данным названием, и в ней создайте две папки log и web. Папка log будет содержать лог-файл веб-сервера. В папке web будут лежать html-страницы и изображения, которые веб-сервер будет отдавать клиентам. Весь веб-сервер состоит из семи файлов.
Начнём с файла, обьявляющего пакет, и asd файла описания самого пакета.
Создайте файл package.lisp:
Как видите, наш веб-сервер состоит из трех пакетов:
Функция in-package как правило ставится в начале файла и указывает имя пакета в котором мы объявляем переменные и функции. В данном случае так как мы объявляем пакеты, то мы должны объявить их в основном пакете :cl-user.
Обратите внимание на директивы :use и :export в обьявлении пакетов. :use позволяет нам пользоваться функциями из других пакетов без указания названия пакетов в начале имени функции, тем самым сокращая количество набираемого текста. :export задает имена тех функций, которыми можно пользоватся вне пакета. Как можно увидеть, у нас в пакете :myweb есть функции :start-http и :stop-http. Находясь в пакете :cl-user, мы не сможем вызывать их через myweb:start-http, если предварительно не объявим их с помощью директивы :export.
Обьявление пакетов у нас уже есть, теперь осталось написать сам исходный код этих пакетов. Создайте файлы web.lisp, util.lisp и handler.lisp и в каждом из них добавьте вызов in-package. Для web.lisp — (in-package :myweb), для util.lisp (in-package :myweb.util) и т.д. Нам также понадобится создать файл log.lisp c вызовом (in-package :cl-log). Этот файл нужен для запуска и конфигурации системы логирования cl-log.
Завершающим штрихом создания структуры файлов для веб-сервера будет создание файла myweb.asd, описывающего, какие файлы система asdf должна загрузить, чтоб у нас всё работало.
Ключ :serial t указывает, чтоб asdf загружала файлы в том же порядке, в каком они у нас перечислены.
Теперь нужно написать файл load.lisp, который будет подгружать наш пакет и запускать swank сервер для slime.
Чтобы продолжить разработку, нам нужно уже запустить swank и загрузить все нужные библиотеки с помощью quicklisp. Для этого запустите sbcl, находясь в директории myweb, и вызовите функцию (quicklisp:quickload «swank»). После установки swank запустите swank-сервер, вызвав (swank:create-server) из коммандной строки sbcl.
Используя slime-connect из emacs, подсоединитесь к запущенному sbcl и вызовите все остальные функции с quicklisp из load.lisp при помощи slime-mode в emacs и комбинации клавиш ctrl-e. Если вы всё сделали правильно, то quicklisp скачает всё нужные библиотеки и подгрузит их с помощью asdf за вас. Все готово к началу разработки.
Начнём с самого веб-сервера. Для него нам понадобятся сокеты. Работы с сокетами я решил реализовать с помощью широко распространнёной библиотеки usocket. Также нам понадобится потоки (threads), для которых мы будем использовать bordeaux-threads. Но сначала я хотел бы рассказать о той модели обработки http-запросов, которую мы собираемся создать. Каждый запрос будет обрабатываться отдельным потоком. У нас будут потоки-worker'ы, которые будут создаваться в зависимости от количества запросов. Среди них у нас будут отдельные idle-потоки, которые после завершения обработки запроса будут переходить в состояние condition-wait, ожидая новых запросов. Тем самым можно снизить нагрузку от создания новых worker-потоков. Получается своеобразный механизм thread pool для обработки http-запросов.
Начнём с обьявления сокетов и переменных для mutex-ов в файле web.lisp:
Для принятия и распределения запросов по потокам мы будем использовать отдельный поток, указатель на который будет храниться в *listen-thread*. Начнём с метода start-http:
Это простая функция для запуска потока-распределителя, который в свою очередь будет вызывать функцию http-acceptor. Также у нас есть два ключа — это worker-limit — максимальное кол-во worker-ов, и idle-workers — кол-во idle worker-ов.
Напишем саму функцию распределения запросов:
Первое, что мы делаем, это socket-listen на указанный адрес и порт. Далее в цикле мы делаем socket-accept, получая в результате socket на подключённого клиента, который мы должны обработать в worker-е. Плюс мы присваиваем запросу request-id. На этом этапе мы должны решить, что делать с запросом и как его обработать. Первым делом мы проверяем количество idle-потоков. Если у нас все worker-ы заняты, мы добавляем запрос в очередь для обработки. Если же у нас есть свободный idle worker, то мы опять-таки добавляем запрос в очередь, но на этот раз вызываем (condition-notify (caar *idle-workers*))). И в третьем случае мы просто создаём новый worker и передаём ему запрос, который будет обработан в функции worker-thread. Всё достаточно просто. Осталось лишь написать функцию обработки worker-потока:
Если у нас произошёл вызов с request-id, то нам нужно в первую очередь обработать запрос. Мы просто вызываем вспомогательную функцию http-worker и передаём ей socket клиента. Далее мы проверяем, есть ещё запросы на обработку: просто убираем первый же запрос из очереди и передаём его в worker-thread на обработку, вызывая тем самым функцию worker-thread рекурсивно. Может возникнуть вопрос «а не случится ли recursion limit от того, что стек переполнится в какой-то момент, например при большом кол-ве запросов в очереди?» Так как после вызова worker-thread рекурсивно у нас ничего в функции не вызывается, то recursion limit не произойдёт. Почти все современные реализации Common Lisp поддерживают эту оптимизацию. Ну и если очередь пуста, то нам осталось проверить количество idle worker-ов. Если у нас всё в порядке, то мы просто завершаем запрос и убираем worker из списка worker-ов. Если же нет, то мы делаем condition-wait, и тем самым worker становится idle worker-ом.
Если вы заметили, то мы также вызываем list-workers. Эта вспомогательная функция, которая просто очищает лист worker-ов от мертвых потоков.
Осталось написать http-worker функцию:
Здесь мы создаем socket-stream, парсим запрос и передаем его в myweb.handler:process-request (об этих функциях мы поговорим во второй части). list-workers просто возвращает нам список worker-ов, предварительно очистив его от мертвых потоков. Мы вызываем эту функцию в worker-thread перед condition-wait.
Последнее, что нам нужно сделать — это написать функцию stop-http, которая будет останавливать наш веб-сервер:
Как видите, здесь всё просто — мы останавливаем поток распределителя, убиваем все worker-ы и обнуляем списки.
И так, всё готово для того, чтобы обрабатывать наши запросы. Об этом мы поговорим во второй части.
Cпасибо за внимание!
P.S. Спасибо ertaquo за помощь с орфографией и layout-ом
Для его написания нам понадобится Linux с установленными emacs, sbcl, slime и quicklisp. Описывать, как это всё устанавливать, настраивать и как этим пользоваться, я не стану — в интернете есть множество статей об этом. Весь наш веб-сервер будет находиться в одном пакете, называемом myweb. Создайте у себя папку с данным названием, и в ней создайте две папки log и web. Папка log будет содержать лог-файл веб-сервера. В папке web будут лежать html-страницы и изображения, которые веб-сервер будет отдавать клиентам. Весь веб-сервер состоит из семи файлов.
Начнём с файла, обьявляющего пакет, и asd файла описания самого пакета.
Создайте файл package.lisp:
(in-package :cl-user) (defpackage :myweb (:use :cl :usocket :bordeaux-threads) (:export :start-http :stop-http :list-workers :list-requests)) (defpackage :myweb.util (:use :cl :local-time) (:export :parse-request :read-utf-8-string :response-write :get-param :get-header :http-response :file-response :html-template :log-info :log-warning :log-error)) (defpackage :myweb.handler (:use :cl) (:export :process-request))
Как видите, наш веб-сервер состоит из трех пакетов:
- myweb — будет содержать функции для запуска и остановки веб-сервера
- myweb.util — будет содержать функции, помогающие обрабатывать запросы
- myweb.handler — будет содержать сам код обработки запроса
Функция in-package как правило ставится в начале файла и указывает имя пакета в котором мы объявляем переменные и функции. В данном случае так как мы объявляем пакеты, то мы должны объявить их в основном пакете :cl-user.
Обратите внимание на директивы :use и :export в обьявлении пакетов. :use позволяет нам пользоваться функциями из других пакетов без указания названия пакетов в начале имени функции, тем самым сокращая количество набираемого текста. :export задает имена тех функций, которыми можно пользоватся вне пакета. Как можно увидеть, у нас в пакете :myweb есть функции :start-http и :stop-http. Находясь в пакете :cl-user, мы не сможем вызывать их через myweb:start-http, если предварительно не объявим их с помощью директивы :export.
Обьявление пакетов у нас уже есть, теперь осталось написать сам исходный код этих пакетов. Создайте файлы web.lisp, util.lisp и handler.lisp и в каждом из них добавьте вызов in-package. Для web.lisp — (in-package :myweb), для util.lisp (in-package :myweb.util) и т.д. Нам также понадобится создать файл log.lisp c вызовом (in-package :cl-log). Этот файл нужен для запуска и конфигурации системы логирования cl-log.
Завершающим штрихом создания структуры файлов для веб-сервера будет создание файла myweb.asd, описывающего, какие файлы система asdf должна загрузить, чтоб у нас всё работало.
;; myweb.asd (asdf:defsystem #:myweb :serial t :components ((:file "package") (:file "log") (:file "util") (:file "web") (:file "handler")))
Ключ :serial t указывает, чтоб asdf загружала файлы в том же порядке, в каком они у нас перечислены.
Теперь нужно написать файл load.lisp, который будет подгружать наш пакет и запускать swank сервер для slime.
(in-package :cl-user) (quicklisp:quickload "swank") (quicklisp:quickload "usocket") (quicklisp:quickload "bordeaux-threads") (quicklisp:quickload "trivial-utf-8") (quicklisp:quickload "cl-log") (quicklisp:quickload "local-time") (pushnew '*default-pathname-defaults* asdf:*central-registry*) (asdf:load-system 'myweb) (swank:create-server)
Чтобы продолжить разработку, нам нужно уже запустить swank и загрузить все нужные библиотеки с помощью quicklisp. Для этого запустите sbcl, находясь в директории myweb, и вызовите функцию (quicklisp:quickload «swank»). После установки swank запустите swank-сервер, вызвав (swank:create-server) из коммандной строки sbcl.
Используя slime-connect из emacs, подсоединитесь к запущенному sbcl и вызовите все остальные функции с quicklisp из load.lisp при помощи slime-mode в emacs и комбинации клавиш ctrl-e. Если вы всё сделали правильно, то quicklisp скачает всё нужные библиотеки и подгрузит их с помощью asdf за вас. Все готово к началу разработки.
Начнём с самого веб-сервера. Для него нам понадобятся сокеты. Работы с сокетами я решил реализовать с помощью широко распространнёной библиотеки usocket. Также нам понадобится потоки (threads), для которых мы будем использовать bordeaux-threads. Но сначала я хотел бы рассказать о той модели обработки http-запросов, которую мы собираемся создать. Каждый запрос будет обрабатываться отдельным потоком. У нас будут потоки-worker'ы, которые будут создаваться в зависимости от количества запросов. Среди них у нас будут отдельные idle-потоки, которые после завершения обработки запроса будут переходить в состояние condition-wait, ожидая новых запросов. Тем самым можно снизить нагрузку от создания новых worker-потоков. Получается своеобразный механизм thread pool для обработки http-запросов.
Начнём с обьявления сокетов и переменных для mutex-ов в файле web.lisp:
(defvar *listen-socket* nil) (defvar *listen-thread* nil) (defvar *request-mutex* (make-lock "request-mutex")) (defvar *request-threads* (list)) (defvar *worker-mutex* (make-lock "worker-mutex")) (defvar *workers* (list)) (defvar *worker-num* 0) (defvar *idle-workers* (list)) (defvar *idle-workers-num* 0) (defvar *request-queue* (list))
Для принятия и распределения запросов по потокам мы будем использовать отдельный поток, указатель на который будет храниться в *listen-thread*. Начнём с метода start-http:
(defun start-http (host port &key (worker-limit 10) (idle-workers 1)) (if (not *listen-socket*) (setq *listen-thread* (make-thread (lambda () (http-acceptor host port worker-limit idle-workers)) :name "socket-acceptor")) "http server already started"))
Это простая функция для запуска потока-распределителя, который в свою очередь будет вызывать функцию http-acceptor. Также у нас есть два ключа — это worker-limit — максимальное кол-во worker-ов, и idle-workers — кол-во idle worker-ов.
Напишем саму функцию распределения запросов:
(defun http-acceptor (host port worker-limit idle-workers) (setq *listen-socket* (socket-listen host port :reuse-address t :element-type '(unsigned-byte 8) :backlog (* worker-limit 2))) (let ((request-id 0) (worker-id 0)) (loop while *listen-thread* do (let* ((socket (socket-accept *listen-socket* :element-type '(unsigned-byte 8)))) (progn (setq request-id (1+ request-id)) (acquire-lock *worker-mutex*) (if (>= *worker-num* worker-limit) (push (cons request-id socket) *request-queue*) ;; Get worker from idle workers (if (> *idle-workers-num* 0) (progn (push (cons request-id socket) *request-queue*) (condition-notify (caar *idle-workers*))) ;; Add new Worker (progn (setq worker-id (1+ worker-id)) (setq *worker-num* (1+ *worker-num*)) (setq *workers* (cons (make-thread (lambda () (worker-thread request-id socket idle-workers)) :name (concatenate 'string "socket-worker-" (prin1-to-string worker-id))) *workers*))))) (release-lock *worker-mutex*) t)))))
Первое, что мы делаем, это socket-listen на указанный адрес и порт. Далее в цикле мы делаем socket-accept, получая в результате socket на подключённого клиента, который мы должны обработать в worker-е. Плюс мы присваиваем запросу request-id. На этом этапе мы должны решить, что делать с запросом и как его обработать. Первым делом мы проверяем количество idle-потоков. Если у нас все worker-ы заняты, мы добавляем запрос в очередь для обработки. Если же у нас есть свободный idle worker, то мы опять-таки добавляем запрос в очередь, но на этот раз вызываем (condition-notify (caar *idle-workers*))). И в третьем случае мы просто создаём новый worker и передаём ему запрос, который будет обработан в функции worker-thread. Всё достаточно просто. Осталось лишь написать функцию обработки worker-потока:
(defun worker-thread (request-id socket idle-workers) (if request-id ;; Process request if it is not nil (progn (with-lock-held (*request-mutex*) (setq *request-threads* (cons (cons request-id (current-thread)) *request-threads*)) ) (http-worker socket) (with-lock-held (*request-mutex*) (setq *request-threads* (remove-if (lambda (x) (eq (car x) request-id)) *request-threads*)) ) )) (acquire-lock *worker-mutex*) (if *request-queue* (let ((request nil)) (setq request (car *request-queue*)) (setq *request-queue* (cdr *request-queue*)) (release-lock *worker-mutex*) (worker-thread (car request) (cdr request) idle-workers)) (if (< *idle-workers-num* idle-workers) (let ((condition (make-condition-variable)) (idle-lock (make-lock)) (request nil)) (push (cons condition (current-thread)) *idle-workers*) (setq *idle-workers-num* (1+ *idle-workers-num*)) (release-lock *worker-mutex*) (list-workers) (with-lock-held (idle-lock) (condition-wait condition idle-lock) ) (with-lock-held (*worker-mutex*) (setq *idle-workers* (cdr *idle-workers*)) (setq *idle-workers-num* (1- *idle-workers-num*)) (setq request (car *request-queue*)) (setq *request-queue* (cdr *request-queue*)) ) (worker-thread (car request) (cdr request) idle-workers)) (progn (setq *workers* (remove (current-thread) *workers*)) (setq *worker-num* (1- *worker-num*)) (release-lock *worker-mutex*)))))
Если у нас произошёл вызов с request-id, то нам нужно в первую очередь обработать запрос. Мы просто вызываем вспомогательную функцию http-worker и передаём ей socket клиента. Далее мы проверяем, есть ещё запросы на обработку: просто убираем первый же запрос из очереди и передаём его в worker-thread на обработку, вызывая тем самым функцию worker-thread рекурсивно. Может возникнуть вопрос «а не случится ли recursion limit от того, что стек переполнится в какой-то момент, например при большом кол-ве запросов в очереди?» Так как после вызова worker-thread рекурсивно у нас ничего в функции не вызывается, то recursion limit не произойдёт. Почти все современные реализации Common Lisp поддерживают эту оптимизацию. Ну и если очередь пуста, то нам осталось проверить количество idle worker-ов. Если у нас всё в порядке, то мы просто завершаем запрос и убираем worker из списка worker-ов. Если же нет, то мы делаем condition-wait, и тем самым worker становится idle worker-ом.
Если вы заметили, то мы также вызываем list-workers. Эта вспомогательная функция, которая просто очищает лист worker-ов от мертвых потоков.
Осталось написать http-worker функцию:
(defun http-worker (socket) (let* ((stream (socket-stream socket)) (request (myweb.util:parse-request stream))) (myweb.handler:process-request request stream) (finish-output stream) (socket-close socket))) (defun list-workers () (with-lock-held (*worker-mutex*) (setq *workers* (remove-if (lambda (w) (not (thread-alive-p w))) *workers*)) (setq *worker-num* (length *workers*)) *workers*))
Здесь мы создаем socket-stream, парсим запрос и передаем его в myweb.handler:process-request (об этих функциях мы поговорим во второй части). list-workers просто возвращает нам список worker-ов, предварительно очистив его от мертвых потоков. Мы вызываем эту функцию в worker-thread перед condition-wait.
Последнее, что нам нужно сделать — это написать функцию stop-http, которая будет останавливать наш веб-сервер:
(defun stop-http () (if *listen-socket* (progn (stop-thread) (socket-close *listen-socket*) (setq *listen-socket* nil) (setq *request-queue* nil) (setq *worker-num* 0) (setq *workers* nil) (mapcar (lambda (i) (destroy-thread (cdr i))) *idle-workers*) (setq *idle-workers-num* 0) (setq *idle-workers* nil) (release-lock *worker-mutex*) (setq *request-threads* nil) (release-lock *request-mutex*) (setq *request-mutex* (make-lock "request-mutex")) (setq *worker-mutex* (make-lock "worker-mutex"))))) (defun stop-thread () (if (and *listen-thread* (thread-alive-p *listen-thread*)) (destroy-thread *listen-thread*)))
Как видите, здесь всё просто — мы останавливаем поток распределителя, убиваем все worker-ы и обнуляем списки.
И так, всё готово для того, чтобы обрабатывать наши запросы. Об этом мы поговорим во второй части.
Cпасибо за внимание!
P.S. Спасибо ertaquo за помощь с орфографией и layout-ом