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

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

Fingercomp

835 просмотров

Среди всех компонентов в 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

  while true do
    local chunk, err = handle.read()

    if not chunk then -- ⑥
      local _, _, responseHeaders = handle.response() -- ⑦
      local length
      
      for k, v in pairs(responseHeaders) do -- ⑧
        if k:lower() == "content-length" then
          length = tonumber(v)
        end
      end

      if not length or read >= length then -- ⑨
        break
      end

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

    read = read + #chunk -- ⑪
    table.insert(url, chunk)
  end
  
  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.read может ещё вернуть ошибку. В том числе если мы прочли весь ответ, и сервер закрыл соединение. Поэтому обработка ошибок будет немного сложной.
  7. ⑦ — мы хотим сверить число прочитанных байт с размером ответа. Для этого нам потребуется получить хедеры, отправленными сервером. Вызываем handle.response.
  8. ⑧ — размер ответа обычно пишется в заголовок Content-Length. Однако сервер может поиграться с регистром. Например, писать content-length или CONTENT-LENGTH. OpenComputers не трогает эти хедеры. Поэтому придётся пройтись по всем ключам таблицы и найти хедер без учёта регистра.
  9. ⑨ — если length не nil, то это число. Тогда проверяем, что столько байт мы прочли. Если же Content-Length не задан, то будем считать, что серверу не важно, сколько надо прочесть. В любом случае выходим из цикла и завершаем чтение.
  10. ⑩ — если мы прочли меньше, чем требуется, то явно ошибка какая-то. Обрабатываем.
  11. ⑪ — не забываем обновлять read.
  12. ⑫ — наконец, склеиваем таблицу в одну строку.

 

Из цикла можно выйти только в случае ошибки. А при ошибке соединение уже закрыто. Следовательно, самим вызывать handle.close() не нужно.

 

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

  while true do
    local chunk, err = handle.read()

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

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

      if not length or read >= length then
        break
      end

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

    read = read + #chunk
    f:write(chunk)
  end
  
  return true
end
  1. ① — открываем файл, в этот раз для записи. Обрабатываем ошибки.
  2. ② — отправляем запрос без данных и с дефолтными хедерами. Обрабатываем ошибки.
  3. ③ — если мы сюда попали, то дальше каким-либо образом (ретурном или брейком) выпрыгнем из цикла. Поэтому не забываем закрывать за собой файл.

 

Чтобы было удобнее копипастить, я оставил повторяющийся код в двух функциях. В своей программке можно 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 механизм лучше.

 

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

  • Нравится 12


17 комментариев


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

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 для подклассов... И костыляешь что хочешь, и либа целёхонькая

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


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

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

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

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

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

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

Изменено пользователем 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.

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


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

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

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

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

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

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

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