برنامه نویسی

REST API با Go، Chi، MySQL و sqlx

این ادامه پست قبلی REST API با Go، Chi و InMemory Store است. در این آموزش من سرویس را برای ذخیره داده ها در پایگاه داده MySQL گسترش خواهم داد. من از Docker برای اجرای MySQL و اجرای مهاجرت پایگاه داده استفاده خواهم کرد.

راه اندازی پروژه

من با کپی کردن مطالب شروع می کنم https://github.com/kashifsoofi/blog-code-samples/tree/main/movies-api-with-go-chi-and-memory-store، آن را در یک پوشه جدید قرار دهید movies-api-with-go-chi-and-mysql و به روز رسانی نام ماژول در go.mod برای مطابقت با پوشه جدید و به روز رسانی در فایل های منبع که در آن استفاده می شود. این معمولاً ریشه شما خواهد بود git مخزن است و به این شرح مفصل نخواهد بود.

راه اندازی سرور پایگاه داده

من از یک docker-compose برای اجرای MySQL در یک کانتینر docker استفاده خواهم کرد. این به ما امکان می دهد خدمات بیشتری را اضافه کنیم که api استراحت ما به عنوان مثال سرور redis برای کش توزیع شده به آن وابسته است.

بیایید با افزودن یک فایل جدید به نام به عنوان شروع کنیم docker-compose.dev-env.yml، با خیال راحت آن را هر طور که دوست دارید نام ببرید. برای افزودن یک نمونه پایگاه داده برای api استراحت فیلم، محتوای زیر را اضافه کنید.

version: '3.7'

services:
  movies.db:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=Password123
      - MYSQL_DATABASE=moviesdb
    volumes:
      - moviesdbdata:/var/lib/mysql
    ports:
      - "3306:3306"
    healthcheck:
      test: "mysql -uroot -pPassword123 moviesdb -e 'select 1'"
      timeout: 20s
      interval: 10s
      retries: 10

volumes:
  moviesdbdata:
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

یک ترمینال در ریشه راه حل که فایل docker-compose در آن قرار دارد باز کنید و دستور زیر را برای راه اندازی سرور پایگاه داده اجرا کنید.

docker-compose -f docker-compose.dev-env.yml up -d
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

مهاجرت های پایگاه داده

قبل از اینکه بتوانیم استفاده از MySQL را شروع کنیم، باید یک جدول برای ذخیره داده های خود ایجاد کنیم. من از ابزار مهاجرت پایگاه داده عالی استفاده خواهم کرد، همچنین می توان آن را به عنوان کتابخانه وارد کرد.

برای مهاجرت من یک پوشه ایجاد کرده ام db و یک پوشه دیگر به نام migrations زیر db من دستورات زیر را برای ایجاد مهاجرت اجرا کردم.

migrate create -ext sql -dir db/migrations -seq schema_movies_create
migrate create -ext sql -dir db/migrations -seq table_movies_create
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با این کار 4 فایل ایجاد می شود که برای هر مهاجرت یک فایل وجود دارد up و الف down فیلمنامه، up هنگام اعمال مهاجرت و اجرا خواهد شد down زمانی که تغییر را به عقب برگردانید اجرا می شود.

  • 000001_schema_movies_create.up.sql
CREATE SCHEMA IF NOT EXISTS Movies;
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  • 000001_schema_movies_create.down.sql
DROP SCHEMA IF EXISTS Movies;
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  • 000002_table_movies_create.up.sql
CREATE TABLE IF NOT EXISTS Movies (
    Id          CHAR(36)        NOT NULL UNIQUE,
    Title       VARCHAR(100)    NOT NULL,
    Director    VARCHAR(100)    NOT NULL,
    ReleaseDate DATETIME        NOT NULL,
    TicketPrice DECIMAL(12, 4)  NOT NULL,
    CreatedAt   DATETIME        NOT NULL,
    UpdatedAt   DATETIME        NOT NULL,
    PRIMARY KEY (Id)
) ENGINE=INNODB;
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

  • 000002_table_movies_create.down.sql
DROP TABLE IF EXISTS Movies;
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

من معمولاً کانتینری ایجاد می‌کنم که همه مهاجرت‌های پایگاه داده و ابزاری برای اجرای آن مهاجرت‌ها داشته باشد. Dockerfile برای اجرای مهاجرت های پایگاه داده به صورت زیر است

FROM migrate/migrate

# Copy all db files
COPY ./migrations /migrations

ENTRYPOINT [ "migrate", "-path", "/migrations", "-database"]
CMD ["mysql://root:Password123@tcp(movies.db:3306)/moviesdb up"]
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

موارد زیر را اضافه کنید docker-compose.dev-env.yml فایل برای اضافه کردن محفظه مهاجرت و اجرای مهاجرت در هنگام راه اندازی. لطفاً به یاد داشته باشید که اگر مهاجرت‌های جدیدی اضافه کنید، باید کانتینر و را حذف کنید movies.db.migrations تصویر برای افزودن فایل های مهاجرتی جدید به تصویر.

  movies.db.migrations:
    depends_on:
      movies.db:
        condition: service_healthy
    image: movies.db.migrations
    build:
      context: ./db/
      dockerfile: Dockerfile
    command: "'mysql://root:Password123@tcp(movies.db:3306)/moviesdb' up"
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

یک ترمینال را در ریشه پروژه باز کنید که در آن فایل docker-compose مکان است و دستور زیر را برای راه اندازی سرور پایگاه داده و اعمال مهاجرت برای ایجاد اجرا کنید. Movies طرحواره و Movies جدول.

docker-compose -f docker-compose.dev-env.yml up -d
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

فروشگاه فیلم MySQL

من از sqlx برای اجرای پرس و جوها و ستون های نقشه برای ساخت فیلدها و بالعکس استفاده خواهم کرد. sqlx کتابخانه ای است که مجموعه ای از برنامه های افزودنی را به صورت استاندارد ارائه می دهد database/sql کتابخانه

یک فایل جدید به نام اضافه کنید mysql_movies_store.go. یک ساختار جدید اضافه کنید MySqlMoviesStore حاوی databaseUrl و اشاره گر به sqlx.DB، همچنین روش های کمکی را به آن اضافه کنید connect به پایگاه داده و close اتصال نیز. همچنین توجه داشته باشید که من یک را اضافه کرده ام noOpMapper روش و به عنوان MapperFunc از تنظیم کنید sqlx.DB، دلیل این امر استفاده از پوشش مشابه نام فیلد struct است. رفتار پیش فرض برای sqlx نگاشت نام فیلدها به نام ستون ها با حروف کوچک است.

package store

import (
    "context"
    "database/sql"
    "strings"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "github.com/google/uuid"
    "github.com/jmoiron/sqlx"
)

const driverName = "mysql"

type MySqlMoviesStore struct {
    databaseUrl string
    dbx         *sqlx.DB
}

func NewMySqlMoviesStore(databaseUrl string) *MySqlMoviesStore {
    return &MySqlMoviesStore{
        databaseUrl: databaseUrl,
    }
}

func noOpMapper(s string) string { return s }

func (s *MySqlMoviesStore) connect(ctx context.Context) error {
    dbx, err := sqlx.ConnectContext(ctx, driverName, s.databaseUrl)
    if err != nil {
        return err
    }

    dbx.MapperFunc(noOpMapper)
    s.dbx = dbx
    return nil
}

func (s *MySqlMoviesStore) close() error {
    return s.dbx.Close()
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

تگ db را اضافه کنید

به روز رسانی Movie ساختن در movies_store.go فایل برای اضافه کردن برچسب db برای ID فیلد، این به sqlx اجازه می دهد تا نقشه برداری کند ID فیلد برای تصحیح ستون جایگزین برای این استفاده از AS در پرس و جوهای انتخاب کنید یا نام ستون را در پایگاه داده به عنوان تغییر نام دهید ID. با استفاده از تمام فیلدهای دیگر به درستی نگاشت می شوند noOpMapper از قسمت بالا

type Movie struct {
    ID          uuid.UUID `db:"Id"`
    ...
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

متن نوشته

ما از آن استفاده نکردیم Context در نمونه قبلی movies-api-with-go-chi-and-memory-store، اکنون که در حال اتصال به یک حافظه خارجی و بسته هستیم، می خواهیم از روش های پشتیبانی پرس و جو برای پذیرش استفاده کنیم. Context ما خود را به روز خواهیم کرد store.Interface پذیرفتن Context و هنگام اجرای پرس و جو از آن استفاده کنید. store.Interface به شرح زیر به روز خواهد شد

type Interface interface {
    GetAll(ctx context.Context) ([]Movie, error)
    GetByID(ctx context.Context, id uuid.UUID) (Movie, error)
    Create(ctx context.Context, createMovieParams CreateMovieParams) error
    Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error
    Delete(ctx context.Context, id uuid.UUID) error
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

ما همچنین باید به روز رسانی کنیم MemoryMoviesStore روش های پذیرش Context برای ارضای store.Interface و روش های به روز رسانی در movies_handler برای عبور متن درخواست با استفاده از r.Context() هنگام تماس store مواد و روش ها.

ايجاد كردن

ما با استفاده از پایگاه داده متصل می شویم connect روش کمکی، یک نمونه جدید از Movie و insert query را با NamedExecContext. ما در حال رسیدگی به یک error و برگشت DuplicateKeyError اگر خطای برگشتی حاوی متن باشد Error 1062. اگر درج موفقیت آمیز بود، ما برمی گردیم nil.
ایجاد تابع به نظر می رسد

func (s *MySqlMoviesStore) Create(ctx context.Context, createMovieParams CreateMovieParams) error {
    err := s.connect(ctx)
    if err != nil {
        return err
    }
    defer s.close()

    movie := Movie{
        ID:          createMovieParams.ID,
        Title:       createMovieParams.Title,
        Director:    createMovieParams.Director,
        ReleaseDate: createMovieParams.ReleaseDate,
        TicketPrice: createMovieParams.TicketPrice,
        CreatedAt:   time.Now().UTC(),
        UpdatedAt:   time.Now().UTC(),
    }

    if _, err := s.dbx.NamedExecContext(
        ctx,
        `INSERT INTO Movies
            (Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt)
        VALUES
            (:Id, :Title, :Director, :ReleaseDate, :TicketPrice, :CreatedAt, :UpdatedAt)`,
        movie); err != nil {
        if strings.Contains(err.Error(), "Error 1062") {
            return &DuplicateKeyError{ID: createMovieParams.ID}
        }
        return err
    }

    return nil
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

GetAll

ما با استفاده از پایگاه داده متصل می شویم connect روش کمکی، سپس استفاده کنید SelectContext روش از sqlx برای اجرای پرس و جو، sqlx ستون ها را به فیلدها نگاشت می کرد. اگر پرس و جو موفقیت آمیز باشد، برشی از فیلم های بارگذاری شده را برمی گردانیم.

func (s *MySqlMoviesStore) GetAll(ctx context.Context) ([]Movie, error) {
    err := s.connect(ctx)
    if err != nil {
        return nil, err
    }
    defer s.close()

    var movies []Movie
    if err := s.dbx.SelectContext(
        ctx,
        &movies,
        `SELECT
            Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt
        FROM Movies`); err != nil {
        return nil, err
    }

    return movies, nil
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اگر در تجزیه خطایی وجود دارد DATETIME ستون، به یاد داشته باشید که اضافه کنید parseTime=true پارامتر رشته اتصال شما

GetByID

ما با استفاده از پایگاه داده متصل می شویم connect روش کمکی، سپس استفاده کنید GetContext روش اجرای پرس و جو انتخاب، sqlx ستون ها را به فیلدها نگاشت می کرد. اگر راننده برگردد sql.ErrNoRows سپس برمی گردیم store.RecordNotFoundError. اگر با موفقیت بارگذاری شد movie رکورد برگردانده می شود.

func (s *MySqlMoviesStore) GetByID(ctx context.Context, id uuid.UUID) (Movie, error) {
    err := s.connect(ctx)
    if err != nil {
        return Movie{}, err
    }
    defer s.close()

    var movie Movie
    if err := s.dbx.GetContext(
        ctx,
        &movie,
        `SELECT
            Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt
        FROM Movies
        WHERE Id = ?`,
        id); err != nil {
        if err != sql.ErrNoRows {
            return Movie{}, err
        }

        return Movie{}, &RecordNotFoundError{}
    }

    return movie, nil
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

به روز رسانی

ما با استفاده از پایگاه داده متصل می شویم connect روش کمکی، سپس استفاده کنید NamedExecContext روشی برای اجرای پرس و جو برای به روز رسانی یک رکورد موجود.

func (s *MySqlMoviesStore) Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error {
    err := s.connect(ctx)
    if err != nil {
        return err
    }
    defer s.close()

    movie := Movie{
        ID:          id,
        Title:       updateMovieParams.Title,
        Director:    updateMovieParams.Director,
        ReleaseDate: updateMovieParams.ReleaseDate,
        TicketPrice: updateMovieParams.TicketPrice,
        UpdatedAt:   time.Now().UTC(),
    }

    if _, err := s.dbx.NamedExecContext(
        ctx,
        `UPDATE Movies
        SET Title = :Title, Director = :Director, ReleaseDate = :ReleaseDate, TicketPrice = :TicketPrice, UpdatedAt = :UpdatedAt
        WHERE Id = :Id`,
        movie); err != nil {
        return err
    }

    return nil
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

حذف

ما با استفاده از پایگاه داده متصل می شویم connect روش helper، سپس query را برای حذف یک رکورد موجود با استفاده از آن اجرا کنید ExecContext.

func (s *MySqlMoviesStore) Delete(ctx context.Context, id uuid.UUID) error {
    err := s.connect(ctx)
    if err != nil {
        return err
    }
    defer s.close()

    if _, err := s.dbx.ExecContext(
        ctx,
        `DELETE FROM Movies
        WHERE id = ?`, id); err != nil {
        return err
    }

    return nil
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

پیکربندی پایگاه داده

یک ساختار جدید به نام اضافه کنید Database که در config.go و آن را به Configuration ساختار نیز.

type Configuration struct {
    HTTPServer
    Database
}
...
type Database struct {
    DatabaseURL        string `envconfig:"DATABASE_URL" required:"true"`
    LogLevel           string `envconfig:"DATABASE_LOG_LEVEL" default:"warn"`
    MaxOpenConnections int    `envconfig:"DATABASE_MAX_OPEN_CONNECTIONS" default:"10"`
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

تزریق وابستگی

به روز رسانی main.go به صورت زیر برای ایجاد یک نمونه جدید از MySqlMoviesStore، من تصمیم به ایجاد نمونه ای از MySqlMoviesStore بجای MemoryMoviesStore، راه حل را می توان برای ایجاد یکی از وابستگی ها بر اساس یک پیکربندی تقویت کرد.

// store := store.NewMemoryMoviesStore()
store := store.NewMySqlMoviesStore(cfg.DatabaseURL)
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

تست

من هیچ واحد یا تست ادغام را برای این آموزش اضافه نمی کنم، شاید یک آموزش زیر. اما تمام نقاط پایانی را می توان با استفاده از Postman با پیروی از طرح آزمایشی مقاله قبلی آزمایش کرد.

شما می توانید rest api را با اجرای mysql در docker با اجرای زیر شروع کنید

DATABASE_URL=root:Password123@tcp(localhost:3306)/moviesdb?parseTime=true go run main.go
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

منبع

کد منبع برای برنامه آزمایشی در GitHub در مخزن نمونه کدهای وبلاگ میزبانی می شود.

منابع

بدون ترتیب خاصی

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا