Реализация HTTP файлового сервера с нуля с использованием Golang
Протокол HTTP - это протокол прикладного уровня, который обычно реализовывался на основе протокола TCP до HTTP/3. Поскольку протокол TCP является надежным протоколом потоковой связи, после установления соединения как отправители, так и получатели могут отправлять данные любой длины, и стек TCP также может выполнять нарезку данных. Таким образом, протоколы прикладного уровня на основе TCP должны согласовывать формат передачи сообщений, чтобы как отправители, так и получатели могли извлечь полное сообщение из принятого потока данных. Протокол HTTP - это одно из многих соглашений. Короче говоря, TCP - это протокол транспортного уровня, который обеспечивает потоковую связь, а HTTP определяет формат сообщения.
Основы протокола HTTP
Загрузка файлов - это самый простой из протоколов HTTP, и это то, для чего он изначально предназначался. Типичный запрос на загрузку выглядит следующим образом.
GET /index.html HTTP/1.1\r\n
Host: taoshu.in\r\n
User-Agent: httpie/1.0\r\n
\r\n
Все содержимое представлено простыми символами ASCII, с \r\n
, обозначающими строку. Первая строка называется строкой запроса и состоит из трех частей: метод запроса, путь и версию протокола. Каждая строка после строки запроса представляет собой набор заголовков, которые, в свою очередь, содержат имена и значения, разделенные двоеточиями. Последняя строка \r\n
указывает на завершение передачи заголовка сообщения.
Серверная сторона получает GET-запрос и должна разрешить из него путь к файлу, а затем отправить соответствующий контент клиенту. Однако перед передачей данных серверной стороне необходимо отправить текущий статус ответа.
HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Content-Length: 5\r\n
\r\n
hello
Первая строка - это строка состояния, которая включает в себя номер версии, код состояния и сообщение о состоянии. протокол HTTP определяет серию кодов состояния, 2XX для успешного выполнения, 4XX для ошибок на стороне клиента и 5XX для ошибок на стороне сервера. За строкой состояния также следует информация заголовка, которая совпадает с сообщением запроса. За пустой строкой следует фактическое содержимое файла, который необходимо передать.
HTTP/1.1 повторно использует базовое TCP-соединение по умолчанию, поэтому клиенту необходимо определить длину файла, в противном случае клиент будет продолжать ждать, пока сервер отправит данные. Существует два способа определить длину файла. Первый проще и задается непосредственно с помощью заголовка Content-Length
. Однако иногда сервер не уверен в общей длине данных при передаче файла, поэтому HTTP/1.1 поддерживает фрагментированную кодировку передачи. Короче говоря, это сегментированная передача, при которой длина сегмента передается до передачи данных. Последний сегмент имеет нулевое значение длины, указывающее на окончание передачи данных.
HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Transfer-Encoding: chunked\r\n
\r\n
2\r\n
he\r\n
3\r\n
llo\r\n
0\r\n
\r\n
Сегментированные передачи задаются с помощью заголовка Transfer-Encoding: chunked
. Таким образом, сегменты данных также находятся в строках с добавлением \r\n
в конце. В приведенном выше примере hello
разбивается на сегменты he
и llo
для передачи. Конечный 0\r\n\r\n
указывает на то, что длина этого сегмента равна нулю, т.е. окончание передачи данных.
Для экономии пропускной способности протокол HTTP поддерживает сжатие данных. Но только в том случае, если сервер поддерживает тот же алгоритм сжатия, что и клиент. При запросе файла клиент отправляет поддерживаемые в настоящее время алгоритмы сжатия через заголовок Accept-Encoding
, обычно gzip
и deflate
, с несколькими алгоритмами, разделенными запятыми. Сервер извлекает информацию заголовка и выбирает одну из поддерживаемых данных сжатия. Конкретный алгоритм сжатия должен быть указан в заголовке Content-Encoding
.
HTTP/1.1 200 OK\r\n
Content-Type: plain/text\r\n
Content-Length: 5\r\n
Content-Encoding: gzip\r\n
\r\n
[gzip binary data]
В этом случае заголовок Content-Length указывает общую длину сжатых данных.
Content-Type
указывает тип данных, который принимает значение типа MIME и должен оцениваться по содержимому файла. Распространенными типами являются plain/text
, plain/html
, image/png
и т.д.
Приведенные выше знания HTTP используются в этой статье, далее будут представлены базовые знания сетевого программирования на языке Go.
Основы сетевого программирования
Говоря в общем, сетевое программирование называется сетевым программированием TCP/IP. Поскольку IP имеет только сетевые адреса, пакеты могут быть отправлены с одного компьютера на другой. Но какая программа должна быть передана получателю для обработки данных после их получения Вот тут-то и вступает в игру протокол TCP. В частности, протокол TCP определяет концепцию портов на основе протокола IP. Обе взаимодействующие стороны должны не только определить IP-адрес, но и указать номер порта перед отправкой данных. После получения IP-данных операционная система находит соответствующий процесс по порту и передает его для обработки.
Поскольку связь дуплексная и обе стороны являются отправителем и получателем друг друга, обе стороны должны привязать номер порта. Сеанс TCP состоит из четырех частей: адрес источника, порт источника, адрес назначения и порт назначения, также называемый 4-кортежем, и, как правило, серверная программа должна указать свой собственный порт. Его еще называют прослушивающим портом, в противном случае клиент не знает, как подключиться. А номер порта клиента обычно назначается операционной системой автоматически.
Первый API для сетевого программирования - это прослушивание портов.
import "net"
ln, err := net.Listen("tcp", "0.0.0.0:8080")
Эта функция обеспечивается Listen
функцией net
модуля. Первый аргумент указывает тип протокола, в этом разделе используется только "tcp"
. Второй параметр указывает адрес и порт для привязки. Где 0.0.0.0
указывает на все IP-адреса, привязанные к текущему устройству. Устройство может иметь несколько NIC, несколько адресов на одном NIC и специальные адреса, такие как 127.0.0.1
. Для внешних сервисов проще всего привязать ко всем адресам, т.е. 0.0.0.0
. Это позволит вам обрабатывать данные, отправленные с любого адреса. Число 8080
после двоеточия — это порт для привязки. Обратите внимание, что программа не может прослушивать порты в диапазоне [0-1024]
без прав администратора.
Функция net.Dial
необходима для подключения клиентов к серверу. Поскольку в этом разделе речь идет только о стороне сервера, на стороне клиента используется готовый завиток, поэтому мы не будем подробно останавливаться на нем.
net.Listen
возвращает объект интерфейса net.Listener
. Наиболее важной функцией этого интерфейса является Accept()
. Эта функция вызывается сервером и зависает до тех пор, пока клиент не завершит установление связи TCP с сервером, а затем пробудится.
c, err := ln.Accept()
Функция Accept
возвращает интерфейс net.Conn
. Этот интерфейс немного сложнее.
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr
RemoteAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
Программы могут отправлять и получать данные с помощью функций Read
и Write
. Функция Close
используется для закрытия соединения. Функции LocalAddr
и RemoteAddr
используются для получения четырехкратной информации о TCP-соединении. Последние три функции используются для установки времени ожидания.
Контроль тайм-аута — очень важная тема в сетевом программировании. Если разумный тайм-аут не установлен, вредоносный клиент будет массово создавать большое количество TCP-соединений и не будет отправлять или получать данные самостоятельно, что в конечном итоге истощит ресурсы сервера, что является типичной атакой типа «отказ в обслуживании» (DDoS).
Контроль времени ожидания в Go довольно специфичен. Это требует, чтобы программа вычисляла абсолютное время, которое представляет собой текущее время плюс интервал времени ожидания, а затем передала его операционной системе. Если read/write не завершены к установленному сроку, вызов функции Read
или Write
вернет ошибку тайм-аута.
Самая простая сетевая программа называется Echo, которая отправляет полученные данные обратно клиенту без изменений.
package main
import "net"
func main() {
ln, _ := net.Listen("tcp", "0.0.0.0:8080")
for {
c, _ := ln.Accept()
buf := make([]byte, 1024)
for {
n, _ := c.Read(buf)
if n == 0 {
break
}
c.Write(buf[:n])
}
}
}
В этой программе отсутствует вся логика обработки ошибок, но фактический код должен тщательно проверять наличие ошибок и обрабатывать их соответствующим образом.
После запуска программа будет прослушивать порт 8080. Вы можете выполнить telnet 127.0.0.1 8080
, затем вывести любой контент и ввести, и telnet получит обратно тот же контент от службы.
Если вы запустите два процесса telnet одновременно, вы обнаружите, что второй заблокирован. Эта проблема возникает из-за того, что приведенный выше код Echo обрабатывается только одной Goroutine. До тех пор, пока первый telnet не отключится, внутренний цикл for не завершится. Говоря о выходе, функция Read
также возвращает значение, если telnet активно отключается, но первое возвращаемое значение равно нулю, что указывает на то, что клиент активно отключился. Если внутренний цикл не отделен, функция Accept
во внешнем цикле не сможет выполниться. Таким образом, второй telnet не может правильно установить TCP-соединение.
Решение также очень простое — используйте Goroutine. каждый раз, когда функция Accept
возвращает значение, создается новая Goroutine для запуска внутреннего цикла, а текущая Goroutine продолжает вызывать Accept
для ожидания следующего входящего соединения.
Общий дизайн
Мы хотим, чтобы приложение поддерживало следующие функции:
- загрузка файлов через GET-запросы
- сжатие данных с помощью Gzip
- передача больших файлов с использованием фрагментированных кодов передачи
- запись журналов доступа.
Для модели параллелизма мы используем простейший шаблон с несколькими Goroutine. Параллельный процесс, в котором расположена main
функция (основная Goroutine), отвечает за прослушивание порта и рекурсивный вызов Accept
для приема входящих подключений от клиента. Затем для каждого соединения запускается отдельная рабочая Goroutine для обработки HTTP-запроса. Этот подход, хотя и классический, слишком прост. Чтобы дополнительно продемонстрировать параллельное программирование, используем специализированную Goroutine для сбора и экспорта журналов.
Выполнение каждой рабочей Goroutine выглядит следующим образом:
- Чтение и разрешение HTTP-запроса
- Поиск соответствующей информации о файле на основе пути запроса
- Сжатие данных в соответствии с возможностями клиента
- Отправка HTTP-ответа и данные файла клиенту.
Каждая работающая Goroutine полностью независима и не влияет друг на друга.
Ниже мы начнем подробно описывать ключевой дизайн каждой детали.
Конструкция компонентов
Синтаксический анализ протокола
Наиболее сложным аспектом серверного программного обеспечения является синтаксический анализ протокола. HTTP - это высоко масштабируемый протокол, который очень гибок в использовании, но за счет того, что его очень сложно анализировать.
Ранее мы говорили, что TCP логически является потоковым протоколом. Однако при реализации данные передаются сегментами. Проще говоря, эта сегментация может привести к тому, что данные, полученные на принимающей стороне, будут несовместимы с данными, полученными на отправляющей стороне. Например, если клиент отправляет байты abcde
сразу, получатель может сначала получить abc
, а затем de
. Это, конечно, довольно преувеличенное утверждение. На самом деле эта проблема возникает только тогда, когда одновременно отправляются большие данные.
Существует также случай, когда клиент отправляет два фрагмента данных, которые принимаются сервером одновременно, или получает часть первого и второго. Предположим, клиент последовательно отправляет два заголовка User-Agent: curl/1.0\r\n
и Accept-Encoding: gzip\r\n
. Однако из-за возможной сегментации подложки TCP серверная сторона может получить User-Agent: curl/1.0\r\nAccept-Encoding:
. Обратите внимание, что следующая часть Accept-Encoding
является неполной.
В любом случае серверная часть должна быть совместима. Обработка также очень проста и классична, с использованием буферов.
n := 0
buf := make([]byte, 1024)
for {
n,_ = c.Read(buf[n:])
r, ok := parse(buf[:n])
if ok { /*..*/ }
copy(buf, buf[r:n])
n = n - r
}
Каждый раз, когда parse
заканчивается, ему необходимо возвращать смещение необработанных данных в buf
, а остальные данные программе нужно переместить в начало буфера, а затем пропустить эту часть данных, чтобы продолжить получение последующих данных от клиента. Это означает, что мы не можем просто предположить, что серверная сторона получает полный пакет за один раз.
Разбор HTTP-запросов
HTTP-запросы обрабатываются строками, и существуют различные способы их обработки. Самый простой — установить относительно большой буфер и попытаться собрать все данные запроса сразу. Если синтаксический анализ завершается неудачей, считается, что у клиента возникла проблема. Этот метод прост и жесток, но на практике используется редко.
Классический подход заключается в установке подходящего буфера, скажем, 1k байт. Это требует, чтобы каждая строка данных не превышала 1 КБ, в противном случае она не может быть проанализирована. Затем повторите полученные данные по одному за раз и используйте конечный автомат для записи текущей начальной позиции содержимого, подлежащего анализу.
Это немного сложно сказать, но давайте приведем пример.
GET /index.html HTTP/1.1\r\n
Сначала мы должны извлечь метод запроса GET
. Мы можем установить начальную позицию p
равной 0 и сканировать каждый байт до первого пробела. Предполагая, что текущая позиция представлена i
, тогда buf[p:i]
является методом запроса GET
.
Сразу после этого мы хотим пропустить пробелы. Для этого поведения можно установить отдельное состояние. В текущем состоянии, когда сканируется первый непустой символ, нам нужно записать текущую позицию p
и затем переключиться в состояние разбора путей. Когда сканируется другое пространство, buf[p:i]
соответствует запрошенному пути. И так далее, продолжайте переключаться и сканировать, и, наконец, закончите синтаксический анализ.
Общая структура кода представляет собой внешний цикл с большим switch
оператором, разветвляющимся на суждения внутри. Поскольку протокол HTTP сложен, конечный автомат должен устанавливать множество состояний. Многие правила не оцениваются, но могут нормально извлекать всю информацию, необходимую для этого раздела. Поскольку это простая версия, установлено только 16 состояний. Это уже сложнее для начинающих.
func (req *Request) Feed(buf []byte) (ParseStatus, int) {
var p, i int
status := ParseBegin
if req.status != ParseBegin {
status = req.status
}
var headerName, headerValue string
for i = 0; i < len(buf); i++ {
switch status {
case ParseBegin:
//...
case ParseMethod:
//...
default:
status = ParseError
break
}
}
req.status = status
return status, i
}
Хотя протокол HTTP требует использования разрывов строк \r\n
, почти все реализации поддерживают использование разрывов строк \n
. Совместимость с этой функцией также делает конечный автомат более сложным. Вы должны быть осторожны при чтении кода.
Кодирование передачи
Кодирование передачи в основном используется для решения проблемы использования памяти. Если ничего не сделано, мы можем отлично прочитать файл, который будет отправлен сначала в память, а затем отправить его клиенту. Но если файл очень большой, он будет занимать много памяти. С помощью фрагментированного кода передачи мы можем использовать буфер фиксированной длины для отправки файла сегментами. Основная логика заключается в следующем.
buf := make([]byte, 1024)
for {
n, err := f.Read(buf)
chunk := buf[:n]
if n == 0 {
break
}
hexSize := strconv.FormatInt(int64(n), 16)
w.Write([]byte("\r\n" + hexSize + "\r\n"))
a.Write(chunk)
}
w.Write([]byte("\r\n0\r\n\r\n"))
Вот маленькая хитрость. К каждому фрагменту данных добавляется \r\n
. Запись w.Write([]byte("\r\n"))
сама по себе потенциально может вызвать отправку дополнительных данных, но объединение их с предыдущими данными приведет к созданию копии в памяти. Поэтому мы отправляем \r\n
в конце текущих данных вместе с длиной следующей строки данных, следовательно, запишем w.Write([]byte("\r\n "+hexSize+"\r\n"))
.
Сжатие контента
При сжатии контента необходимо учитывать две проблемы. Во-первых, существует множество файлов, которые по своей природе являются сжатыми форматами, например, jpeg. сжимать их - пустая трата усилий. Вообще говоря, обычный текст лучше сжимается. Во-вторых, следует учитывать размер файла. Сжатие файла создаст дополнительную информацию о данных, и если сам файл относительно короткий, то его сжатие, вероятно, увеличит размер.
Существует также проблема, заключающаяся в том, что сжатие без потерь (например, алгоритм Хоффмана) требует предварительного чтения всего содержимого файла, а затем создания сжатого словаря, что требует немедленной загрузки файла в память. Объем уже определен после сжатия, поэтому нет необходимости в передаче по частям.
Стандартная библиотека языка Go поддерживает сжатие gzip, и она очень проста в использовании.
data, err := io.ReadAll(f)
ctype := http.DetectContentType(data)
gzip := false
if strings.HasPrefix(ctype, "text/") && len(data) > zipSize {
var buf bytes.Buffer
zw, _ := gzip.NewWriterLevel(&buf, gzip.DefaultCompression)
zw.Write(data)
zw.Close()
data = buf.Bytes()
gziped = true
}
Вам нужно вызвать функцию Close
после записи данных с помощью zw
, иначе gzip не запишет сжатые данные в буфер buf
.
Модуль ведения журнала
Как уже было написано ранее, для демонстрации параллельного программирования я поместил функцию ведения журнала в отдельную Goroutine. на самом деле в реальных проектах это рутинная операция. Поскольку система обрабатывает множество запросов одновременно, запись журналов сразу в конце каждого запроса повлияет на производительность системы. Поэтому мы обычно создаем буфер ведения журнала, а затем выводим его после заполнения буфера.
Однако, если в определенное время поступает мало запросов, буфер может не заполниться. Затем это необходимо было бы объединить с периодическим обновлением таймера. Такое поведение компонента ведения журнала особенно хорошо подходит для демонстрации использования Goroutine.
Основное определение журнала заключается в следующем.
type Log struct {
EntryNum int
Writer io.Writer
Interval time.Duration
i int
ch chan entry
t *time.Ticker
entries []entry
}
Буквы верхнего регистра начинаются с EntryNum
, чтобы вызывающий абонент мог изменить настройки. EntryNum
обозначает длину очереди буфера, Writer
обозначает реальный объект для вывода журнала, а Interval
обозначает интервал обновления по времени.
Основная логика обработки журнала заключается в следующем.
func (l *Log) Loop() {
for {
select {
case e := <-l.ch:
l.entries[l.i] = e
l.i++
if l.i == l.EntryNum {
l.flush()
}
case _ = <-l.t.C:
l.flush()
}
}
}
Нам нужно запустить функцию Loop
в отдельной Goroutine. Он прослушивает оба канала l.ch
и l.t.C
в цикле через select
. Любой канал с сообщением обрабатывается вовремя. Если есть журналы, они сохраняются в очередь l.entries
. Если количество журналов достигает l.EntryNum
, оно сбрасывается. если данных журнала недостаточно, но время истекло, оно также принудительно удаляется. Таким образом достигается эффект, описанный выше.
Вышеизложенное является кратким введением в дизайнерские идеи каждого компонента. Конкретные детали должны быть внимательно прочитаны в исходном коде.
Структура кода
Полный код размещен на GitHub со следующей структурой каталогов.
├── LICENSE
├── README.md
├── cmd
│ └── sfile
│ └── main.go # main
├── go.mod
├── go.sum
├── http # HTTP protocol related
│ ├── file.go # chunked and gzip related
│ ├── file_test.go
│ ├── http.go # HTTP request parsing
│ └── http_test.go
├── log
│ ├── log.go # Logging Module
│ └── log_test.go
└── server
└── server.go # Server
Таким образом, к основной логике добавляются модульные тесты, а код сохраняется в соответствующем файле *_test.go
.
Заключение
Надеемся, что благодаря практике вы сможете углубить свое понимание языка Go. Если вы уже знакомы со всем кодом sfile, вы можете рассмотреть возможность изучения стандартной библиотеки Go net/http
, а затем попытаться повторно реализовать sfile с помощью стандартной библиотеки.