Перейти к содержимому

Как всё-таки использовать интернет-плату?

Fingercomp

3 153 просмотра

Среди всех компонентов в OC у интернет-платы самый ужасный API. Неудивительно, что правильно использовать его умеют немногие. Даже за Vexatos мне приходилось чинить tape.lua — программку для записи кассет. Плюс в ирке нередко спрашивают, как отправить HTTP-запрос на сервер. Значит, пришло время написать, как же всё-таки использовать интернет-плату.

 

Гайд строится на следующих предположениях (сорри за педантизм):

  • Вы умеете прогать на Lua, в том числе знаете о двух основных способах возвращать ошибку.
  • Вы писали уже программы для OpenComputers, которые использовали API этого мода или OpenOS, особенно либу event.
  • Вы как-то использовали (или пытались использовать) интернет-карточку в программах.
  • Секции 1, 3: вы понимаете основные принципы HTTP.
  • Секции 2, 4: вы понимаете, как пользоваться TCP-сокетами и зачем (не обязательно в Lua).
  • Секция 4: вас не смущает setmetatable и вы понимаете, как делать ООП на прототипах.
  • Секции 2, 4: у вас OC 1.6.0 или выше.
  • Секции 1, 3, 5: у вас OC 1.7.5 или выше.
  • Текущая версия мода — 1.7.5, а в новой ничего не изменилось.

 

У инет-карты есть две разных фичи — HTTP-запросы и TCP-сокеты. Кратко пробежимся по API и затем разберём детальнее применение. Рассматривать я буду API компонента: часто используют require("internet") — это не компонент, а обёртка.

 

1. Отправка HTTP-запросов: component.internet.request

У этого метода 4 параметра:

  1. URL, на который надо послать запрос. На всякий случай, URL начинается со схемы (http: или https:), после которого идёт адрес хоста (например: //localhost, //127.0.0.1, //[::1], //google.com:443), за которым следует путь (/my-file.html). Пример: https://computercraft.ru/blogs/entry/666-profiliruem-programmy-pod-oc/.
  2. Данные запроса. Оно же тело запроса. Если мы отправляем GET/HEAD-запрос, то этот аргумент надо установить в nil.
  3. Хедеры, которыми запрос сопровождать. Можно поставить nil, тогда там по минимуму дефолтные подтянутся. Иначе передавать надо таблицу. Её ключи — это названия хедеров. Например, {["Content-Type"] = "application/json"}.
  4. Метод запроса. Если же этот аргумент не передавать, то возьмётся по дефолту GET или POST: это зависит от того, пуст ли аргумент 2 или нет.


Если возникла ошибка, метод вернёт nil и сообщение об ошибке.

Если же всё нормально, то метод вернёт handle — табличку с функциями. Вот что это за функции:

  1. handle.finishConnect() — проверяет, подключены ли мы к серверу.
    • Если да, то вернёт true.
    • Если к серверу ещё не подключены, то вернёт false.
    • Если же возникла ошибка (например, 404 вернул сервер или закрыл соединение), то вернёт nil и сообщение об ошибке. Например, nil, "connection lost".
    • В доках написано, что функция ошибку пробрасывает. На самом деле нет: она вообще не бросает исключения.
  2. handle.response() — возвращает мета-данные ответа с сервера.
    • Если соединение ещё не установлено, вернёт nil.
    • Если возникла ошибка, вернёт nil и сообщение об ошибке. Например, nil, "connection lost".
    • В противном случае возвращает 3 значения:
      1. Код ответа (например, 200).
      2. Статус (например, "OK").
      3. Таблицу с хедерами, которые отправил сервер. Выглядит примерно так: {["Content-Type"] = {"application/json", n = 1}, ["X-My-Header"] = {"value 1", "value 2", n = 2}}. Выпишу отдельно, что значения таблицы — это не строки, а ещё одни таблицы.
  3. handle.read([n: number]) — читает n байт (если n не задано, то сколько сможет).
    • Если компьютер ещё не успел получить данные, то отдаст "".
    • Если возникла ошибка, то выдаст nil и сообщение об ошибке. Например, nil, "connection lost".
    • Если сервер закрыл соединение, то вернёт nil.
    • В противном случае отдаст строку с частью ответа.
  4. handle.close() — закрывает соединение.

 

2. TCP-сокеты: component.internet.connect

У метода есть 2 параметра:

  1. Адрес хоста. Например, 127.0.0.1. Здесь также можно указать порт: google.com:80.
  2. Порт. Если в первом аргументе порта нет, то второй параметр обязателен.

 

Если возникла ошибка, он также вернёт nil и сообщение. Иначе возвращает handle — табличку с функциями. Вот такими:

  1. handle.finishConnect() — то же, что и выше.
  2. handle.read([n: number]) — то же, что и выше.
  3. handle.write(data: string) — отправляет data по сокету на сервер. Возвращает число переданных байт.
    • Если соединение не установлено, это число равно 0.
  4. handle.close() — то же, что и выше.
  5. handle.id() — возвращает id сокета.

 

3. Как правильно отправить HTTP-запрос на сервер и получить ответ

Чтобы было интереснее, реальная задача: написать аналог pastebin, только вместо пастбина использовать https://clbin.com/. Особенности:

  1. Для взаимодействия с сайтом нужно отправлять HTTP-запросы: GET и POST. Это всё OC умеет.
  2. Чтобы скачать, достаточно простого GET по ссылке. Это можно сделать даже через wget.
  3. А вот чтобы отправить файл, надо использовать MIME-тип multipart/form-data. OC не умеет из коробки такие формы отправлять. Мы напишем минимальную реализацию, которая бы нас устроила.
  4. Не забываем, что этот MIME-тип нужно установить в хедер.
  5. При этом мы хотим красиво обработать все ошибки и не допустить ошибок сами.

Таким образом, использовать будем практически все фичи.

 

3.1. multipart/form-data

Порядок особенностей нам не важен, поэтому начинаем с самого скучного. Сделаем функцию, которая принимает данные и обрамляет их согласно формату multipart/form-data.

local function generateBorder(str)
  local longestOccurence = nil

  for match in str:gmatch("%-*cldata") do
    if not longestOccurence or #match > #longestOccurence then
      longestOccurence = match
    end
  end
  
  return longestOccurence and ("-" .. longestOccurence) or "cldata"
end

local function asFormData(str, fieldName)
  local border = generateBorder(str)
  
  local contentType = "multipart/form-data; boundary=" .. border
  
  return ([[
--%s
Content-Disposition: form-data; name="%s"

%s
--%s--]]):format(
    border,
    fieldName,
    str,
    border
  ), contentType
end

Так как это не туториал по интернет-стандартам, вдаваться в детали реализации не буду.

С помощью asFormData можно содержимое файла превратить в тело HTTP-запроса. Мы будем вызывать asFormData(str, "clbin"), ибо этого требует сайт.

Кроме того, эта функция нам передаст значение хедера Content-Type. Он нам понадобится.

 

3.2. Взаимодействие с сайтом

Напишем теперь функцию — обёртку над component.internet.request.

local function request(url, body, headers, timeout)
  local handle, err = inet.request(url, body, headers) -- ①
  
  if not handle then
    return nil, ("request failed: %s"):format(err or "unknown error")
  end

  local start = comp.uptime() -- ②
  
  while true do
    local status, err = handle.finishConnect() -- ③
    
    if status then -- ④
      break
    end
    
    if status == nil then -- ⑤
      return nil, ("request failed: %s"):format(err or "unknown error")
    end
    
    if comp.uptime() >= start + timeout then -- ⑥
      handle.close()

      return nil, "request failed: connection timed out"
    end
    
    os.sleep(0.05) -- ⑦
  end

  return handle -- ⑧
end

Эту функцию можно прямо брать и копипастить в свои программы. Что она делает:

  1. ① — отправляем запрос. Сразу обрабатываем ошибку.
  2. ② — запрос доходит до сервера не мгновенно. Нужно подождать. Чтобы не зависнуть слишком долго, мы засекаем время начала.
  3. ③ — вызываем finishConnect, чтобы узнать статус подключения.
  4. ④ — finishConnect вернул true. Значит, соединение установлено. Уходим из цикла.
  5. ⑤ — finishConnect вернул nil. Мы специально проверяем через status == nil, потому что не нужно путать его с false. nil — это ошибка. Поэтому оформляем его как ошибку.
  6. ⑥ — проверяем, висим ли в цикле мы слишком долго. Если да, то тоже возвращаем ошибку. Не забываем закрыть за собой соединение.
  7. ⑦ — нам не нужен бизи-луп. Спим.
  8. ⑧ — мы не читаем сразу всё в память, чтобы экономить память. Вместо этого отдаём наружу handle.

 

Частая ошибка — отсутствие элементов ②–⑦. Они нужны. Если до установки соединения мы вызовем handle.read(), то получим nil. Многие программы в этом случае сразу отчаются получить ответ и вернут ошибку. А надо было просто подождать.

 

3.3. Отправка файла

Функция для отправки файла должна сначала прочесть его содержимое, затем сделать запрос и прочесть ответ. В ответе будет находиться URL файла.

local function sendFile(path)
  local f, err = io.open(path, "r") -- ①

  if not f then
    return nil, ("could not open file for reading: %s"):format(err or "unknown error")
  end

  local contents = f:read("*a") -- ②
  f:close()

  local data, contentType = asFormData(contents, "clbin") -- ③
  local headers = {["Content-Type"] = contentType}

  local handle, err = request("https://clbin.com", data, headers, 10) -- ④

  if not handle then
    return nil, err
  end

  local url = {} -- ⑤
  local read = 0

  local _, _, responseHeaders = handle.response() -- ⑥
  local length

  for k, v in pairs(responseHeaders) do -- ⑦
    if k:lower() == "content-length" then
      length = tonumber(v)
    end
  end

  while not length or read < length do -- ⑧
    local chunk, err = handle.read()

    if not chunk then
      if length then -- ⑨
        return nil, ("error occured while reading response: %s"):format(err or "unknown error") -- ⑩
      end

      break -- ⑩
    end

    read = read + #chunk -- ⑪

    if length and read > length then
      chunk = chunk:sub(1, length - read - 1) -- ⑫
    end

    table.insert(url, chunk)
  end

  handle.close() -- ⑬

  return table.concat(url) -- ⑭
end
  1. ① — открываем файл для чтения. Обрабатываем ошибки.
  2. ② — считываем всё из файла. Не забываем закрыть его за собой.
  3. ③ — вызываем заранее написанную функцию asFormData. Мы получаем тело запроса и значение хедера Content-Type. Создаём таблицу хедеров.
  4. ④ — отправляем наш запрос. Обрабатываем ошибки.
  5. ⑤ — handle.read может не сразу вернуть весь ответ, а кусочками. Чтобы не забивать память кучей строк, кусочки мы будем класть в таблицу (получится что-то вроде {"htt", "p://", "clbi", "n.co", "m/ab", "cdef"}). Также мы храним число прочитанных байт.
  6. ⑥ — мы хотим сверять число прочитанных байт с ожидаемым размером ответа. Для этого нам потребуется получить хедеры, отправленными сервером. Вызываем handle.response.
  7. ⑦ — размер ответа обычно пишется в заголовок Content-Length. Однако сервер может поиграться с регистром. Например, писать content-length или CONTENT-LENGTH. OpenComputers не трогает эти хедеры. Поэтому придётся пройтись по всем ключам таблицы и найти хедер без учёта регистра.
  8. ⑧ — если length не nil, то это число. Тогда проверяем, что ещё столько байт мы не прочли, и заходим в цикл. Если же Content-Length не задан, то будем считать, что серверу не важно, сколько надо прочесть, и крутимся до упора.
  9. ⑨ — handle.read может ещё вернуть ошибку. Если нам известна длина, то в силу условия цикла мы прочли меньше, чем ожидали. Сигналим о неудаче. (Закрывать соединение в случае ошибки не требуется.)
  10. ⑩ — если же длина неизвестна, то считаем, что сервер отдал всё, что мог, ошибку игнорируем и покидаем цикл.
  11. ⑪ — не забываем обновлять read.
  12. ⑫ — если сервер случайно отослал нам больше данных, чем надо (а мы знаем, сколько надо: length определён), то излишки обрезаем. Код здесь отрежет с конца строки (read - length) байт.
  13. ⑬ — закрываем соединение за собой, когда оно больше не нужно.
  14. ⑭ — наконец, склеиваем таблицу в одну строку.

 

3.4. Скачивание файлов

Код для скачивания похож на предыдущий. Только вот в память мы записывать ответ с сервера уже не будем. Вместо этого напрямую пишем в файл.

local function getFile(url, path)
  local f, err = io.open(path, "w") -- ①

  if not f then
    return nil, ("could not open file for writing: %s"):format(err or "unknown error")
  end

  local handle, err = request(url, nil, nil, 10) -- ②

  if not handle then
    return nil, err
  end

  local read = 0

  local _, _, responseHeaders = handle.response()
  local length

  for k, v in pairs(responseHeaders) do
    if k:lower() == "content-length" then
      length = tonumber(v)
    end
  end

  while not length or read < length do
    local chunk, err = handle.read()

    if not chunk then
      if length then
        f:close() -- ③

        return nil, ("error occured while reading response: %s"):format(err or "unknown error")
      end

      break
    end

    read = read + #chunk

    if length and read > length then
      chunk = chunk:sub(1, length - read - 1)
    end

    f:write(chunk)
  end

  f:close() -- ④
  handle.close()

  return true
end
  1. ① — открываем файл, в этот раз для записи. Обрабатываем ошибки.
  2. ② — отправляем запрос без данных и с дефолтными хедерами. Обрабатываем ошибки.
  3. ③ — если мы сюда попали, то дальше сделаем ретурн. Поэтому не забываем закрывать за собой файл. (Сокет закрывать не нужно, так как при ошибке он это делает сам.)
  4. ④ — добропорядочно освобождаем ресурсы.

 

Чтобы было удобнее копипастить, я оставил повторяющийся код в двух функциях. В своей программке можно sendFIle и getFile отрефакторить, выделить дублирующуюся часть в отдельную функцию.

 

3.5. UI

Пришло время красивой каденции. Аккордом финальным в ней будет пользовательский интерфейс. Он к интернет-карте отношения уже не имеет, но для полноты приведу и его.

local args, opts = shell.parse(...)

local function printHelp()
  io.stderr:write([[
Usage: clbin { get [-f] <code> <path> | put <path> }
  clbin get [-f] <code> <path>
    Download a file from clbin to <path>.
    If the target file exists, -f overwrites it.
  clbin put <path>
    Upload a file to clbin.
]])
  os.exit(1)
end

if args[1] == "get" then
  if #args < 3 then
    printHelp()
  end

  local code = args[2]
  local path = args[3]
  local url = ("https://clbin.com/%s"):format(code)
  path = fs.concat(shell.getWorkingDirectory(), path)

  if not (opts.f or opts.force) and fs.exists(path) then
    io.stderr:write("file already exists, pass -f to overwrite\n")
    os.exit(2)
  end

  local status, err = getFile(url, path)

  if status then
    print("Success! The file is written to " .. path)
    os.exit(0)
  else
    io.stderr:write(err .. "\n")
    os.exit(3)
  end
elseif args[1] == "put" then
  if #args < 2 then
    printHelp()
  end

  local path = args[2]
  local url, err = sendFile(path)
  
  if url then
    url = url:gsub("[\r\n]", "")
    print("Success! The file is posted to " .. url)
    os.exit(0)
  else
    io.stderr:write(err .. "\n")
    os.exit(4)
  end
else
  printHelp()
end

 

3.6. Вуаля

Осталось добавить реквайры, и мы получим полноценный клиент clbin. Результат — на гисте.

 

4. Как правильно установить соединение через TCP-сокет

Прошлая секция была вроде интересной, поэтому здесь тоже запилим какую-нибудь программку. @Totoro вот сделал интернет-мост Stem. Напишем для него клиент. Правильно. Опять же, особенности:

  1. Работает через TCP-сокет.
  2. Протокол бинарный.
  3. И асинхронный.
  4. А ещё сессионный: у каждого TCP-соединения есть собственный стейт.
  5. Доки хранятся на вики.
  6. При разрыве соединения клиент должен переподключиться и восстановить стейт.

 

Здесь снова придётся использовать все фичи интернет-карты.

 

4.1. Архитектура

Мы разделим программу на 2 части — фронтенд и бэкенд. Фронт будет заниматься рисованием и приёмом данных от пользователя, и им займёмся в конце и без комментариев. Бэк — поддержанием соединения и коммуникации с сервером. Это куда больше имеет отношения к гайду, рассмотрим подробнее.

 

Бэкенд реализуем через ООП. Создадим конструктор, напихаем методов, которые затем будет дёргать фронт.

 

4.2. Конструктор

Привычно вбиваем ООП-шаблон в Lua.

local newClient do
  local meta = {
    __index = {},
  }

  function newClient(address, channels, connectionTimeout, readTimeout, maxReconnects)
    local obj = {
      __address = address,
      __channels = channels,
      __connectionTimeout = connectionTimeout,
      __readTimeout = readTimeout,
      __maxReconnects = maxReconnects;

      __socket = nil,
      __buffer = nil,
      __running = false,
      __reconnectCount = 0,
    }

    return setmetatable(obj, meta)
  end
end

Ну, тут всё мирно пока. Начнём боевые действия с протокола.

 

4.3. Протокол

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

local meta = {
  __index = {
    __opcodes = {
      message = 0,
      subscribe = 1,
      unsubscribe = 2,
      ping = 3,
      pong = 4,
    },

    __craftPacket = function(self, opcode, data)
      return (">s2"):pack(string.char(opcode) .. data)
    end,
    
    __parsePacket = function(self, packet)
      local opcode, data = (">I1"):unpack(packet), packet:sub(2)
      
      return self.__parsers[opcode](data)
    end,

    send = function(self, channel, message)
      return self:write(self:__craftPacket(self.__opcodes.message, (">s1"):pack(channel) .. message))
    end,
    
    subscribe = function(self, channel)
      return self:write(self:__craftPacket(self.__opcodes.subscribe, (">s1"):pack(channel)))
    end,
    
    unsubscribe = function(self, channel)
      return self:write(self:__craftPacket(self.__opcodes.unsubscribe, (">s1"):pack(channel)))
    end,
    
    ping = function(self, message)
      return self:write(self:__craftPacket(self.__opcodes.ping, message))
    end,
    
    pong = function(self, message)
      return self:write(self:__craftPacket(self.__opcodes.pong, message))
    end,
  },
}

meta.__index.__parsers = {
  [meta.__index.__opcodes.message] = function(data)
    local channel, idx = (">s1"):unpack(data)

    return {
      type = "message",
      channel = channel,
      message = data:sub(idx),
    }
  end,
  
  [meta.__index.__opcodes.subscribe] = function(data)
    return {
      type = "subscribe",
      channel = (">s1"):unpack(data),
    }
  end,
  
  [meta.__index.__opcodes.unsubscribe] = function(data)
    return {
      type = "unsubscribe",
      channel = (">s1"):unpack(data),
    }
  end,
  
  [meta.__index.__opcodes.ping] = function(data)
    return {
      type = "ping",
      message = data,
    }
  end,
  
  [meta.__index.__opcodes.pong] = function(data)
    return {
      type = "pong",
      message = data,
    }
  end,
}

В коде я активно использую string.pack и string.unpack. Эти функции доступны только на Lua 5.3 и выше, но позволяют очень удобно работать с бинарными форматами.

 

4.4. Подключение к серверу

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

local meta = {
  __index = {
    ...,

    connect = function(self)
      local socketStream = assert(inet.socket(self.__address)) -- ①
      local socket = socketStream.socket -- ②

      local start = comp.uptime() -- ③

      while true do
        local status, err = socket.finishConnect()

        if status then
          break
        end

        if status == nil then
          error(("connection failed: %s"):format(err or "unknown error")) -- ④
        end

        if comp.uptime() >= start + self.__connectionTimeout then
          socket.close()
          error("connection failed: timed out") -- ④
        end

        os.sleep(0.05)
      end

      self.__socket = socket -- ⑤

      self.__buffer = buffer.new("rwb", socketStream) -- ⑥
      self.__buffer:setTimeout(self.__readTimeout) -- ⑦
      self.__buffer:setvbuf("no", 512) -- ⑧

      for _, channel in ipairs(self.__channels) do -- ⑨
        self:subscribe(channel)
      end
    end,
  },
}
  1. ① — я использую обёртку над component.internet. Она потом будет нужна, чтобы мы могли поместить сокет в буфер. Обращаю внимание, что вызов обёрнут в assert. Работает она так: если первое значение не nil и не false, то возвращает его, а иначе кидает ошибку, используя второе значение в качестве сообщения. Проще говоря, она превращает nil, "error message" в исключение.
  2. ② — а пока я вытягиваю из обёртки сокет...
  3. ③ — чтобы можно было проверить, установлено ли соединение. Код здесь аналогичен тому, что мы делали в прошлой секции. Не выдумываем.
  4. ④ — одно различие: вместо return nil, "error message" я сразу прокидываю исключение. Прежде всего потому, что ошибки мы прокидывать должны единообразно. Раз в ① кидаем исключение, и здесь делаем то же.

    Почему исключение, а не return nil, "error message"? Мы вызывать connect будем из всяких мест. Так как в случае ошибок бэкенд беспомощен, то лучше прокинуть ошибку до фронтенда и не усложнять код бэка проверками на nil. Кроме того, это громкая ошибка: если забыть где-то её обработать, она запринтится на экран, случайно пропустить её или подменить какой-нибудь непонятной "attempt to index a nil value" не получится.

    В конце концов, мне так проще.
  5. ⑤ — сокет я сохраняю в поле. socket.finishConnect нам ещё понадобится.
  6. ⑥ — пришло время обернуть сокет в буфер. Может показаться излишним, особенно учитывая ⑧. Причины станут ясны, когда будем делать чтение.

    rw — это буфер для чтения и записи. b — бинарный режим: buffer:read(2) вернёт 2 байта, а не 2 символа. Так как символы кодируются в UTF-8 и занимают 1 (латиница), 2 (кириллица, диакритика), 3 (BMP: куча письменностей, всякие графические символы, большая часть китайско-японско-корейских иероглифов) или 4 байта (всё, что не влезло в BMP, например emoji), то отсутствие этого режима может дать ощутимую разницу. В нашем случае протокол бинарный — ставим b.
  7. ⑦ — устанавливаем таймаут для чтения. Объясню подробнее, когда будем это чтение делать.
  8. ⑧ — отключаем буфер для записи. Он нам не нужен.
  9. ⑨ — здесь же подключаемся ко всем каналам.

 

Итого мы получаем свойства __socket и __buffer. Сокет использовать будем, чтобы вызывать .finishConnect() и .id(). Буфер — для записи и чтения.

 

4.5. Запись

Теперь, разобравшись с сокетами и буферами, мы можем запросто писать в сокет. Пилим write:

local meta = {
  __index = {
    ...,

    write = function(self, data)
      return assert(self.__buffer:write(data))
    end,
  },
}

Здесь тоже оборачиваем write в assert, чтобы кидать исключения. Причины уже пояснял.

 

4.6. Чтение и обработка пакета

Сначала делаем функцию readOne. Она будет пытаться читать ровно один пакет. Здесь требуется нестандартная обработка ошибок, поэтому код сложноват.

local meta = {
  __index = {
    ...,

    readOne = function(self, callback) -- ⑥
      self.__buffer:setTimeout(0) -- ①
      local status, head, err = pcall(self.__buffer.read, self.__buffer, 2)
      self.__buffer:setTimeout(self.__readTimeout)

      if not status and head:match("timeout$") then
        return
      end

      assert(status, head) -- ②

      local length = (">I2"):unpack(assert(head, err)) -- ③
      local packet = self:__parsePacket(assert(self.__buffer:read(length))) -- ④

      if packet.type == "ping" then -- ⑤
        self:pong(packet.message)
      end

      callback(self, packet) -- ⑥

      return true
    end,
  }
}
  1. ① — рассмотрим эту мишуру по порядку:
    • Любой пакет stem начинается с 2 байт, которыми кодируется длина остатка. Отсюда всплывает двойка.
    • Автор buffer, к сожалению, не осилил реализовать адекватную обработку ошибок. Он использует и исключения, и тихие ошибки (nil, "error message").
    • В случае таймаута будет прокинуто исключение. Однако мы перед чтением поставили таймаут в 0. Если буфер не найдёт сразу 2 байта в сокете, то он сразу кинет ошибку.
    • Мы хотим проверить, есть ли в сокете пакет, который бы можно было прочесть. Используем pcall. Сначала раскроем self.__buffer:read(2) как self.__buffer.read(self.__buffer, 2), а затем поместим функцию и её аргументы в pcall.
    • pcall возвращать будет сразу 3 значения по следующему принципу:
      1. Если на сокете есть 2 непрочитанных байта, read вернёт их без ошибок. Тогда status будет равен true, в head сохранятся эти 2 байта, а в err запишется nil.
      2. Если на сокете этих байтов нет, то read прокинет исключение "timeout". status установится в false, head приравняется "/lib/buffer.lua:74: timeout", а err также будет nil.
      3. Если же при чтении с сокета возникла другая ошибка, то read вернёт её по-тихому: status будет true, head — nil, а сообщение об ошибке уйдёт в err.
      4. Не думаю, что этот случай возможен, однако read может кинуть исключение и не из-за таймаута. status установится в false, а ошибка сохранится в head.
    • В if мы проверяем, был ли таймаут (ситуация 1.2). В таком случае мы не кидаем исключения, а тихо выходим.
    • Наконец, не забываем вернуть прежнее значение таймаута.
  2. ② — обрабатываем случай 1.4.
  3. ③ — обрабатываем случай 1.3 с помощью assert. Последний оставшийся и единственный успешный случай (1.1) также покрывается: распаковываем 2 байта в целое беззнаковое число (uint16_t).
  4. ④ — в ③ мы получили длину оставшегося пакета. Очевидно, надо остаток дочитать, что и делаем. Здесь уже не надо отдельно обрабатывать таймаут, достаточно assert. Считанный пакет отдаём в __parsePacket.
  5. ⑤ — если сервер докопался до нас своим пингом, отправим ему понгу.
  6. ⑥ — функция readOne принимает коллбэк. Это функция, которая будет обрабатывать все пакеты. Коллбэк будет передавать фронтенд, а бэкенд займётся минимальной обработкой, чтобы в принципе работало. Как, например, ③.

 

Отлично. Мы приготовили все примитивы, которые были нужны. Осталось собрать их воедино — в event loop.

 

4.7. Event loop и события

Ивент луп — это цикл, который ждёт событий и что-то с ними делает. Пришло время разобраться, что за события есть в OC.

 

Когда мы вызываем socket.read или socket.finishConnect, устанавливается "ловушка" (селектор). Она срабатывает, когда на сокет пришли новые байты. При этом компьютер получает событие internet_ready. После чего "ловушка" деактивируется до следующего вызова.

 

internet_ready, таким образом, — это событие, извещающее нас о том, что на сокете валяются непрочитанные данные и пора вызвать socket.read, чтобы их собрать. У события два параметра. Первый — это адрес интернет-карты. Второй — id сокета. Тот id, который возвращает socket.id(). Поэтому мы сохранили сокет в поле __socket: сейчас будем использовать его.

local meta = {
  __index = {
    ...,

    __run = function(self, callback)
      while self.__running do
        local e, _, id = event.pullMultiple(self.__readTimeout, "internet_ready", "stem%-client::stop") -- ①

        if e == "internet_ready" and id == self.__socket.id() then -- ②
          while self:readOne(callback) do
            self.__reconnectCount = 0 -- ③
          end
        elseif e ~= "stem-client::stop" then
          self:ensureConnected() -- ④
        end
      end
    end,

    stop = function(self)
      self.__running = false
      event.push("stem-client::stop") -- ⑤
    end,
  }
}
  1. ① — ждём события internet_ready или stem-client::stop. Так как в event.pullMultiple названия ивентов сверяются через string.match, дефис экранируем. Второй ивент нужен, чтобы принудительно прервать цикл из stop.
  2. ② — обрабатываем мы только internet_ready и только для нашего сокета. Проверяем.
  3. ③ — если поймался пакет или пакеты, то пытаемся обработать каждый в порядке прибытия. Когда мы закончили обрабатывать все пакеты, self:readOne вернёт nil, и цикл прервётся. Кстати говоря, если мы внутри цикла оказались, то соединение установилось. Не забываем отметить это.
  4. ④ — если же улов пуст, перепроверяем, подключены ли мы вообще.
  5. ⑤ — не забываем добавить метод, чтобы остановить наш цикл. Отсюда же отсылаем событие stem-client::stop.

 

Отлично. Теперь пришло время ловить все наши прокидываемые исключения.

 

4.8. Обработка ошибок

Последними 2 функциями, которые мы добавим, будут ensureConnected и run. С их помощью бэкенд будет автоматически переподключаться к серверу в случае проблем.

local meta = {
  __index = {
    ...,

    ensureConnected = function(self)
      local status, err = self.__socket.finishConnect() -- ①
      
      if status == false then
        error("not yet connected")
      end
      
      return assert(status, err or "unknown error")
    end,

    run = function(self, callback)
      if self.__running then -- ②
        return
      end

      self:connect() -- ③
      self.__running = true

      while self.__running do -- ④
        local status, err = pcall(self.__run, self, callback) -- ⑤

        if not status then
          if self.__reconnectCount == self.__maxReconnects then -- ⑥
            return nil, ("connection lost: %s; reconnect limit is reached"):format(err or "unknown error")
          end

          self.__reconnectCount = self.__reconnectCount + 1

          self.__buffer:close() -- ⑦

          if not pcall(self.connect, self) then -- ⑧
            if self.__socket then
              self.__socket:close()
            end

            if self.__buffer then
              self.__buffer:close()
            end

            os.sleep(1)
          end
        end
      end
      
      self.__buffer:close()
    end,
  },
}
  1. ① — ensureConnected просто прокинет ошибку, которую вернёт finishConnect().
  2. ② — принимаем защитную позицию против дураков. Рекурсивно запускать циклы смысла нет.
  3. ③ — сначала подключаемся к серверу. Если всё отлично, то можно начинать.
  4. ④ — как и в __run, здесь мы оборачиваем код в цикл. Если вызван stop(), то сначала остановится self.__run, а затем и этот цикл.
  5. ⑤ — обработка исключений требует pcall. Потому что их надо словить.
  6. ⑥ — если мы старались-старались, но так и не смогли уложиться в self.__maxReconnects по реконнектам, кидаемся белым флагом.
  7. ⑦ — не забудем закрыть буфер.
  8. ⑧ — вспомним, что self.connect кидает исключение. Перехватываем. На всякий случае позакрываем то, что породил connect.

 

4.9. Фронтенд

На этом наш бэкенд готов. Поздравляю. Остаётся лишь прицепить ввод-вывод. Опять же, даю готовый код без комментариев, ибо не об этом пост.

local gpu = com.gpu
local w, h = gpu.getResolution()

local function writeLine(color, line)
  local oldFg

  if gpu.getForeground() ~= color then
    oldFg = gpu.setForeground(color)
  end

  local lines = 0

  for line in text.wrappedLines(line, w + 1, w + 1) do
    lines = lines + 1
  end

  gpu.copy(1, 1, w, h - 1, 0, -lines)

  local i = 0

  for line in text.wrappedLines(line, w + 1, w + 1) do
    gpu.set(1, h - lines + i, (" "):rep(w))
    gpu.set(1, h - lines + i, line)
    i = i + 1
  end

  if oldFg then
    gpu.setForeground(oldFg)
  end
end

local channel = ...

if not channel then
  io.stderr:write("Usage: stem <channel>\n")
  os.exit(1)
end

if #channel == 0 or #channel >= 256 then
  io.stderr:write("Invalid channel name\n")
  os.exit(2)
end

local client = newClient(
  "stem.fomalhaut.me:5733",
  {channel},
  10,
  10,
  5
)

require("thread").create(function()
  while true do
    term.setCursor(1, h)
    io.write("← ")
    local line = io.read()

    if not line then
      break
    end

    local status, err = pcall(client.send, client, channel, line)

    if not status then
      writeLine(0xff0000, ("Got error while sending: %s"):format(err or "unknown error"))
      break
    end
  end

  client:stop()
end)

client:run(function(client, evt)
  if evt.type == "message" then
    writeLine(0x66ff00, "→ " .. evt.message)
  elseif evt.type == "ping" or evt.type == "pong" then
    writeLine(0xa5a5a5, "Ping: " .. evt.message:gsub(".", function(c)
      return ("%02x"):format(c:byte())
    end))
  end
end)

os.exit(0)

Здесь я упускаю одну вещь: обработку ошибок в client.send. Если мы попытаемся отправить сообщение, когда у нас потеряно соединение (или до того, как оно установлено), мы или словим ошибку, или потеряем сообщение. Починить это можно, добавив очередь отправляемых пакетов, но это в разы усложнит программу, поэтому оставим так.

 

4.10. Готово!

Добавим реквайров... И у нас получился вполне рабочий клиент для Stem!

ceoMukQ.pngtkFqMPV.png

Код программы — на гисте.

 

5. В чём различие между component.internet и require("internet")

Первое — исходный компонент. Второе — обёртка над ним. У обёртки есть 3 функции:

  • internet.request(url, data, headers, method) — обёртка над component.internet.request. Удобна тем, что все ошибки превращает в исключения за программиста. Кроме того, возвращаемое значение — итератор, и его можно поместить в цикл for. Тем не менее, код, который ждёт установки соединения, нужно писать самому.
  • internet.socket(address, port) — промежуточная обёртка над component.internet.connect. Она используется для того, чтобы потом превратить её в буфер, как сделали мы. Сама по себе достаточно бесполезна.
  • internet.open(address, port) — тоже обёртка над component.internet.connect. Она вызывает internet.socket(address, port) и сразу превращает результат в буфер. Проблема в том, что сам объект сокета использовать можно только через приватные свойства, которые могут ломаться между обновлениями OpenOS. Из-за этого функция исключительно ущербна.

 

Для отправки HTTP-запросов я предпочитаю использовать API компонента. TCP-сокеты же проще создавать через обёртку (internet.socket), вручную проверять подключение и так же вручную укладывать обёртку в буфер, как показано выше.

 

6. Конец

Самое сложное в использовании интернет-карты — это правильно обработать все ошибки. Они могут возникнуть на каждом шагу, при этом быть полноценными исключениями или тихими ошибками. Необработанные исключения крашат программу, из-за чего возникает желание весь код программы поместить в один большой pcall. Например, IRC-клиент, который на дискете поставляется, делает так. Тихие ошибки гораздо подлее. Необработанные, они тоже крашат программу, только вот сама ошибка теряется, подменяется другой (обычно "attempt to index a nil value").

 

В Lua обработать все ошибки — задача сложная, потому что механизм ошибок ужасен. В нормальных языках стэктрейс отделён от сообщения об ошибке, плюс каждая ошибка имеет свой тип, по которому можно безопасно определять вид ошибки. Lua этим не заморачивается: сообщение об ошибке включает позицию в коде, откуда ошибка прокинута. Есть или нет стэктрейс, зависит от выбора между pcall и xpcall. Если они находятся где-то в другой библиотеке, программист на выбор повлиять не может. В коде Stem-клиента единственный способ узнать, от таймаута ли ошибка прокинута, — матчить последние 7 символов на слово "timeout". Это эталонный костыль. Даже в JavaScript механизм лучше.

 

Поэтому этот пост получился не столько про интернет-карту, сколько про обработку ошибок.

  • Нравится 15


21 комментарий


Рекомендуемые комментарии

28 минут назад, Totoro сказал:

Лайк за клиент для Stem'а.

Звучит, будто кроме стема остальное лайка не заслуживает. :(

Поделиться комментарием


Ссылка на комментарий
7 часов назад, Fingercomp сказал:

Звучит, будто кроме стема остальное лайка не заслуживает. :(

Ну остальное тоже ничего так. :P

Поделиться комментарием


Ссылка на комментарий

Да, проверка "content-length" - обязательная штука с плохим интернетом. Я везде ее пихаю, иначе как в опеноси, приходится запускать скачивание несколько раз, а если загрузка происходит прямо из биоса, тогда может произойти котострофа.

Поделиться комментарием


Ссылка на комментарий

Гайд по самой плате более чем достойный, однако начиная с пункта 4 он будет полностью понятен лишь человеку, уже знакомому с луа/опенкомпами и работавшему с платой. Новичков отпугнёт. Поясню претензию:

 

Цитата

Привычно вбиваем ООП-шаблон в Lua.

Во-первых, какое, блин, ООП? В чтении/записи в сокет функционала кот наплакал, и реализовать его процедурным подходом можно в разы проще и доступнее для понимания. Ты гайд пишешь или состязаешься с читателем? Со стороны это выглядит так: а давайте-ка, детишки, напишем объектную систему для мульти-соединений с сервером по всем канонам. А потом... будем использовать лишь ОДНО во фронтенде! *ba dum tss*

 

Во-вторых, "привычно" для кого, для Fingercomp'а? В луа не имеется общепринятых конвенций по реализации объектов. Метатаблицы и приватные члены - это, безусловно, интересный подход, однако тут я бы задал себе несколько вопросов в порядке убывания приоритетности:

  1. Будет ли он быстрее работать и расходовать меньше ресурсов?
  2. Нужен ли он для клиента, использующего только 1 соединение с сервером?
  3. Будет ли он проще в реализации?
  4. Будет ли он понятнее в виде гайда для людей, впервые работающих с интернет-платой и луа в целом?

Имхо, ответом на каждый из них будет "нет". Вытекающий вопрос "зачем, господи?" висит мёртвым грузом...

Поделиться комментарием


Ссылка на комментарий
55 минут назад, ECS сказал:

Новичков отпугнёт.

Критика валидная. Да, это так, не отрицаю. Четвёртый пункт не для новичков. Если бы я писал для новичков, сначала бы пришлось описывать, что такое сокет, что значат 3 буквы TCP, чем request не устраивает. Эта часть предназначена целиком для тех, кто уже пользовался сокетами (не обязательно на Lua).

 

В целом, вся запись подразумевает, что читатель уже знает Lua и использовал интернет-плату прежде. Наверное, надо было об этом написать явно.

 

1 час назад, ECS сказал:

Во-первых, какое, блин, ООП?

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

 

Получилось это так. Изначально я писал библиотеку, а не приложение. Написав 80% текста, я затем передумал и решил превратить библиотеку в чат-клиент. Но переписывать весь код было жалко, поэтому выбросил лишь отдельные части. Если бы начинал сразу приложением оформлять, разумеется, ООП был бы бесполезным, так как настройки бы все я запёк в константах в начале программы. Но мне было лень. Добавил для этого пункт 4.1, переписал 4.2, вставил в остальные пункты контекст и решил, что и так сойдёт.

 

Не знаю, стоит ли сейчас переписывать без объектов. В коде только self исчезнет.

 

1 час назад, ECS сказал:

Во-вторых, "привычно" для кого, для Fingercomp'а? В луа не имеется общепринятых конвенций по реализации объектов.

Гм. Я до этого комментария считал наоборот. Где бы ни рассказывали здесь про то, как делать ООП, шаблон один и тот же: функция-конструктор new, метатаблица в некотором виде (где-то с рекурсивной ссылкой в __index, где-то таблица методов и {__index = methods} в конструкторе). do ... end — моя отсебятина, но выполняет она исключительно декоративную функцию.

 

А какие ещё способы используются? На ум приходят только замыкания, но это уже какой-то костыль экзотический.

 

P. S. Вообще, приятно, что кто-то потратил время, чтобы дельно покритиковать. :3

Поделиться комментарием


Ссылка на комментарий
21 минуту назад, Fingercomp сказал:

Изначально я писал библиотеку, а не приложение. Написав 80% текста, я затем передумал и решил превратить библиотеку в чат-клиент

А, понятно, это имеет смысл. Переписывать никто не требует, олдфаги всё поймут, а нубасы пусть курят основы хв

 

28 минут назад, Fingercomp сказал:

Где бы ни рассказывали здесь про то, как делать ООП, шаблон один и тот же: функция-конструктор new,

Ну почему же? Возьмем элементарный код:

local function meow(self)
  print(self.abc)
end

local function newObject()
  return {
    abc = 123,
    meow = meow
  }
end

local function meowOverriden(self)
  if self.condition then
    meow(self)
  else
    print("Not condition")
  end
end

local function newObjectExtended()
  local self = newObject()
  
  self.condition = true
  self.meow = meowOverriden
  
  return self
end

Объекты? Есть. Наследование? Есть. Переопределение методов? Есть. Метатаблицы? Да на фиг не нужны. Этот код - такая же отсебятина и дело уже моей привычки. Я хотел обратить внимание не на метод реализации ООП (ибо все пишут как привыкли), а на его избыточную сложность для гайда: если уж делаешь библиотеку, использующую "железный занавес" для приватных членов через метатаблицы, то, как мне кажется, они должны быть полностью приватны для отдельно взятого класса. Чтобы, к примеру, нельзя было творить вот такую грязь:

local client = stem.newClient(...)

client.__opcodes[0] = "hehe"
client.__craftPacket(...)

Это же в корне ломает концепцию приватности, позволяя потрошить либу как душе угодно. Пофиг ли на это? Конечно пофиг. Но зачем тогда усложнять изящную либу и делать приватные члены? А если усложняешь, то почему не используешь полное экранирование с внутренним использованием rawget?

Поделиться комментарием


Ссылка на комментарий
34 минуты назад, ECS сказал:

Ну почему же? Возьмем элементарный код: ...

И ведь действительно. Самое простое упустил. ¯\_(ツ)_/¯

 

37 минут назад, ECS сказал:

Но зачем тогда усложнять изящную либу и делать приватные члены?

Не, do .. end — это исключительно для структурирования кода конструкция. А setmetatable заменяет цикл. Приватными вещи в луа делать смысла большого не вижу, если только не требуется это задачей (например, публичный луа-терминал делать). Достаточно пометить внутренние функции через __.

Поделиться комментарием


Ссылка на комментарий
32 минуты назад, Fingercomp сказал:

Не, do .. end — это исключительно для структурирования кода конструкция

Я не про do ... end, а про __. Дело привычки, видимо. Крайне сложно принять факт наличия публичных полей, которые "эстетически" помечены приватными в ООП-системе. Я бы их тогда в сам объект вовсе не вносил, оставляя реализацию в виде локальных функций в начале скрипта. Впрочем, либа твоя, хозяин-барин, мяу

Поделиться комментарием


Ссылка на комментарий
12 минуты назад, ECS сказал:

Я не про do ... end, а про __. Дело привычки, видимо.

А-а-а. Вспомнил Python полгода назад, а там приватные поля как раз через __ оформляются. Теперь вот и в Lua так делаю. С одной стороны, если юзер смотрит, какие поля есть (в интерпретаторе Lua в OpenOS есть автодополнение по полям таблицы), он сразу видит, что это что-то внутреннее, что может сломаться при обновлении либы, даже не смотря в доки. С другой стороны, если ему надо что-то закостылять, чего автор либы не предусмотрел, есть где развернуться.

Поделиться комментарием


Ссылка на комментарий
10 минут назад, Fingercomp сказал:

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

Эх, вот бы сюда сочный protected для подклассов... И костыляешь что хочешь, и либа целёхонькая

Поделиться комментарием


Ссылка на комментарий

Не понял:blink:, как мне использовать request из примера, просто по ссылке мне нужно получать JSON ответ
я добавив ваш код, выполняю

  local stat = request("https://levshx.000webhostapp.com/loader.php?option=stat", nil,nil,5)
  if stat then
  ...

но stat = nil

и каким образом подбирать timeout 

пожалуйста, можно получить пример использавання ?

Изменено пользователем levshx

Поделиться комментарием


Ссылка на комментарий
3 часа назад, levshx сказал:

Непоняв, так как мне использовац этот ваш request, просто по ссылке мне нид получать JSON ответ
я добавив ваш код, выполняю

  local stat = request("https://levshx.000webhostapp.com/loader.php?option=stat", nil,nil,5)
  if stat then
  ...

но stat = nil шо за приколы

Этот пост, я ещё раз повторюсь, не столько про интернет-карточку, сколько про обработку ошибок. Даже assert поставить — это уже обработка ошибок.

local handle = assert(request(url, nil, nil, 5))

В коде из цитаты обработки ошибок нет. Ошибка если и есть, то теряется. Такой код проблему не решает и ещё порождает новые. Поэтому код неправильный.

 

3 часа назад, levshx сказал:

и скока надо пихать в timeout непанятна

Меня огорчает, что код скопирован, а времени то, чтобы понять, как он работает, не потрачено совершенно. :(

Внутри функции request находится цикл. Этот цикл ждёт соединения с сервером. timeout ограничивает время пребывания в этом цикле. Сколько ставить — это не мне решать, поэтому именно он и параметром к функции выведен. Зависит от программы.

 

3 часа назад, levshx сказал:

очень сложна, так то всё работало с простым request просто со временем крашилась программка

Сетевые приложения никогда не были простыми. Исключительных случаев огромное множество, и за всеми надо уследить. В посте я постарался объяснить, зачем я использую свою обёртку и что именно она делает.

 

3 часа назад, levshx сказал:

пажаласта можно пример использавання ?

Я не могу дать полностью готовый кусок кода и сказать, что это канон. Самое универсальное — это функция request. Как использовать, зависит от приложения.

 

Здесь я нарисовал с assert пример — при любой ошибке программа будет крашиться. Удобно при разработке.

 

Если есть консольный интерфейс, надо ошибку обрабатывать явно. Например:

local handle, err

for i = 0, math.huge, 1 do
  handle, err = request(url, nil, nil, 5)
  
  if handle then
    break
  end

  local delay = math.min(180, i ^ 3)
  io.stderr:write([[
We've had a problem fetching a webpage: %s.
Retrying in %d seconds...]]):format(err, delay))
  os.sleep(delay)
end

В посте как раз консольная программка, к слову.

 

Если интерфейс графический, ошибку писать надо куда-то ещё. Если программка автономная, то ошибку куда-то в лог класть.

Поделиться комментарием


Ссылка на комментарий
3 часа назад, ov3rwrite сказал:

Не читал комменты выше но вообще-то есть обертка для всего этого.Почитайте https://ocdoc.cil.li/api:internet.Полезные там только 2 функции и это internet.open() и internet.request().

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

 

internet.request не ждёт соединения с сервером. Хотя она прокидывает ошибки через error, этого недостаточно. См. 3.2–3.4 про то, как правильно послать запрос и получить ответ. А с таким кодом смысла в использовании обёртки ноль.

 

internet.open кладёт оригинальный сокет в приватные свойства. Заставляет программиста постоянно и без причины дёргать :read() и не даёт воспользоваться сигналом internet_ready. Поэтому не только бесполезен, но и вреден. Нужно вызывать internet.socket, чтобы можно было получить id сокета и вызвать finishConnect, и вручную класть стрим в буфер. См. 4.5–4.8 про то, как правильно создать и использовать сокет.

 

Таким образом, действительно полезна только одна функция — internet.socket. И очень зря она не удостоилась такой характеризации цитируемым комментарием.

Поделиться комментарием


Ссылка на комментарий

Я так понимаю, что TLS присутствует только для запросов (https://)?

 

Если да, то как можно передавать поток, чтобы он оставался защищенным без использования Data Card?

Поделиться комментарием


Ссылка на комментарий
29 минут назад, Mihis сказал:

Я так понимаю, что TLS присутствует только для запросов (https://)?

 

Если да, то как можно передавать поток, чтобы он оставался защищенным без использования Data Card?

В OC встроен TLS только для HTTPS-запросов. Если надо кастомный TCP-сокет, обёрнутый в TLS, то надо самому имплементить TLS-протокол. Без дата-карты 3 уровня будет сложно: придётся делать криптографические примитивы на Lua.

Поделиться комментарием


Ссылка на комментарий
Цитата

Неудивительно, что правильно использовать его умеют немногие.

Уменьшаем счётчик на единицу. :D

 

Претензия к пункту 3.3. Программа читает столько данных, сколько может - если сервер укажет маленький Content-Length и отправит гигабайт данных, программа всё это будет читать.

Поделиться комментарием


Ссылка на комментарий
20 минут назад, ProgramCrafter сказал:

Претензия к пункту 3.3. Программа читает столько данных, сколько может - если сервер укажет маленький Content-Length и отправит гигабайт данных, программа всё это будет читать.

Потрясающее замечание. Отвечу развёрнуто с удовольствием.

 

Если посмотреть на код, то можно заметить, что я эту проблему опознал и в случае несоответствия в меньшую сторону я решил хедер игнорировать (собственно, поэтому в сравнении там оператор >=). К сожалению, когда код я стал комментировать, про это совершенно забыл — и без этого коммента бы, наверное, и не вспомнил.

 

Претензия имеет силу, если мы обратимся к стандарту. Юзер-агент должен в случае получения невалидного хедера Content-Length просигналить об ошибке и выбросить полученные данные:

Цитата

   4.  If a message is received without Transfer-Encoding and with
       either multiple Content-Length header fields having differing
       field-values or a single Content-Length header field having an
       invalid value, then the message framing is invalid and the
       recipient MUST treat it as an unrecoverable error.  If this is a
       request message, the server MUST respond with a 400 (Bad Request)
       status code and then close the connection.  If this is a response
       message received by a proxy, the proxy MUST close the connection
       to the server, discard the received response, and send a 502 (Bad
       Gateway) response to the client.  If this is a response message
       received by a user agent, the user agent MUST close the
       connection to the server and discard the received response.

— RFC7230: “Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing”, §3.3.3, стр. 32–33

 

Более того, когда писал код, специально стандарт вычитывал именно поэтому. Почему же тогда в статье я решил игнорировать его предписания, притом намеренно? Для простоты. Чтобы не вдаваться в детали HTTP, не рассказывать про Transfer-Encoding и не требовать реализации алгоритма из цитированного стандарта.

 

Чтобы быть предельно педантным, нужно сначала проверить, не стоит ли Transfer-Encoding (потому что иначе длина неизвестна), потом убедиться, что все Content-Length имеют одно и то же значение (это тоже в статье я не освещал, но требуется стандартом), а затем читать данное число байт: если фактически пришло меньше или больше, ответ отбросить и вернуть ошибку.

 

Или же подойти с прагматической стороны, учесть, что по соединению из OC мы можем отправить ровно один HTTP-запрос и забить на эту сложность. Возможно, стоит проверить, что Content-Length все имеют согласованное значение и являются интами, чтобы быть уверенным в ожидаемой длине. Самое главное, что требуется от того кода: убедиться, что соединение не порвалось посередине ответа.

 

Поэтому, хотя всех тонкостей HTTP я действительно не знаю, я бы не спешил декрементить счётчик. :P

Поделиться комментарием


Ссылка на комментарий

Пролистал RFC7230. Как ни странно, не нашёл там указаний на то, что после отправки сообщения сервер не может по тому же соединению отправлять ещё бессмысленный мусор. А если Content-Length меньше длины полученных данных, то не указано, корректный это заголовок или нет.

 

А вообще, получается так: если сервер, например, решит "а я не буду закрывать соединение после отправки сообщения", то программа будет висеть, пока это соединение не разорвётся чем-нибудь посередине. Плюс сервер (доверять которому обычно не надо бы) может заставить программу на Lua использовать произвольное количество памяти. Не совсем best practices, по-моему :)

Поделиться комментарием


Ссылка на комментарий
7 минут назад, ProgramCrafter сказал:

Пролистал RFC7230. Как ни странно, не нашёл там указаний на то, что после отправки сообщения сервер не может по тому же соединению отправлять ещё бессмысленный мусор. А если Content-Length меньше длины полученных данных, то не указано, корректный это заголовок или нет.

Хм. Действительно, в RFC7230 валидный Content-Length — любое целочисленное неотрицательное значение. Попытался найти, почему я решил, что нельзя слать больше, чем в Content-Length, и обнаружил следующее в устаревшей версии стандарта:

Цитата

When a Content-Length is given in a message where a message-body is allowed, its field value MUST exactly match the number of OCTETs in the message-body. HTTP/1.1 user agents MUST notify the user when an invalid length is received and detected.

— RFC2616: “Hypertext Transfer Protocol -- HTTP/1.1”, §4.4, стр. 34

 

Что, в принципе, объясняет мою уверенность. В таком случае, действительно, стоит ориентироваться на Content-Length. Статью исправлю соответствующе. Спасибо за замечания.

Поделиться комментарием


Ссылка на комментарий
Гость
Добавить комментарий...

×   Вы вставили отформатированное содержимое.   Удалить форматирование

  Разрешено использовать не более 75 эмодзи.

×   Ваша ссылка была автоматически встроена.   Отобразить как ссылку

×   Ваш предыдущий контент был восстановлен.   Очистить редактор

×   Вы не можете вставлять изображения напрямую. Загружайте или вставляйте изображения по ссылке.

×
×
  • Создать...