Pull to refresh

Пишем симулятор медленных соединений на Go

Reading time 6 min
Views 16K
В этой статье я хочу показать, как просто в Go можно делать достаточно сложные вещи, и какую мощь в себе несут интерфейсы. Речь пойдет о симуляции медленного соединения — но, в отличие от популярных решений в виде правил для iptables, мы реализуем это на стороне кода — так, чтобы можно было легко использовать, к примеру, в тестах.

Ничего сложного тут не будет, и ради большей наглядности я записал ascii-анимации (с помощью сервиса asciinema), но, надеюсь, будет познавательно.



Интерфейсы


Интерфейсы — это специальный тип в системе типов Go, позволяющий описывать поведение объекта. Любой статический тип, для которого определены методы (поведение) неявно реализует интерфейс, который описывает эти методы. Самый известный пример — интерфейс из стандартной библиотеки io.Reader:
// Reader is the interface that wraps the basic Read method.
// ...
type Reader interface {
    Read(p []byte) (n int, err error)
}

Любая структура, для которой вы определите метод Read([]byte) (int, error) — может использоваться как io.Reader.

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

Вывод в консоль


Итак, начнем с простейшего применения Reader-а — выведем строчку в stdout. Конечно, для этой задачи лучше использовать функции из пакета fmt, но мы же хотим продемонстрировать работу Reader-а. Поэтому создадим переменную типа strings.Reader (которая реализует io.Reader) и, с помощью функции io.Copy() — которая, как раз тоже работает с io.Reader, скопируем это в os.Stdout (которая, в свою очередь, имплементирует io.Writer).
package main

import (
    "io"
    "os"
    "strings"
)

func main() {
    r := strings.NewReader("Not very long line...")
    io.Copy(os.Stdout, r)
}


А теперь, используя композицию (composition), создадим свой тип SlowReader, который будет читать из оригинального Reader-а по одному символу с задержкой, скажем, в 100 миллисекунд — таким образом, обеспечивая скорость 10 байт в секунду.
// SlowReader reads 10 chars per second                                    
type SlowReader struct {                                                   
    r io.Reader                                                            
}                                                                          

func (sr SlowReader) Read(p []byte) (int, error) {                         
    time.Sleep(100 * time.Millisecond)                                     
    return sr.r.Read(p[:1])                                                
}      

Что такое p[:1], надеюсь, объяснять не нужно — просто новый slice, состоящий из 1 первого символа от оргинального slice-а.

Всё что нам остается — это использовать наш strings.Reader в качестве оригинального io.Reader-а, и передать в io.Copy() медленный SlowReader! Посмотрите, как просто и круто одновременно.
(ascii-каст открывается в новом окне, js-скрипты на хабре запрещено встраивать)


Вы уже должны начать подозревать, что этот простой SlowReader можно использовать не только для вывода на экран. Также можно добавить параметр вроде delay. А еще лучше — вынести SlowReader в отдельный package, чтобы было легко использовать в дальнейших примерах. Немного причешем код.

Причёсываем код


Создадим директорию test/habr/slow и перенесем код туда:
package slow

import (
	"io"
	"time"
)

type SlowReader struct {
	delay time.Duration
	r     io.Reader
}

func (sr SlowReader) Read(p []byte) (int, error) {
	time.Sleep(sr.delay)
	return sr.r.Read(p[:1])
}

func NewReader(r io.Reader, bps int) io.Reader {
	delay := time.Second / time.Duration(bps)
	return SlowReader{
		r:     r,
		delay: delay,
	}
}

Или, кому интересно смотреть ascii-касты, вот так — выносим в отдельный package:


И добавляем параметр delay типа time.Duration:


(Правильнее было бы, после выноса кода в отдельный пакет, назвать тип Reader — чтобы было slow.Reader, а не slow.SlowReader, но скринкаст уже записан так).

Чтение из файла


А теперь, практически без усилий, проверим наш SlowReader для медленного чтения из файлов. Получив переменную типа *os.File, которая хранит в себе дескриптор открытого файла, но при этом реализует интерфейс io.Reader — мы можем работать с файлом точно также, как и ранее со strings.Reader.
package main

import (
        "io"
        "os"
        "test/habr/slow"
)

func main() {
        file, err := os.Open("test.txt")
        if err != nil {
                panic(err)
        }
        defer file.Close() // close file on exit

        r := slow.NewReader(file, 5) // 5 bps

        io.Copy(os.Stdout, r)
}

Или так:


Декодируем JSON


Но с чтением из файла — это слишком просто. Давайте рассмотрим пример чуть интереснее — JSON-декодер из стандартной библиотеки. Хотя для удобства пакет encoding/json предоставляет функцию json.Unmarshal(), он также позволяет работать с io.Reader с помощью json.Decoder — с ним можно десериализовать потоковые данные в json-формате.

Мы возьмем простую json-encoded строку и будем её «медленно читать» с помощью нашего SlowReader-а — а json.Decoder выдаст готовый объект только после того, как дойдут все байты. Чтобы это было очевидно, мы добавим в функцию slow.SlowReader.Read() вывод на экран каждого прочитанного символа:
package main

import (
        "encoding/json"
        "fmt"
        "strings"
        "test/habr/slow"
)

func main() {
        sr := strings.NewReader(`{"value": "some text", "id": 42}`) // encoded json

        r := slow.NewReader(sr, 5)
        dec := json.NewDecoder(r)

        type Sample struct {
                Value string `json:"value"`
                ID    int64  `json:"id"`
        }

        var sample Sample
        err := dec.Decode(&sample)
        if err != nil {
                panic(err)
        }

        fmt.Println("Decoded JSON value:", sample)
}

Это же в ascii-касте:


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

«Медленный» HTTP-клиент


Вас уже не должно удивлять, что io.Reader используется в стандартной библиотеке повсевместно — для всего, что умеет что-либо откуда-либо читать. Чтение из сети не исключение — io.Reader используется на нескольких уровнях, и спрятан под капотом такого, вроде бы, простого однострочного вызова http.Get(url string).

Для начала напишем стандартный код для HTTP GET запроса и выведем ответ в консоль:
package main

import (
    "io"
    "net/http"
    "os"
)

func main() {
    resp, err := http.Get("http://golang.org")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    io.Copy(os.Stdout, resp.Body)
}


Для тех, кто ещё не успел познакомиться с net/http-библиотекой — несколько объяснений. http.Get() — это обертка для метода Get() реализованного для типа http.Client — но в этой обёртке используется «подходящая для большинства случаев» уже иницилизированная переменная под названием DefaultClient. Собственно, Client дальше выполняет всю пыльную работу, в том числе и читает из сети с помощью объекта типа Transport, который в свою очередь использует более низкоуровневый объект типа net.Conn. Поначалу это может показаться запутанным, но, на самом деле, это достаточно легко изучается простым чтением исходников библиотеки — вот что-что, а стандартная библиотека в Go, в отличие от большинства других языков — это образцовый код, на котором можно (и нужно) учиться Go и брать с него пример.

Чуть ранее я упомянул про «io.Reader используется на нескольких уровнях» и это действительно так — к примеру resp.Body — это тоже io.Reader, но нам он не интересен, потому что нам интересно симулировать не тормознутый браузер, а медленное соединение — значит нужно найти io.Reader, который читает из сети. И это, забегая вперед, переменная типа net.Conn — а значит именно её нам и нужно переопределить для нашего кастомного http-клиента. Мы это можем сделать с помощью встраивания (embedding):
type SlowConn struct {
        net.Conn // embedding
        r        slow.SlowReader // in ascii-cast I use io.Reader here, but this one a bit better
}

// SlowConn is also io.Reader!
func (sc SlowConn) Read(p []byte) (int, error) {
        return sc.r.Read(p)
}


Самое сложное тут заключается в том, чтобы всё-таки немного глубже разобраться в пакетах net и net/http из стандартной библиотеки, и правильно создать наш http.Client, использующий медленный io.Reader. Но, в результате ничего сложного — надеюсь, на скринкасте видна логика, по мере того, как я поглядываю в код стандартной библиотеки.

В итоге получается следующий клиент (для реального кода это лучше вынести в отдельную функцию и чуть причесать, но для proof-of-concept примера сойдет):
        client := http.Client{
                Transport: &http.Transport{
                        Dial: func(network, address string) (net.Conn, error) {
                                conn, err := net.Dial(network, address)
                                if err != nil {
                                        return nil, err
                                }

                                return SlowConn{conn, slow.NewReader(conn, 100)}, nil
                        },
                },
        }


Ну а теперь склеиваем это всё вместе и смотрим результат:


В конце видно, что HTTP-заголовки выводятся в консоль нормально, а текст, собственно, страницы выводится с удвоением каждого символа — это нормально, поскольку мы выводим resp.Body с помощью io.Copy() и при этом наша, чуть модифицированная, реализация SlowReader.Read() выводит каждый символ тоже.

Заключение


Как говорилось в начале статьи, интерфейсы — чрезвычайно мощный инструментарий, да и сама идея разделения типов для свойств и для поведения — очень правильная. Но по-настоящему эта мощь проявляется, когда интерфейсы действительно используются по назначению в разных библиотеках. Это позволяет соединять очень разный функционал, и использовать чужой код для вещей, о которых оригинальный автор мог даже не подозревать. И речь не только о стандартных интерфейсах — внутри больших проектов интерфейсы дают огромную гибкость и модульность.

Ссылки


Поскольку идея этого поста была нагло утянута из твиттера Francesc Campoy, то только одна ссылка :)
twitter.com/francesc/status/563310996845244416
Tags:
Hubs:
+25
Comments 7
Comments Comments 7

Articles