# README
I. Работа с базой данных
Проект изначально создавался под Postgres, но переделан и полностью рабочий под MySQL. Если есть отличия Postgres от MySQL по тексту добавлены коментарии. Поэтому видим Postgres, а подразумеваем MySQL.
1. Cхема БД и SQL-код
-> Go to App
Export PostgreSQL
doc/db.dbml doc/schema.sql
2. Docker Postgres
$ docker ps // список всех запущенных контейнеров
$ docker images // список всех имеющихся образов
Удалить установленный Postgres
Uninstall the PostgreSQL application
$ sudo apt-get --purge remove postgresql
Remove PostgreSQL packages
$ dpkg -l | grep postgres
To uninstall PostgreSQL completely, you need to remove all of these packages using the following command:
$ sudo apt-get --purge remove <package_name>
Remove PostgreSQL directories
$ sudo rm -rf /var/lib/postgresql/
$ sudo rm -rf /var/log/postgresql/
$ sudo rm -rf /etc/postgresql/
Remove the postgres user
$ sudo deluser postgres
Verify uninstallation
$ psql --version
Скачать образ
hub.docker.com
поиск postgres
https://hub.docker.com/_/postgres
docker pull <image>:<tag>
$ docker pull postgres:16-alpine
или
$ docker pull mysql:8.0
Запуск контейнера из образа
docker run --name <container_name> -e <environment_variable> -d <image>:<tag>
Environment Variables:
- POSTGRES_PASSWORD
- POSTGRES_USER
- POSTGRES_DB
- POSTGRES_INITDB_ARGS
- POSTGRES_INITDB_WALDIR
- POSTGRES_HOST_AUTH_METHOD
- PGDATA
For example:
$ docker run -d \
--name some-postgres \
-e POSTGRES_PASSWORD=mysecretpassword \
-e PGDATA=/var/lib/postgresql/data/pgdata \
-v /custom/mount:/var/lib/postgresql/data \
postgres
пароли можно подгружать из файла:
$ docker run --name some-postgres -e POSTGRES_PASSWORD_FILE=/run/secrets/postgres-passwd -d postgres
Port mapping
docker run --name ‹container_name> -e ‹environment_variable> -p ‹host_ports:container_ports> -d ‹image>:<tag>
$ docker run --name postgres16 -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=secret -d postgres:16-alpine
или
$ docker run --name mysql8 -p 3306:3306 -e MYSQL_DATABASE=main_db -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0
$ docker ps
$ docker stop postgres16
$ docker ps -a // все контейнеры вне зависимости запущены или нет
$ docker start postgres16 // снова запустить имеющийся контейнер
$ docker rm postgres16 // удалить полностью имеющийся контейнер
Запуск команды в контейнере
docker exec -it ‹container _name_or_id> ‹command> [args]
$ docker exec -it postgres16 psql -U root
select now();
\q - выход
$ docker exec -it postgres16 /bin/sh // запускаем оболочку в контейнере
Просмотр логов контейнера
docker logs <container_name_or_id>
$ docker logs postgres16
TablePlus
Для kubuntu лучше либо pgAdmin4 либо DBeaver При повторном подключении DBeaver к базе может возникнуть ошибка Public Key Retrieval is not allowed В подключении к базе советуют (при разработке) в Свойствах драйвера изменить переменные allowPublicKeyRetrieval=true & useSSL=false
Для mac - tableplus.com
basename: root
user: root
password: secret
url: localhost:5432
для mysql url: 127.0.0.1:3306
3. Миграции
github.com/golang-migrate/migrate
$ brew install golang-migrate
$ migrate -version
v4.17.0
$ migrate -help
$ migrate create -ext sql -dir db/migration -seq init schema
Открыть созданный файл миграции db/migration/000001_init.up.sql и скопировать содержимое вашего файла схемы базы данных doc/schema_mysql.sql в этот файл
$ docker exec -it postgres16 /bin/sh
# createdb -username=root -owner=root main_db
# psql main_db
# dropdb main_db
# exit
$ docker exec postgres16 createdb --username=root --owner=root main_db
$ docker exec -it postgres16 psql -U root main_db
\q
Миграция в проекте
$ set -e
$ source ./app.env
// если нет базы $ docker exec postgres16 createdb --username=root --owner=root main_db
$ migrate -path ./db/migration -database "$DB_SOURCE" -verbose up
или для mysql:
$ migrate -path ./db/migration -database "mysql://root:secret@tcp(localhost:3306)/main_db" -verbose up
$ migrate -path ./db/migration -database "mysql://root:secret@tcp(localhost:3306)/main_db" force 1
$ migrate -path ./db/migration -database "mysql://root:secret@tcp(localhost:3306)/main_db" -verbose down
Cоздаем Makefile
run-postgres: ## Start postgresql database docker image.
docker run --name postgres16 -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=secret -d postgres:16-alpine
start-postgres16: ## Start available postgresql database docker container.
docker start postgres16
stop-postgres: ## Stop postgresql database docker image.
docker stop postgres16
4. CRUD
- Create
- Read
- Update
- Delete
DATABASE/SQL
- Очень быстро и просто
- Ручное сопоставление полей SQL с переменными
- Легко допускать ошибки, которые не обнаруживаются до выполнения
GORM
- Функции CRUD уже реализованы, очень короткий рабочий код
- Необходимо научиться писать запросы с использованием функции gorm
- Выполняется медленно при высокой нагрузке
- В 3 - 5 раз медленнее работает
SQLX
- Довольно быстрый и простой в использовании
- Сопоставление полей с помощью тегов текста запроса и структуры
- Сбой не произойдет до времени выполнения
SQLC
- Очень быстрый и простой в использовании
- Автоматическая генерация кода
- Отслеживание ошибок запроса SQL перед генерацией кодов
- Полная поддержка Postgres. MySQL является экспериментальным
sqlc.dev
github.com/sqlc-dev/sqlc
$ brew install sqlc
$ sqlc version
$ sqlc help
$ sqlc init
sqlc.yaml
docs.sqlc.dev/en/latest/tutorials/getting-started-postgresql.html#setting-up
db/query/account.sql
docs.sqlc.dev/en/latest/tutorials/getting-started-postgresql.html#schema-and-queries
Отличия запросов mysql от postgres:
в запросах не может быть RETURNING *, выкручиваемся последующим запросом данных,
в запросах меняем аргументы $1, $2, $3... на знаки "?", иначе не будет входных аргументов
$ sqlc generate
5. Тесты
_ "github.com/lib/pq" // без драйвера работать не будет
Для MySql драйвер mysql:
$ go get -u github.com/go-sql-driver/mysql
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
$ go test -v // все тесты
$ go test -timeout 30s ./db/sqlc -run ^TestMain$
ok github.com/Country7/backend-captaincode-mysql/db/sqlc 0.433s [no tests to run]
$ make test // команда test из файла Makefile
6. Транзакции
Перевод 10 USD из банка аккаунта 1 в банк аккаунта 2:
- Создайте запись транзакции о переводе с суммой = 10
- Создайте учетную запись для учетной записи 1 с суммой = -10
- Создайте учетную запись для учетной записи 2 с суммой = +10
- Вычтите 10 из баланса учетной записи 1
- Добавьте 10 к балансу учетной записи 2
- BEGIN
- ...
- COMMIT or
- BEGIN
- ...
- ROLLBACK
type Store interface {
Querier
TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
}
// SQLStore provides all functions to execute SQL queries and transactions.
type SQLStore struct {
*Queries
db *sql.DB
}
func NewStore(db *sql.DB) Store {
return &SQLStore{db: db, Queries: New(db)}
}
// execTx executes a function within a database transaction.
func (s *SQLStore) execTx(ctx context.Context, fn func(queries *Queries) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
q := New(tx)
err = fn(q)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
}
return err
}
return tx.Commit()
}
7. Блокировка транзакции
BEGIN;
SELECT * FROM accounts WHERE id = 1;
SELECT * FROM WHERE id = 1 FOR UPDATE; // блокировка запросов
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
$ sqlc generate
Deadlock detected
INSERT INTO entries (account_id, amount) VALUES ($1, $2) RETURNING *;
и
SELECT * FROM accounts WHERE id = $1 LIMIT 1 FOR UPDATE;
заблокируют друг друга (Deadlock detected) несмотря на то, что обращение идет к разным таблицам
Эти две таблицы имеют связи FOREIGN KEY:
ALTER TABLE "entries" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
при обращении к accounts происходит обновление ключа id в таблице accounts по связям с entries
чтобы этого не происходило необходима команда !!! NO KEY UPDATE
SELECT * FROM accounts WHERE id = $1 LIMIT 1 FOR NO KEY UPDATE;
8. Взаимоблокировки
BEGIN:
UPDATE accounts SET balance = balance - 10 WHERE id = 1 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 2 RETURNING *;
ROLLBACK;
BEGIN:
UPDATE accounts SET balance = balance - 10 WHERE id = 2 RETURNING *;
UPDATE accounts SET balance = balance + 10 WHERE id = 1 RETURNING *;
ROLLBACK;
Одновременно две эти транзакции приведут в взаимоблокировке (Deadlock detected)
if arg.FromAccountID < arg.ToAccountID {
result.FromAccount, result.ToAccount, err = addMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount)
} else {
result.ToAccount, result.FromAccount, err = addMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount)
}
9. Уровень изоляции транзакций
- Чтение незафиксированных транзакций (read uncommitted)
- Чтение зафиксированных данных (read committed)
- Повторяемый уровень изоляции чтения (repeatable read)
- Параллельные разрешения (serializable)
mysql> select @@transaction_isolation;
mysql> select @@global.transaction_isolation;
mysql> set session transaction isolation level read uncommitted;
mysql> set session transaction isolation level read committed;
mysql> set session transaction isolation level repeatable read;
mysql> set session transaction isolation level serializable;
postgres=# show transaction isolation level;
postgres=# begin;
postgres=# set transaction isolation level read uncommitted;
# в postgres уровень read uncommitted ведет себя как read committed, как будто его нет
postgres=# set transaction isolation level read committed;
postgres=# set transaction isolation level repeatable read;
postgres=# set transaction isolation level serializable;
postgres=# show transaction isolation level;
postgres=# commit;
READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE | |
---|---|---|---|---|
DIRTY READ | V | - | - | - |
NON-REPEATABLE READ | V | V | - | - |
PHANTOM READ | V | V | - | - |
SERIALIZATION ANOMALY | V | V | V | - |
10. Действие на Github Go + Postgres
Рабочий процесс:
- Является автоматизированной процедурой
- Состоит из 1+ заданий
- Запускается по событиям, по расписанию или вручную
- Добавьте файл .yml в репозиторий
Запуск (Runner)
- Является ли сервер для запуска заданий
- Запускайте по 1 заданию за раз
- Размещено на github или самостоятельно
- Сообщайте о ходе выполнения, журналах и результатах на github
Задания (Job)
- Представляет собой набор шагов, выполняемых в одном и том же runner
- Обычные задания выполняются параллельно
- Зависимые задания выполняются последовательно
Шаг
- Является отдельной задачей
- Выполняется последовательно в рамках задания
- Содержит более 1 действия
Действие
- Является автономной командой
- Выполняется последовательно в пределах шага
- Может использоваться повторно
---------------------------------------------
---------------------------------------------
II. Создание RESTful HTTP JSON API
11. Реализация RESTful HTTP API в Go с помощью Gin (2.1)
Стандартный пакет net/http
Popular web frameworks
- Gin
- Beego
- Echo
- Revel
- Martini
- Fiber
- Buffalo
Popular HTTP routers
- FastHttp
- Gorilla Mux
- HttpRouter
- Chi
Самый популярный - Gin - github.com/gin-gonic/gin
api/server.go
api/account.go
main.go
в main.go обязательно добавить импорт драйвера
_ "github.com/lib/pq"
или
_ "github.com/go-sql-driver/mysql"
Makefile:
server: ## Run the application server.
go run main.go
В целях тестирования запросов установить Postman
GET http://localhost:8080/accounts
12. Конфигурация из файла и переменных окружения - Viper (2.2)
$ go get github.com/spf13/viper
app.env
util/config.go
main.go
config, err := util.LoadConfig(".")
$ SERVER_ADDRESS=0.0.0.0:8081 make server
db/sqlc/main_test.go
13. Mock DB (макет) - тестирование HTTP API и 100% охвата (2.3)
Подготовка:
$ go get go.uber.org/mock
$ go install go.uber.org/mock/mockgen@latest
$ ls -l ~/go/bin
// проверить ~/go/bin/mockgen
$ which mockgen
~/go/bin/mockgen
Если нет, то:
$ vi ~/.zshrc // для mac
$ vi ~/.bash_profile // или для другого терминала
i
export PATH=$PATH:~/go/bin
esc
:wq
$ source ~/.zshrc
$ which mockgen
~/go/bin/mockgen
$ mockgen -help
// БЫЛО:
// api/server.go
func NewServer(config util.Config, store *db.Store) (*Server, error)
// для подключения к реальной базе данных используется store *db.Store
// для тестов mock (с макетом) его надо заменить интерфейсом
// db/sqlc/store.go
type Store struct {
db *sql.DB
*Queries
}
func NewStore(db *sqL.DB) *Store {
return &Store {
db: db,
Queries: New(db),
}
}
func (s *Store) execTx(ctx context.Context, fn func(queries *Queries) error) error
func (s *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
// sqlc.yaml
emit_interface: false
// ПЕРЕПИСЫВАЕМ:
// sqlc.yaml
emit_interface: true // было false
$ make sqlc // обновить в терминале
// создался новый файл с интерфейсом db/sqlc/querier.go
// db/sqlc/store.go
type Store interface {
Querier // интерфейс из нового файла db/sqlc/querier.go
TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
}
type SQLStore struct {
*Queries
db *sql.DB
}
func NewStore(db *sql.DB) Store {
return &SQLStore{db: db, Queries: New(db)}
}
func (s *SQLStore) execTx(ctx context.Context, fn func(queries *Queries) error) error
func (s *SQLStore) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error)
// api/server.go
type Server struct {
config util.Config
store db.Store // убрали * у db.Store, это теперь не указатель, а интерфейс
tokenMaker token.Maker
router *gin.Engine
}
func NewServer(config util.Config, store db.Store) (*Server, error) // убрали * у db.Store, это теперь не указатель, а интерфейс
Создаем пакет db/moc:
создаем папку db/moc
$ mockgen -package mockdb -destination db/mock/store.go github.com/Country7/backend-captaincode-mysql/db/sqlc Store
// создался файл db/mock/store.go
// добавляем команду в файл Makefile
mock: ## Generate a store mock.
mockgen -package mockdb -destination db/mock/store.go github.com/Country7/backend-captaincode-mysql/db/sqlc Store
Приступаем к написанию тестов:
api/account_test.go
14. Пользовательский валидатор параметров - transfer (2.4)
api/transfer.go
api/server.go
authRoute.POST("/transfers", server.createTransfer)
Postman:
POST http://localhost:8080/transfers
Body raw JSON
{
"from_account_id": 1,
"to_account_id": 2,
"amount": 10,
"currency": "USD"
}
api/validator.go
import ( "github.com/go-playground/validator/v10" )
var validCurrency validator.Func = func(fieldLevel validator.FieldLevel) bool {
if currency, ok := fieldLevel.Field().Interface().(string); ok {
return util.IsSupportedCurrency(currency)
}
return false
}
util/currency.go
const (
USD = "USD"
EUR = "EUR"
CAD = "CAD" )
// IsSupportedCurrency returns true if the currency is supported.
func IsSupportedCurrency(currency string) bool {
switch currency {
case CAD, EUR, USD:
return true
default:
return false
}
}
Регистрация вадидатора на сервере
api/server.go
import ("github.com/gin-gonic/gin/binding")
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("currency", validCurrency)
}
api/account.go
type createAccountRequest struct {
...
Currency string `json:"currency" binding:"required,currency"`
}
api/transfer.go
type transferRequest struct {
...
Currency string `json:"currency" binding:"required,currency"`
}
Postman:
POST http://localhost:8080/transfers
Body raw JSON
{
"from_account_id": 1,
"to_account_id": 2,
"amount": 10,
"currency": "EUR"
}
util/random.go
// RandomCurrency generates a random currency code.
func RandomCurrency() string {
currencies := []string{EUR, USD, CAD}
n := len(currencies)
return currencies[rand.Intn(n)]
}
15. Добавление таблицы users с ограничениями уникальности и внешнего ключа (2.5)
-> Go to App
Table users as U {
username varchar [pk]
role varchar [not null, default: 'depositor']
hashed_password varchar [not null]
full_name varchar [not null]
email varchar [unique, not null]
is_email_verified bool [not null, default: false]
password_changed_at timestamptz [not null, default: '0001-01-01']
created_at timestamptz [not null, default: `now()`]
}
Table accounts as A {
...
owner varchar [ref: > U.username, not null]
...
Indexes {
owner
(owner, currency) [unique] // в одной валюте только один счет у пользователя
// в разной валюте может быть несколько счетов
}
}
Export PostgreSQL
$ migrate -help
$ migrate create -ext sql -dir db/migration -seq add_users
В созданный файл db/migration/000002_add_users.up.sql копируем изменения из doc/schema.sql (таблицу users и ключи)
$ make migrateup
// ошибка, так как данные accounts есть, а их в новой таблице users - нет
$ make migratedown
// ошибка, надо вручную менять значение в БД / таблице schema_migrations с TRUE на FALSE
$ make migratedown
// удалились все таблицы
$ make migrateup
В файле db/migration/000002_add_users.down.sql грохаем ключи, грохаем таблицу users
16. Обработка ошибок базы данных (2.6)
db/query/user.sql
$ make sqlc
Появился db/sqlc/user.sql.go
Изменились
db/sqlc/models.go
db/sqlc/querier.go
Пишем db/sqlc/user_test.go
Правим db/sqlc/account_test.go
func createRandomAccount(t *testing.T) Account {
user := createRandomUser(t)
arg := CreateAccountParams{
Owner: user.Username,
...
}
$ make mock
$ make test
Допиливаем api/account.go
account, err := server.store.CreateAccount(ctx, arg)
if err != nil {
var pqErr *pq.Error // добавляем от сюда
if errors.As(err, &pqErr) {
switch pqErr.Code.Name() {
case "foreign_key_violation", "unique_violation":
// ошибки на сервере при создании аккаунта без юзера,
// и создании аккаунта с одинаковой валютой счета
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
} // до сюда
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
Postman:
POST http://localhost:8080/users
Body raw JSON
{
"username": "QuangBang",
"password": "secret",
"full_name": "Quang Bang",
"email": "[email protected]"
}
POST http://localhost:8080/users/login
Body raw JSON
{
"username": "QuangBang",
"password": "secret"
}
При работе с mysql выдает ошибку, что полю PasswordChangedAt time.Time - row.Scan не может присвоить значение []int8.
Решается просто. В файле app.env к переменной DB_SOURCE добавляем параметр ?parseTime=true.
?? под вопросом, нужно проверить - И, кстати, убираем из этой переменной mysql://.
Получается: DB_SOURCE=root:secret@tcp(localhost:3306)/main_db?parseTime=true
Лучше с кавычками: DB_SOURCE="root:secret@tcp(localhost:3306)/main_db?parseTime=true"
А то потом, при развертывании docker контейнеров могут быть ошибки.
POST http://localhost:8080/accounts
Authorization Bearer Token ...
Body raw JSON
{
"owner": "QuangBang",
"currency": "USD"
}
GET http://localhost:8080/accounts?page_id=1&page_size=5
key page_id 1 page_size 5
Body raw JSON
{
"owner": "QuangBang",
"limit": 5,
"offset": 0
}
---------------------------------------------
17. Безопасное хранение паролей Hash password в Go с помощью Bcrypt (2.7)
util/password.go
api/user.go
api/validator.go
github.com/go-playground/validator
alphanum Alphanumeric
email E-mail String
18. Модульные тесты с помощью gomock (2.8)
19. Почему PASETO лучше, чем JWT, для аутентификации на основе токенов (2.9)
Token-based Authentication
Client | 1. POST /users/login -----------------------> {username, password} | Server |
Client | 200 OK <----------------------- {access_token: JWT, PASETO, ...} | <-- Sign token | Server |
Client | 2. GET /accounts -----------------------> Authorization: Bearer <access_token> | Server |
Client | 200 OK <----------------------- [account1, account2, ...] | <-- Verify token | Server |
АЛГОРИТМЫ ПОДПИСИ JWT
header:
{
"typ": "JWT",
"alg": "HS256"
}
payload:
{
"id": "1337",
"username": "bizone",
"iat": 1594209600,
"role": "user"
}
signature:
ZvkYYnyM929FM4NW9_hSis7_x3_9rymsDAx9yuOcc1I
Алгоритм симметричной цифровой подписи
- Для подписи и проверки используется один и тот же секретный ключ, токен
- Для локального использования: внутренние службы, где можно совместно использовать секретный ключ
- HS256, HS384, HS512
- HS256 = HMAC + SHA256
- HMAC: Hash-based Message Authentication Code - Код аутентификации сообщения на основе хэша
- SHA: Secure Hash Algorithm - Алгоритм безопасного хэширования
- 256/384/512: количество выходных битов
Алгоритм асимметричной цифровой подписи
- Закрытый ключ используется для подписи токена
- Открытый ключ используется для проверки токена
- Для публичного использования: внутренняя служба подписывает токен, но внешняя служба должна его подтвердить
- RS256, RS384, RS512 || PS256, PS384, PS512 || ES256, ES384, ES512
- RS256 = RSA PKCSv1.5 + SHA256 [PKCS: Public-Key Cryptography Standards - Стандарты криптографии с открытым ключом]
- PS256 = RSA PSS + SHA256 [PSS: Probabilistic Signature Scheme - Вероятностная схема подписи]
- ES256 = ECDSA + SHA256 [ECDSA: Elliptic Curve Digital Signature Algorithm - Алгоритм цифровой подписи с эллиптической кривой]
В чем проблема JWT?
Слабые алгоритмы
- Дают разработчикам слишком много алгоритмов на выбор
- Известно, что некоторые алгоритмы уязвимы:
- RSA PKCSv1.5: атака на oracle с дополнением
- ECDSA: атака с недопустимой кривой
Тривиальная подделка
- Установите для заголовка "alg" значение "none"
- Установите для заголовка "alg" значение "HS256", в то время как сервер обычно проверяет токен с помощью открытого ключа RSA
Platform-Agnostic SEcurity TOkens [PASETO] Независимые от платформы токены безопасности
Более надежные алгоритмы
- Разработчикам не нужно выбирать алгоритм
- Нужно только выбрать версию PASETO
- Каждая версия имеет 1 набор надежных шифров
- Принимаются только 2 самые последние версии PASETO
Нетривиальная подделка
- Больше никакого заголовка "alg" или алгоритма "none"
- Все аутентифицировано
- Зашифрованная полезная нагрузка для локального использования <симметричный ключ>
- v1 [совместима с устаревшей системой]
- локальный: <симметричный ключ>
- Аутентифицированное шифрование
- AES256 CTR + HMAC SHA384
- открытый: <асимметричный ключ>
- Цифровая подпись
- RSA PSS + SHA384
- локальный: <симметричный ключ>
- v2 [рекомендуется]
- локальный: <симметричный ключ>
- Аутентифицированное шифрование
- XChaCha20-Poly1305
- открытый: <асимметричный ключ>
- Цифровая подпись
- Ed25519 [EdDSA + Curve25519]
- локальный: <симметричный ключ>
• Version: v2
• Purpose: public [asymmetric-key digital signature]
• Payload:
• Body:
• Encoded: [base64]
eyJeHAiO¡IyMDM5LTAxLTAxVDAwOjAwOjAwKzAwOjAwIiwiZGFOYSI
6InRoaXMgaXMgYSBzaWduZWQgbWVzc2FnZSJ91g
• Decoded:
{
"data": "this is a signed message" ,
"exp": "2039-01-01T00:00:00+00:00"
}
• Signature: [hex-encoded]
d600bbfa3096b0dde6bf8b89699c59a746ed2c981cc95c0bfacbc90fb7
f8207c86b5e29edc74cb8c761318723532d0aa27e1120cb36813ba2d90
8cda985b2408
20. Создать и верифицировать токен JWT & PASETO (2.10)
token/maker.go
token/payload.go
token/jwt_maker.go
token/jwt_maker_test.go
$ go get github.com/google/uuid
$ go get github.com/golang-jwt/jwt/v5
token/paseto_maker.go
token/paseto_maker_test.go
$ go get github.com/o1egl/paseto/v2
21. API для входа в систему через токен PASETO или JWT (2.11)
api/server.go
app.env
util/config.go
api/main_test.go
api/transfer_test.go
api/user_test.go
api/account_test.go
main.go
api/user.go
api/server.go router
При работе с mysql выдает ошибку, что слишком длинную строку RefreshToken сервер пытается записат в БД.
Длина токена получается 335 символов когда в базе заявлено 255. Лечится просто:
В db/migration/000001_init.up.sql строку refresh_token
varchar(255) NOT NULL в CREATE TABLE sessions
делаем длиной 512 - refresh_token
varchar(512) NOT NULL
утилита Postman:
POST http://localhost:8080/users/login
Body raw JSON
{
"username": "qwe",
"password": "1234567"
}
Response 200 OK
{
"session_id": "e7a1856e-aaa0-4226-9681-6ff7993dd789",
"access_token": "v2.local. ...",
"access_token_expires_at": "2024-03-30T16:05:55.116046+03:00",
"refresh_token": "v2.local. ...",
"refresh_token_expires_at": "2024-03-31T15:50:55.116324+03:00",
"user": {
"username": "qwe",
"full_name": "asdfgh",
"email": "[email protected]",
"password_changed_at": "0001-01-01T00:00:00Z",
"created_at": "2024-03-27T17:34:06.837219Z"
}
}
22. Middleware авторизации (2.12)
Что такое промежуточное программное обеспечение?
| | Send request | |
| | --------------> Route | |
| | /accounts/create | |
| | | | |
| | | | |
| | V | |
| | ctx. Abort() Middlewares | |
| CLIENT | <-------------- Logger (ctx), | SERVER |
| | Send response Auth(ctx) | |
| | | | |
| | | ctx.Next() | |
| | | | |
| | V | Авторизация:
| | Send response Handler <--------- У пользователя
| | <-------------- createAccount(ctx) | есть разрешение?
| | | |
api/middleware.go
api/middleware_test.go
api/server.go
router := gin.Default()
router.POST("/users/login", server.loginUSer)
authRoute := router.Group("/").Use(authMiddleware(server.tokenMaker))
authRoute.GET("/accounts", server.listAccount)
ПРАВИЛА АВТОРИЗАЦИИ
API Create account | -------> | Правило Авторизованный пользователь может создать учетную запись только для себя |
API Get account | -------> | Правило Авторизованный пользователь может получить только те учетные записи, которыми он владеет |
API List accounts | -------> | Правило Авторизованный пользователь может перечислять только те учетные записи, которые принадлежат ему |
API Transfer money | -------> | Правило Авторизованный пользователь может отправлять деньги только со своего собственного аккаунта |
api/account.go func createAccount
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) // добавили
arg := db.CreateAccountParams{
Owner: authPayload.Username, // было req.Owner
Balance: 0,
Currency: req.Currency,
}
api/account.go func getAccount
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) // добавили
if account.Owner != authPayload.Username {
err := errors.New("account doesn't belong to the authenticated user")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}
db/query/account.sql
-- name: ListAccounts :many
SELECT * FROM accounts
WHERE owner = $1 // добавлено условие
ORDER BY id LIMIT $2
OFFSET $3;
make sqlc // db/sqlc/account.sql.go listAccounts обновился
make mock
db/sqlc/account_test.go TestListAccounts
api/account.go func listAccount
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) // добавили
arg := db.ListAccountsParams{
Owner: authPayload.Username, // добавили
Limit: req.PageSize,
Offset: (req.PageID - 1) * req.PageSize,
}
api/transfer.go
func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) (db.Account, bool)
// добавили db.Account
return account, true
func createTransfer
fromAccount, valid := server.validAccount(ctx, req.FromAccountID, req.Currency)
if !valid {
return
}
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
if fromAccount.Owner != authPayload.Username {
err := errors.New("from account doesn't belong to the authenticated user")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}
_, valid = server.validAccount(ctx, req.ToAccountID, req.Currency)
if !valid {
return
}
api/account_test.go
---------------------------------------------
---------------------------------------------
III. Развертывание приложения в рабочей среде (Deploying the application to production)
23. Образ Golang Docker с помощью многоступенчатого файла Dockerfile (3.1)
$ git checkout -b deploying
update go
go.mod
go 1.22
.github/workflows/test.yml
go-version: '1.22'
$ git status
$ git add .
$ git status
$ git commit -m"update go to 1.22"
$ git push -u origin deploying
Из терминала переходим по ссылке
https://github.com/Country7/backend-captaincode-mysql/pull/new/deploying
Name -> Add docker
-> Create pull request
$ brew upgrade golang-migrate // для mac
$ curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-amd64.tar.gz | tar xvz
// для linux
$ migrate -version
v4.17.0
$ make migrate-up
.github/workflows/test.yml
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-amd64.tar.gz | tar xvz
$ git add .
$ git status
$ git commit -m"upgrade golang-migrate to v4.17.0"
$ git push
Если тесты на github пройдены, то приложение готово к 1 запуску
Создаем Dockerfile:
https://hub.docker.com/_/golang
FROM golang:1.22.2-alpine3.19
WORKDIR /app
COPY . .
RUN go build -o main main.go
EXPOSE 8080
CMD [ "/app/main" ]
$ docker build --help
$ docker build -t captaincode:latest .
$ docker images
captaincode latest 601MB
Размер образа получился 601Мб, чтобы уменьшить размер образа нужно применить многоступенчатую сборку. Нам в образе нужен только исполняемый файл.
Dockerfile:
# Build stage
FROM golang:1.22.2-alpine3.19 AS builder
WORKDIR /app
COPY . .
RUN go build -o main main.go
# Run stage
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
CMD [ "/app/main" ]
$ docker build -t captaincode:latest .
$ docker images
captaincode latest 21MB
$ docker rmi 61504815c89a // удалить старый образ IMAGE ID = 61504815c89a
24. Подключить контейнеры в одной сети docker (3.2)
$ docker run --name captaincode -p 8080:8080 captaincode:latest
cannot load config:Config File "app" Not Found in "[/app]"
$ docker ps -a
$ docker rm captaincode
$ docker images
$ docker rmi a4809227e909
Dockerfile:
COPY app.env .
$ docker build -t captaincode:latest .
$ docker images
$ docker run --name captaincode -p 8080:8080 captaincode:latest
Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
$ docker rm captaincode
$ docker run --name captaincode -p 8080:8080 -e GIN_MODE=release captaincode:latest
$ docker ps
Postman: "error": "dial tcp 127.0.0.1:5432: connect: connection refused"
Terminal: [GIN] | 500 | 1.437083ms | 192.168.65.1 | POST "/users/login"
app.env: DB_SOURCE=postgresql://root:secret@localhost:5432/main_db?sslmode=disable
$ docker container inspect postgres16
"NetworkSettings": "Networks": "bridge": "IPAddress": "172.17.0.2"
$ docker container inspect captaincode
"NetworkSettings": "Networks": "bridge": "IPAddress": "172.17.0.3"
$ docker stop captaincode
$ docker rm captaincode
$ docker run --name captaincode -p 8080:8080 -e GIN_MODE=release -e "DB_SOURCE=postgresql://root:[email protected]:5432/main_db?sslmode=disable" captaincode:latest
Postman: Status 200 OK
Способ получше (подключиться к контейнеру postgres16 по имени, а не по ip адресу)
$ docker rm captaincode
$ docker network ls
9a00594f4037 bridge bridge local
$ docker network inspect bridge
"Containers": "Name": "postgres16"
// контейнеры в мостовой сети bridge не могут видеть друг друга по имени, как в других сетях
// поэтому нужно создать свою сеть и подключить к ней контейнер
$ docker network --help
$ docker network create ww-network
$ docker network connect --help
$ docker network connect ww-network postgres16
$ docker container inspect postgres16
"Networks":
"bridge": "IPAddress": "172.17.0.2"
"ww-network": "IPAddress": "172.18.0.2"
$ docker run --name captaincode --network ww-network -p 8080:8080 -e GIN_MODE=release -e "DB_SOURCE=postgresql://root:secret@postgres16:5432/main_db?sslmode=disable" captaincode:latest
c mysql это будет так:
для mysql в app.env DB_SOURCE должна быть в кавычках:
DB_SOURCE="mysql://root:secret@tcp(mysql8:3306)/main_db?parseTime=true"
Кроме того при открытии соединения
conn, err := sql.Open(config.DBDriver, dsnDBSource)
переменная подключения dsnDBSource должна быть не в формате URL, а в формате DSN,
т.е. без префикса mysql://
1.
$ docker network create cc-network
2.
$ docker run --name mysql8 -p 3306:3306 -e MYSQL_DATABASE=main_db -e MYSQL_ROOT_PASSWORD=secret -d mysql:8.0
3.
$ docker network connect cc-network mysql8
$ docker network inspect cc-network
$ docker container inspect mysql8
4.
$ migrate -path db/migration -database "mysql://root:secret@tcp(localhost:3306)/main_db" -verbose up
5.
kubuntu:
$ sudo apt-get update
$ sudo apt-get install -y mysql-client
mac:
$ brew install mysql-client
/Users/country/.zshrc:
export PATH=/opt/homebrew/opt/mysql-client/bin:$PATH
export LDFLAGS="-L/opt/homebrew/opt/mysql-client/lib"
export CPPFLAGS="-I/opt/homebrew/opt/mysql-client/include"
export PKG_CONFIG_PATH="/opt/homebrew/opt/mysql-client/lib/pkgconfig"
$ mysql --version
6.
init.sql - для создания пользователя и назначения ему прав
$ mysql -u root -p -h localhost -P 3306 --protocol=TCP main_db --password="secret" < "./init.sql"
7.
$ mysql -u mysqluser -p -h localhost -P 3306 --protocol=TCP main_db --password="secretpass"
SHOW GRANTS;
\q
8.
$ mysql -u root -p -h localhost -P 3306 --protocol=TCP main_db --password="secret"
SELECT user, host FROM mysql.user WHERE user = 'mysqluser';
SHOW GRANTS FOR 'mysqluser'@'%';
или удалить пользователя DROP USER 'mysqluser'@'%';
9.
$ docker build -t captaincode:latest . // ubuntu 244 Mb, alpine 158 Mb
10.
$ docker run --name captaincode --network cc-network -p 8080:8080 -e "DB_SOURCE=mysql://mysqluser:secretpass@tcp(mysql8:3306)/main_db?parseTime=true" captaincode:latest
11.
$ docker exec -it captaincode sh
printenv // вывод подключенных переменных
mysql -u root -p -h mysql8 main_db --password="secret"
SELECT user, host FROM mysql.user WHERE user = 'mysqluser';
SHOW GRANTS FOR 'mysqluser'@'%';
\q
mysql -u mysqluser -p -h mysql8 main_db --password="secretpass"
SHOW GRANTS;
\q
exit
12.
$ docker network inspect cc-network
$ docker container inspect captaincode
13.
POST http://localhost:8080/users
Content-Type: application/json
{
"username": "QuangBang",
"password": "secret",
"full_name": "Quang Bang",
"email": "[email protected]"
}
14.
$ docker logs captaincode
15.
$ docker exec -it captaincode sh
env | grep DATABASE
к команде можно добавить e GIN_MODE=release
MySQL_Allow_Empty_Password
Postman: Status 200 OK
Terminal: [GIN] | 200 | 101.980875ms | 192.168.65.1 | POST "/users/login"
$ docker network inspect ww-network
"Containers":
"Name": "postgres16"
"Name": "captaincode",
Makefile
run-postgres: ## Run postgresql database docker image.
docker run --name postgres16 --network ww-network -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=secret -d postgres:16-alpine
25. Файл docker-compose (3.3)
Docker Compose для автоматической настройки всех служб
docker-compose.yaml // .yaml очень чувствителен к пробелам, устанавливаем 2 пробела для Tab Size
$ docker compose up
$ docker images
$ docker ps
$ docker network inspect ww-network
Postman: Status: 500 Internal Server Error
{
"error": "pq: relation \"users\" does not exist"
}
// Потому как не было миграции
Допиливаем Dockerfile для добавления миграции:
# Build stage
RUN apk add curl
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
# Run stage
COPY --from=builder /app/migrate ./migrate
COPY start.sh .
COPY db/migration ./migration
ENTRYPOINT [ "/app/start.sh" ]
Создаем файл start.sh
$ chmod +x start.sh // делаем его исполняемым
start.sh:
#!/bin/sh
set -e
echo "run db migration"
source /app/app.env
/app/migrate -path /app/migration -database "$DB_SOURCE" -verbose up
echo "start the app"
exec "$@"
$ docker compose down // удалит все контейнеры и сети
$ docker image ls
$ docker rmi api
$ docker network ls
$ docker compose up
!!! При запуске Docker Compose на Linux (Kubuntu) переменную $DB_SOURCE
он взял не из docker-compose.yaml, где к адресу БД обращение по имени postgres,
а из файла app.env, где адрес указан localhost.
api | error: dial tcp 127.0.0.1:5432: connect: connection refused
Пришлось в app.env внести изменения:
# DB_SOURCE=postgresql://root:secret@localhost:5432/main_db?sslmode=disable
DB_SOURCE=postgresql://root:secret@postgres:5432/main_db?sslmode=disable
для mysql нужно в кавычках:
DB_SOURCE="mysql://root:secret@tcp(mysql8:3306)/main_db?parseTime=true"
для прохождения тестов для mysql нужно в формате DSN - без префикса mysql://
DB_SOURCE=root:secret@tcp(localhost:3306)/main_db?parseTime=true
[GIN] | 200 | 144.777766ms | 172.20.0.1 | POST "/users"
[GIN] | 200 | 123.707554ms | 172.20.0.1 | POST "/users/login"
Deploy на сервер
Создаем .github/workflows/deploy.yml
Варианты рабочих деплоев:
doc/deploy_ubuntu-latest.yml
doc/deploy_cont_ubuntu.13.04.yml
doc/deploy_cont_centos7.yml
SSH ключ для подключения deploy к серверу reg.ru
$ ssh-keygen -t rsa -b 4096 -C "[email protected]"
$ ssh-copy-id [email protected]
~/.ssh/authorized_keys на вашем сервере
GitHub -> Repositories -> Settings -> Secrets and variables -> Actions, SSH_PRIVATE_KEY.
Миграции на сервере reg.ru
$ ssh -t [email protected]
mysql --version
migrate -version
go version
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-amd64.tar.gz | tar xvz
set -o allexport; source ~/app/app.env; set +o allexport
echo "DATABASE_URL: $DATABASE_URL"
ls -la ~/app/db/migration
ps aux | grep api_app
Go и GLIBC 2.17 на сервере reg.ru
wget https://go.dev/dl/go1.22.4.linux-amd64.tar.gz
sha256sum go1.22.4.linux-amd64.tar.gz
tar -C ~/usr/local -xzf go1.22.4.linux-amd64.tar.gz
vi $HOME/.profile
export PATH=$PATH:/usr/local/go/bin
source $HOME/.profile
go version
go tool dist list
go env GOOS GOARCH
-bash-4.2$ go env GOOS GOARCH
linux
amd64
ldd --version
ldd ~/app/api_app
$ GOOS=linux GOARCH=amd64 go build
-bash-4.2$ ldd --version
ldd (GNU libc) 2.17
-bash-4.2$ ldd ~/app/api_app
ENV на сервере reg.ru
$ ssh -t [email protected]
mysql --version
migrate -version
go version
go env GOOS GOARCH
ldd --version
ldd ~/app/api_app
source ~/app/app.env
echo $SERVER_ADDRESS
cd ~/app
./api_app
ps aux | grep api_app // показывает работающий процесс api_app и процесс grep который ищет api_app
ps aux | grep api_app | grep -v grep // показывает только работающий процесс api_app
pkill api_app || true // останавливает процесс
lsof -i: 80 | grep LISTEN // для работы нужно установить lsof
netstat -tulpn | grep :8080 // показывает процесс, который использует порт 8080, если есть root права
Скачивание, распаковка и установка GLIBC 2.17
- name: Install GLIBC 2.17
run: |
sudo apt-get update
sudo apt-get install -y wget build-essential
wget http://ftp.gnu.org/gnu/libc/glibc-2.17.tar.gz
tar -xvf glibc-2.17.tar.gz
cd glibc-2.17
mkdir build
cd build
../configure --prefix=/opt/glibc-2.17
make -j$(nproc)
sudo make install
Запуск приложения на сервере в фоновом режиме
Этот команда запустит api_app в фоновом режиме, перенаправит вывод в файл api_app.log
и освободит консоль. Процесс будет продолжать работать даже после закрытия консоли
nohup ./api_app &> api_app.log &
screen — это оконный менеджер, который позволяет запускать и контролировать несколько терминалов из одной консоли. Вы можете использовать screen для запуска приложения и отделения его от текущей сессии.
sudo yum install screen
screen -S myapp
./api_app
Для отделения от сессии screen, нажмите Ctrl+A, затем D.
Чтобы снова подключиться к сессии:
screen -r myapp
tmux — это другой оконный менеджер, который можно использовать аналогично screen.
sudo yum install tmux
tmux new -s myapp
./api_app
Для отделения от сессии tmux, нажмите Ctrl+B, затем D.
Чтобы снова подключиться к сессии:
tmux attach -t myapp
Использование systemd Если вы хотите запустить ваше приложение как сервис, вы можете создать systemd юнит. Это предпочтительный метод для длительно работающих процессов.
Создайте файл юнита:
sudo nano /etc/systemd/system/api_app.service
[Unit]
Description=API Application
[Service]
ExecStart=./api_app
WorkingDirectory=~/app
Restart=always
[Install]
WantedBy=multi-user.target
Замените /path/to/your на реальный путь к вашему приложению.
Затем выполните следующие команды для управления сервисом:
sudo systemctl daemon-reload
sudo systemctl start api_app.service
sudo systemctl enable api_app.service
Теперь ваше приложение будет запускаться при старте системы и работать в фоновом режиме.
Сгенерируем ключ-строку из 32 символов:
$ openssl rand -hex 64
// строка 128 символов
$ openssl rand -hex 64 | head -c 32
// строка 32 символа
PS
Собрать все зависимости из go.mod
$ go mod tidy