Нередко в приложении со сложной бизнес-логикой требуется найти баг, который корнями уходит куда-то глубоко в DB-слой. Дело еще больше усложняется, когда в приложении нетривиальные SQL-запросы выражены через ORM или, что еще хуже, когда в системе построения запросов напрочь отсутствует какая-то структура. В такой ситуации на помощь может прийти proxy-сервер, который перехватит все запросы из приложения в БД и отобразит их в удобочитаемом виде. В этой статье мы рассмотрим те особенности протокола MySQL, которые помогут нам написать такой сервер.
 

Общая идея

Место proxy-сервера как раз между приложением, запросы которого нужно перехватить, и базой данных MySQL. То есть это будет обычный TCP-сервер, который слушает на заданном порту входящие соединения, читает из него данные, парсит их и далее пересылает уже в базу данных в точности в том виде, в каком они и пришли. Причем важно, чтобы proxy-сервер был асинхронным.

Реализовывать все это хозяйство мы будем на Go, возможности которого в данном случае идеально подходят.

 

Инструментарий

Сегодня нам понадобится:

  1. MySQL-сервер.
  2. Приложение, которое шлет запросы к MySQL-серверу и запросы которого мы будем перехватывать.
  3. Wireshark, чтобы посмотреть, как устроен MySQL-пакет.
  4. Базовые знания Go.
 

Каркас приложения

Начнем с подготовки каркаса нашего proxy-сервера, который пока что будет работать просто посредником между приложением и базой данных, совершенно ничего не анализируя. Слушать он будет на локальном порту 3305, а пересылать пакеты на локальный порт 3306.

// Файл main.go

package main

import (
    "io"
    "log"
    "net"
)

const (
    MYSQL = "127.0.0.1:3306"
    PROXY = "127.0.0.1:3305"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()

    mysql, err := net.Dial("tcp", MYSQL)
    if err != nil {
        log.Fatalf("%s: %s", "ERROR", err.Error())
        return
    }

    go io.Copy(conn, mysql)
    io.Copy(mysql, conn)
}

func main() {
    proxy, err := net.Listen("tcp", PROXY)
    if err != nil {
        log.Fatalf("%s: %s", "ERROR", err.Error())
    }
    defer proxy.Close()

    for {
        conn, err := proxy.Accept()
        if err != nil {
            log.Printf("%s: %s", "ERROR", err.Error())
        }

        go handleConnection(conn)
    }
}

Подробно рассмотрим, что здесь происходит. Как ты уже, вероятно, знаешь, точка входа — это функция main. Строка

proxy, err := net.Listen("tcp", PROXY)

инициирует начало прослушки на порту 3305. Важно не забыть закрыть proxy перед выходом из main:

defer proxy.Close()

Так как сервер должен постоянно принимать входящие соединения, запускаем вечный цикл, в котором и ожидаем подключения:

for {
    conn, err := proxy.Accept()
    //...
    go handleConnection(conn)
}

Здесь очень важно отметить, что вызов proxy.Accept() блокирующий. Это означает, что дальше этой строчки кода программа выполняться не будет, пока на порт 3305 не поступит соединение.

Далее начинается все самое интересное.

Последняя строка цикла запускает метод handleConnection(conn) в отдельной горутине, что обеспечивает возможность принимать входящие соединения и обрабатывать их независимо друг от друга. Как только произошел вызов handleConnection(conn) в отдельной горутине, сразу же, не дожидаясь, пока этот метод отработает, начинается переход к следующей итерации цикла, затем опять ожидание подключения и так далее до бесконечности, пока прогу не завершат или пока она не вылетит сама :).

Содержимое метода handleConnection довольно простое и понятное, но тем не менее именно он — «сердце» приложения. Как только приложение коннектится к нашему proxy-серверу, происходит «дозвон» до MySQL и начинается обмен пакетами в асинхронном режиме:

go io.Copy(conn, mysql)
io.Copy(mysql, conn)

Все, что приходит в proxy-сервер из приложения, мы тут же пересылаем в MySQL, и наоборот. Это и есть проксирование, просто пока довольно бесполезное. Чтобы добиться решения наших задач, придется один из вызовов io.Copy заменить своей реализацией, которая будет выдирать запрос из пакета. Какой из этих двух io.Copy будем заменять? А тот, который копирует данные из приложения в MySQL. io.Copy имеет следующую сигнатуру:

func Copy(dst Writer, src Reader) (written int64, err error)

Значит, наш «клиент» — это второй вызов io.Copy, его в свое время и подменим. А перед тем как начать, нужно рассмотреть, как устроен MySQL-пакет, чтобы знать, как его парсить.

 

Основы MySQL-протокола

В MySQL предусмотрено несколько типов команд, на которых основывается весь протокол передачи данных, — такие как COM_QUERY, COM_DROP_DB, COM_PING, COM_STMT_PREPARE. Каждая команда имеет свою структуру и определенный список возможных ответов. Например, когда приложение решает разорвать соединение с MySQL-сервером, драйвер, который реализует протокол MySQL, посылает команду COM_QUIT, а в ответ может получить OK_Packet. Если верить официальной документации MySQL Internals Manual, то каждый пакет должен быть сформирован по следующим правилам:

  1. Размер пакета не должен превышать 16 Мбайт.
  2. Каждый пакет должен иметь так называемый packet header, который, в свою очередь, состоит из payload_length (3 байта) — длина тела пакета, в байтах, и sequence_id (1 байт) — номер пакета в последовательности.
  3. Каждый пакет должен включать в себя payload, то есть тело пакета, содержащее всю полезную информацию.

Схематично MySQL-пакет можно представить так:

payload_length   sequence_id   payload
[3 байта     ]   [1 байт   ]   [n байт]

Нам будут интересны только команды COM_QUERY и COM_STMT_PREPARE, так как они и содержат в себе SQL-запросы, которые хочется достать.

Пришло время выпустить монстра Wireshark и посмотреть вживую, из чего состоят эти команды. Учитывая, что приложение, запросы которого будем смотреть, и MySQL-сервер находятся на локальной машине, в списке интерфейсов Wireshark нужно выбрать что-то вроде Loopback: lo0:

Начальная страница Wireshark
Начальная страница Wireshark

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

SELECT version();

В окне Wireshark сразу же станет видно весь трафик между приложением и MySQL-сервером. Для удобства выставим фильтр mysql.query, чтобы видеть только те пакеты, которые соответствуют командам COM_QUERY и COM_STMT_PREPARE или, иначе говоря, которые содержат строку SQL-запроса.

Страница трафика
Страница трафика

Выбрав любую строку и развернув список ▶ MySQL Protocol, можно наблюдать всю ту структуру пакета, о которой написано выше:

Структура пакета
Структура пакета

Красным выделено поле Packet Length — то же самое, что payload_length, его размер 3 байта, а равно оно значению 17. Зеленым на рисунке выделены эти 17 байт. Как видно, тело пакета СOM_QUERY состоит из частей Command и Statement. Command — это как раз индикатор команды, в данном случае СOM_QUERY. А Statement — это то, ради чего мы здесь. Команда COM_STMT_PREPARE имеет аналогичное строение пакета. Вот, собственно, и все, что потребуется. Значит, чтобы распарсить такой пакет, нужно предпринять следующие шаги:

  1. Получить заголовок пакета (первые 4 байта).
  2. При помощи заголовка посчитать длину тела пакета (в данном случае это 17 байт).
  3. Получить SQL-запрос длиной 16 байт (1 байт приходится на Command).

Приступим.

 

Разбор пакета MySQL

Создаем новый Go package с именем mysql, в него кладем файл packet.go и добавляем в него следующее:

// Файл mysql/packet.go

package mysql

import (
    "io"
    "net"
    "errors"
)

const (
    COM_QUERY = 3
    COM_STMT_PREPARE = 22
)

// Заранее определим возможные ошибки
var ErrWritePacket = errors.New("error while writing packet payload")
var ErrNoQueryPacket = errors.New("malformed packet")

// ReadPacket читает данные из conn, возвращая готовый пакет
func ReadPacket(conn net.Conn) ([]byte, error) {
    header := []byte{0, 0, 0, 0}

    if _, err := io.ReadFull(conn, header); err == io.EOF {
        return nil, io.ErrUnexpectedEOF
    } else if err != nil {
        return nil, err
    }

    bodyLength := int(uint32(header[0]) | uint32(header[1])<<8 | uint32(header[2])<<16)

    body := make([]byte, bodyLength)

    n, err := io.ReadFull(conn, body)
    if err == io.EOF {
        return nil, io.ErrUnexpectedEOF
    } else if err != nil {
        return nil, err
    }

    return append(header, body[0:n]...), nil
}

// WritePacket пишет пакет, полученный из метода ReadPacket, в conn
func WritePacket(pkt []byte, conn net.Conn) (int, error) {
    n, err := conn.Write(pkt)
    if err != nil {
        return 0, ErrWritePacket
    }

    return n, nil
}

// ProxyPacket объединяет методы ReadPacket и WritePacket
func ProxyPacket(src, dst net.Conn) ([]byte, error) {
    pkt, err := ReadPacket(src)
    if err != nil {
        return nil, err
    }

    _, err = WritePacket(pkt, dst)
    if err != nil {
        return nil, err
    }

    return pkt, nil
}

// CanGetQueryString проверяет, является ли пакет командой COM_QUERY или COM_STMT_PREPARE
func CanGetQueryString(pkt []byte) bool{
    return len(pkt) > 5 && (pkt[4] == COM_QUERY || pkt[4] == COM_STMT_PREPARE)
}

// GetQueryString возвращает строку запроса, начиная с 6-го байта всего пакета
func GetQueryString(pkt []byte) (string, error){
    if CanGetQueryString(pkt){
        return string(pkt[5:]), nil
    }

    return "", ErrNoQueryPacket
}

Подробнее стоит остановиться на методе ReadPacket, остальные вполне очевидны, и для их понимания хватит комментария над каждым.

header := []byte{0, 0, 0, 0}

if _, err := io.ReadFull(conn, header); err == io.EOF {
    return nil, io.ErrUnexpectedEOF
} else if err != nil {
    return nil, err
}

Здесь в первой строке мы подготавливаем буфер для заголовка пакета. Как помнишь, его размер всегда 4 байта. Далее происходит чтение из conn — ровно 4 байта, ни больше ни меньше, иначе это ошибка и «что-то пошло не так».

bodyLength := int(uint32(header[0]) | uint32(header[1])<<8 | uint32(header[2])<<16)

После того как заголовок пакета получен, нам нужно знать, какой длины буфер готовить для тела пакета (то есть для всего остального). Для этого, используя 1, 2 и 3-й байты заголовка и применив к ним логические операции и операции битового сдвига, мы получим требуемый размер в виде десятичного числа.

body := make([]byte, bodyLength)

n, err := io.ReadFull(conn, body)
if err == io.EOF {
    return nil, io.ErrUnexpectedEOF
} else if err != nil {
    return nil, err
}

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

return append(header, body[0:n]...), nil

Последнее, что нужно сделать, — это сформировать готовый пакет-срез, который содержит в себе заголовок и тело. Все, пакет готов: известна его длина, известен тип пакета (5-й байт — это Command). Можно делать с ним что угодно — выдирать SQL-запрос, отправлять дальше в MySQL. Осталось только изменить метод handleConnection, подменив io.Copy(mysql, conn) на свой метод:

import dbms "github.com/orderbynull/myproxy/mysql"
...
func handleConnection(conn net.Conn) {
    defer conn.Close()

    mysql, err := net.Dial("tcp", MYSQL)
    if err != nil {
        log.Fatalf("%s: %s", "ERROR", err.Error())
        return
   }

   go io.Copy(conn, mysql)
   appToMysql(conn, mysql)
}

А вот и сам новый метод:

func appToMysql(app net.Conn, mysql net.Conn) {
    for {
        pkt, err := dbms.ProxyPacket(app, mysql)
        if err != nil {
            break
        }

        if query, err := dbms.GetQueryString(pkt); err == nil {
            fmt.Printf("> %s nn", query)
        }
    }
}

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

Пробуем запустить и проверить. Для этого в папке исходников выполняем

$ go run main.go

Proxy-сервер слушает на порту 3305 и перенаправляет запросы на порт 3306. Тестовое приложение, которое шлет SQL-запросы, настраиваем, чтобы работало с портом 3305. Далее выполняем любые запросы и наблюдаем в консоли нечто вроде этого:

Результат работы
Результат работы
 

Заключение

Мы рассмотрели лишь мизерную часть протокола MySQL. Существует немалое количество типов MySQL-пакетов, большая часть которых имеет куда более сложную структуру. С каждым из них можно ознакомиться в официальной документации.

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии