Let's Go Further خاموشی محترمانه › رهگیری سیگنال‌های خاموش شدن
قبلی · فهرست مطالب · بعدی
فصل ۱۱.۲.

رهگیری سیگنال‌های خاموش شدن

پیش از اینکه وارد جزئیات نحوه رهگیری سیگنال‌ها شویم، بیایید کد مربوط به http.Server خود را از تابع main() به یک فایل جداگانه منتقل کنیم. این کار یک نقطه شروع تمیز و شفاف در اختیارمان قرار می‌دهد تا بتوانیم قابلیت خاموشی محترمانه را بر پایه آن پیاده‌سازی کنیم.

اگر می‌خواهید همراه با ما پیش بروید، یک فایل جدید cmd/api/server.go بسازید:

$ touch cmd/api/server.go

و سپس یک متد جدید app.serve() اضافه کنید که http.Server ما را راه‌اندازی و شروع می‌کند، به این صورت:

فایل: cmd/api/server.go
package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "time"
)

func (app *application) serve() error {
    // Declare an HTTP server using the same settings as in our main() function.
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", app.config.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
    }

    // Likewise log a "starting server" message.
    app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)

    // Start the server as normal, returning any error.
    return srv.ListenAndServe()
}

با وجود این کد، می‌توانیم تابع main() خود را ساده کنیم تا از این متد جدید app.serve() استفاده کند، به این صورت:

فایل: cmd/api/main.go
package main

import (
    "context"
    "database/sql"
    "flag"
    "log/slog"
    "os"
    "time"

    "greenlight.alexedwards.net/internal/data"

    _ "github.com/lib/pq"
)

...

func main() {
    var cfg config

    flag.IntVar(&cfg.port, "port", 4000, "API server port")
    flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

    flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("GREENLIGHT_DB_DSN"), "PostgreSQL DSN")

    flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
    flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
    flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

    flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
    flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
    flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")

    flag.Parse()

    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    db, err := openDB(cfg)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
    defer db.Close()

    logger.Info("database connection pool established")

    app := &application{
        config: cfg,
        logger: logger,
        models: data.NewModels(db),
    }

    // Call app.serve() to start the server.
    err = app.serve()
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
}

...

گرفتن سیگنال‌های SIGINT و SIGTERM

در مرحله بعد، می‌خواهیم برنامه خود را به‌گونه‌ای به‌روزرسانی کنیم که هر سیگنال SIGINT و SIGTERM را «بگیرد». همان‌طور که در بالا ذکر کردیم، سیگنال‌های SIGKILL قابل گرفتن نیستند (و همیشه باعث فوراً خاتمه یافتن برنامه می‌شوند) و SIGQUIT را با رفتار پیش‌فرض خود باقی می‌گذاریم (زیرا اگر بخواهید از طریق میانبر صفحه‌کلید خاموشی غیرمحترمانه اجرا کنید، مفید است).

برای گرفتن سیگنال‌ها، باید یک گوروتین پس‌زمینه راه‌اندازی کنیم که در طول عمر برنامه اجرا شود. در این گوروتین پس‌زمینه، می‌توانیم از تابع signal.Notify() استفاده کنیم تا سیگنال‌های خاصی را گوش داده و آن‌ها را به یک کانال برای پردازش بیشتر ارسال کنیم.

اجازه دهید نشان دهم.

فایل cmd/api/server.go را باز کنید و آن را به این صورت به‌روزرسانی کنید:

فایل: cmd/api/server.go
package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"        // New import
    "os/signal" // New import
    "syscall"   // New import
    "time"
)

func (app *application) serve() error {
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", app.config.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
    }

    // Start a background goroutine.
    go func() {
        // Create a quit channel which carries os.Signal values.
        quit := make(chan os.Signal, 1)

        // Use signal.Notify() to listen for incoming SIGINT and SIGTERM signals and 
        // relay them to the quit channel. Any other signals will not be caught by
        // signal.Notify() and will retain their default behavior.
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

        // Read the signal from the quit channel. This code will block until a signal is
        // received.
        s := <-quit

        // Log a message to say that the signal has been caught. Notice that we also
        // call the String() method on the signal to get the signal name and include it
        // in the log entry attributes.
        app.logger.Info("caught signal", "signal", s.String())

        // Exit the application with a 0 (success) status code.
        os.Exit(0)
    }()

    // Start the server as normal.
    app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)

    return srv.ListenAndServe()
}

در حال حاضر این کد جدید کار زیادی انجام نمی‌دهد — پس از رهگیری سیگنال، فقط یک پیام ثبت می‌کنیم و سپس برنامه را خاتمه می‌دهیم. اما نکته مهم این است که الگوی نحوه گرفتن سیگنال‌های خاص و رسیدگی به آن‌ها در کد را نشان می‌دهد.

یک نکته‌ای که می‌خواهم به‌سرعت در مورد آن تأکید کنم: کانال quit ما یک کانال بافردار با اندازه ۱ است.

ما باید از یک کانال بافردار استفاده کنیم زیرا signal.Notify() منتظر نمی‌ماند تا گیرنده‌ای در دسترس باشد وقتی سیگنالی را به کانال quit ارسال می‌کند. اگر به جای آن از یک کانال معمولی (بدون بافر) استفاده کرده بودیم، ممکن بود سیگنالی «از دست برود» اگر کانال quit ما در لحظه‌ای که سیگنال ارسال می‌شود آماده دریافت نباشد. با استفاده از یک کانال بافردار، این مشکل را حل می‌کنیم و مطمئن می‌شویم که هرگز سیگنالی را از دست نمی‌دهیم.

خب، بیایید آن را امتحان کنیم.

اول، برنامه را اجرا کنید و سپس Ctrl+C را روی صفحه‌کلید خود فشار دهید تا یک سیگنال SIGINT ارسال شود. باید یک لاگ با عنوان "caught signal" با "signal":"interrupt" در ویژگی‌ها ببینید، مشابه این:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.345+02:00 level=INFO msg="caught signal" signal=interrupt

همچنین می‌توانید برنامه را مجدداً راه‌اندازی کنید و ارسال یک سیگنال SIGTERM را امتحان کنید. این بار، ویژگی‌های لاگ باید حاوی signal=terminated باشد، به این صورت:

$ pkill -SIGTERM api
$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.345+02:00 level=INFO msg="caught signal" signal=terminated

در مقابل، ارسال سیگنال SIGKILL یا SIGQUIT همچنان باعث خروج فوری برنامه بدون رهگیری سیگنال می‌شود، بنابراین نباید پیام "caught signal" را در لاگ‌ها ببینید. به عنوان مثال، اگر برنامه را مجدداً راه‌اندازی کنید و یک SIGKILL ارسال کنید…

$ pkill -SIGKILL api

برنامه باید فوراً خاتمه یابد و لاگ‌ها به این صورت خواهند بود:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
signal: killed