Go и работа с базами данных, в частности с PostgreSQL (гостевой пост Владимира Солонина)

11 мая 2015

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

Примечание: Другие статьи Владимира вы найдете по следующим ссылкам:

Стандартным интерфейсом доступа к реляционным СУБД для Go является пакет database/sql. Он используется в связке с драйвером выбранной базы данных. Таким образом, полагаясь на арсенал, предоставляемый database/sql, мы получаем возможность миграции на любую другую РСУБД с минимальными изменениями в коде.

База данных в пакете database/sql представлена объектом sql.DB, который содержит пул соединений и может безопасно применяться для параллельной работы с БД из разных горутин. Он создается функцией sql.Open(), принимающей в качестве параметров название драйвера и строку с информацией о подключении. Последняя является драйвероспецифичной и обычно состоит из адреса сервера БД, номера порта, имени базы данных, имени пользователя и пароля. Объект sql.DB задуман, как долгоживущий. Правильно будет создать его перед первым использованием и закрыть, когда доступ к базе больше не нужен. Вопреки названию, функция sql.Open() не устанавливает соединение с БД. Она также не проверяет возможность подключения и правильность полученных параметров. Все это будет сделано при первом обращении к базе. Если необходима предварительная проверка, используйте метод (*DB)Ping().

func main() {
  db, err := sql.Open("postgres", params())
  chk(err)
  defer db.Close()

Здесь функция params() возвращает параметры БД, взятые из обычного INI-файла конфигурации $HOME/.phonebookrc. В качестве парсера конфига я использовал пакет github.com/FogCreek/mini.

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

Отложенные функции подчиняются трем правилам:

  1. Их аргументы вычисляются там, где находится вызов;
  2. Вызов нескольких отложенных функций происходит в обратном порядке, LIFO, как в стеке;
  3. Отложенные функции могут читать и влиять на именованные возвращаемые значения после их возврата внешней функцией.

Таким образом, закрытие нашей базы данных будет выполнено сразу после выхода из функции main.

Далее проверяем наличие в базе таблицы phonebook и создаем, если ее там нет:

  _, err = db.Exec("CREATE TABLE IF NOT EXISTS " +
    `phonebook("id" SERIAL PRIMARY KEY,` +
    `"name" varchar(50), "phone" varchar(100))`)
  chk(err)

Метод db.Exec() применяется, когда нужно сделать однократное обращение к базе, не требующее возврата данных. Помимо ошибки он возвращает sql.Result, который дает возможность узнать последний введенный ID и количество затронутых запросом строк. Сразу после выполнения (*DB)Exec() возвращает соединение обратно в пул.

Теперь разбор и проверка аргументов командной строки:

  switch os.Args[1] {
  case "add":
    if len(os.Args) != 4 {
      fatal("Usage: phonebook add NAME PHONE")
    }
    num, err := insert(db, os.Args[2], os.Args[3])
    chk(err)
    fmt.Println(num, "rows affected")
  case "del":
    if len(os.Args) < 3 {
      fatal("Usage: phonebook del ID...")
    }
    err = remove(db, os.Args[2:])
    chk(err)
  case "edit":
    if len(os.Args) != 5 {
      fatal("Usage: phonebook edit ID NAME PHONE")
    }
    err = update(db, os.Args[2], os.Args[3], os.Args[4])
    chk(err)
  case "show":
    if len(os.Args) > 3 {
      fatal("Usage: phonebook show [SUBSTRING]")
    }
    var s string
    if len(os.Args) == 3 {
      s = os.Args[2]
    }
    res, err := show(db, s)
    chk(err)
    format(res)
  case "help":
    fmt.Println(help)
  default:
    fatal("Invalid command: " + os.Args[1])
  }

По результатам которых вызывается одна из следующих функций:

// создает новую запись и возвращает кроме ошибки количество
// затронутых строк(всегда одну, очевидно)
func insert(db *sql.DB, name, phone string) (int64, error) {
  res, err := db.Exec("INSERT INTO phonebook VALUES (default, $1, $2)",
    name, phone)
  if err != nil {
    return 0, err
  }
  return res.RowsAffected()
}

Для многократного выполнения однотипных действий имеет смысл использовать связку из (*DB)Prepare() и (*Stmt)Exec():

// удаляет выбранные по id записи
func remove(db *sql.DB, ids []string) error {
  stmt, err := db.Prepare("DELETE FROM phonebook WHERE id=$1")
  if err != nil {
    return err
  }
  defer stmt.Close()
  for _, v := range ids {
    _, err := stmt.Exec(v)
    if err != nil {
      return err
    }
  }
  return nil
}

Использование транзакций:

// изменяет выбранную запись
func update(db *sql.DB, id, name, phone string) error {
  tx, err := db.Begin()
  if err != nil {
    return err
  }
  defer tx.Rollback()
  _, err = tx.Exec("UPDATE phonebook SET name = $1, " +
                   "phone = $2 WHERE id=$3",
                   name, phone, id)
  if err != nil {
    return err
  }
  return tx.Commit()
}

Транзакция всегда резервирует для себя одно соединение, в котором происходит все взаимодействие с базой. Она начинается с вызова (*DB)Begin() и заканчивается вызовом (*Tx)Commit() или (*Tx)Rollback(). После этого соединение возвращается в пул свободных соединений и все попытки использовать данную транзакцию оканчиваются ошибкой ErrTxDone. Таким образом, отложенный tx.Rollback() после выхода из функции update проверит, закрыта ли транзакция, и если да, ничего больше делать не будет, а просто вернет ошибку (ее мы не проверяем). Если же коммит не произошел и транзакция еще открыта, Rollback() произведет отмену всех изменений сделанных в рамках транзакции и закроет ее.

В функции show() используется еще один метод db.Query(). В отличие от db.Exec, его первое возвращаемое значение sql.Rows не должно игнорироваться. Дело в том, что возвращаемые sql.Rows резервируют соединение до вызова (*Rows)Close(), даже если результат запроса пуст или проигнорирован. И хотя в конце концов сборщик мусора закроет это соединение, лучше на это не полагаться. В случае вызова (*DB)Query() из долгоживущей функции рекомендуется выполнить (*Rows)Close(), как только строки будут не нужны, чтобы не занимать соединение до выполнения отложенной функции.

// возвращает все записи где name содержит заданную подстроку
func show(db *sql.DB, arg string) ([]record, error) {
  var s string
  if len(arg) != 0 {
    s = "WHERE name LIKE '%" + arg + "%'"
  }
  rows, err := db.Query("SELECT * FROM phonebook " + s +
                        " ORDER BY id")
  if err != nil {
    return nil, err
  }
  defer rows.Close()

  var rs = make([]record, 0)
  var rec record
  for rows.Next() {
    err = rows.Scan(&rec.id, &rec.name, &rec.phone)
    if err != nil {
      return nil, err
    }
    rs = append(rs, rec)
  }
  err = rows.Err()
  if err != nil {
    return nil, err
  }
  return rs, nil
}

Для итерации по строкам используется метод (*Rows)Next() в паре с for, а для считывания данных из строки — (*Rows)Scan(). Последняя, к сожалению, не может работать напрямую со структурами, но эта возможность есть в сторонних библиотеках. (*Rows)Scan() помимо прочего может самостоятельно конвертировать значение из базы к заданному типу. Так, например, если в базе колонка с типом VARCHAR(20) используется для записи целых чисел, передача указателя на int в качестве аргумента (*Rows)Scan() автоматически вызовет функцию strconv.ParseInt() и не надо будет делать это вручную.

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

Дополнение: Еще вас может заинтересовать пост Работа с PostgreSQL в языке Go при помощи pgx, а также Как легко и просто написать REST-сервис на Go.

Метки: , , .


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