آموزش زبان برنامه‌ نویسی Go: ساخت یک سرور چت به زبان ساده

آخرین به‌روزرسانی: ۹ آبان ۱۳۹۷
زمان مطالعه: ۶ دقیقه

اغلب برنامه‌نویسان از زبان Go در موارد متفاوتی بهره می‌گیرند. اما با صرف زمانی برای یادگیری بهتر این زبان برنامه‌نویسی و همچنین آشنایی بیشتر با این سیستم برنامه‌نویسی که به صورت توزیع یافته است، می‌توان استفاده‌های بیشتری از آن داشت. سرور چت یکی از ایده‌هایی است که می‌توان با استفاده از زبان برنامه‌نویسی Go پیاده‌سازی کرد. با این که این یک کاربرد ساده است؛ اما به عنوان یک پروژه مقدماتی نیز به قدر کافی پیچیده محسوب می‌شود.

در این نوشته یک سرور چت را از صفر تا صد طراحی می‌کنیم. کد منبع پروژه‌ای که در این راهنما معرفی می‌شود را می‌توانید در این مخزن گیت‌هاب (+) مشاهده کنید.

پیش‌نیازها

طراحی ما ویژگی‌های بسیار ساده‌ای دارد:

  • یک اتاق چت منفرد وجود دارد.
  • کاربر می‌تواند به سرور وصل شود.
  • کاربر می‌تواند نام خود را تعیین کند.
  • کاربر می‌تواند پیامی را به اتاق بفرستد و پیام برای همه کاربران اتاق منتشر می‌شود.

در سطح فعلی نیازی به حفظ پیام‌ها وجود ندارد و کاربر تنها در صورتی پیام‌ها را مشاهده می‌کند که به اتاق متصل شده باشد.

پروتکل‌ها

برای این نمونه اولیه ارتباط بین سرور و کلاینت از طریق TCP با استفاده از یک پروتکل رشته‌ای ساده صورت خواهد گرفت. امکان استفاده از بسته rpc نیز وجود داشت؛ اما بهتر است از TCP استفاده کنیم، چون اکثر برنامه‌نویسان مبتدی به ندرت به طور مستقیم با شبکه سر و کار پیدا می‌کنند.

در این پروتکل موارد زیر پیاده‌سازی می‌شود:

  • دستور ارسال: کلاینت یک پیام چت ارسال می‌کند.
  • دستور نام: کلاینت نام خود را تنظیم می‌کند.
  • دستور پیام: سرور پیام چت را به همه اعضا انتشار می‌دهد.

منظور از دستور، رشته‌ای است که با نام دستور آغاز می‌شود، همه پارامترها را دارد و با n\ خاتمه می‌باید.

برای نمونه برای ارسال پیام «hello»، کلاینت دستور SEND Hello\n را روی سوکت TCP ارسال می‌کند و سپس سرور، دستور MESSAGE username Hello\n را به کلاینت‌های دیگر می‌فرستد.

همه پیام‌ها را می‌توان با استفاده از ساختارهای Golang تعریف کرد:

// SendCommand is used for sending new message from client
type SendCommand struct {
   Message string
}
// NameCommand is used for setting client display name
type NameCommand struct {
    Name string
}
// MessageCommand is used for notifying new messages
type MessageCommand struct {
    Name    string
    Message string
}

می‌توان یک reader پیاده‌سازی کرد تا دستور را از رشته تجزیه کند و یک متد writer نوشت که دستور را مجدداً به صورت رشته دربیاورد. زبان برنامه‌نویسی Go از io.Reader و io.Writer به عنوان اینترفیس‌های داخلی استفاده می‌کند و از این رو پیاده‌سازی این متدها لزوماً اطلاع ندارد که این متدها برای جریان TCP استفاده می‌شوند.

متد writer کاملاً ساده است. switch/type بسیار خوب عمل می‌کند.

type CommandWriter struct {
   writer io.Writer
}
func NewCommandWriter(writer io.Writer) *CommandWriter {
   return &CommandWriter{
      writer: writer,
   }
}
func (w *CommandWriter) writeString(msg string) error {
    _, err := w.writer.Write([]byte(msg))
    return err
}
func (w *CommandWriter) Write(command interface{}) error {
    // naive implementation ...
    var err error
   switch v := command.(type) {
     case SendCommand:
       err = w.writeString(fmt.Sprintf("SEND %v\n", v.Message))
     case MessageCommand:
       err = w.writeString(fmt.Sprintf("MESSAGE %v %v\n", v.Name, v.Message))
     case NameCommand:
       err = w.writeString(fmt.Sprintf("NAME %v\n", v.Name))
     default:
       err = UnknownCommand
   }
   return err
}

متد reader کمی طولانی‌تر است و تقریباً نیمی از کد آن به مدیریت خطا اختصاص دارد. زمانی که با این متد مواجه می‌شوید، شاید با خود فکر کنید که مدیریت خطا در زبان‌های دیگر تا چه حد آسان است.

type CommandReader struct {
   reader *bufio.Reader
}
func NewCommandReader(reader io.Reader) *CommandReader {
   return &CommandReader{
      reader: bufio.NewReader(reader),
   }
}
func (r *CommandReader) Read() (interface{}, error) {
   // Read the first part
   commandName, err := r.reader.ReadString(' ')
   if err != nil {
      return nil, err
   }
   switch commandName {
     case "MESSAGE ":
       user, err := r.reader.ReadString(' ')
       if err != nil {
         return nil, err
       }
       message, err := r.reader.ReadString('\n')
       if err != nil {
         return nil, err
       }
      return MessageCommand{
         user[:len(user)-1],
         message[:len(message)-1],
      }, nil
    // similar implementation for other commands
     default:
       log.Printf("Unknown command: %v", commandName)
   }
   return nil, UnknownCommand
}

برای مطالعه بیشتر در این خصوص می‌توانید به منابع reader.go و writer.go مراجعه کنید.

سرور

در این بخش کار خود را با سرور چت آغاز می‌کنیم. اینترفیس سرور را می‌توان به صورت زیر تعریف کرد. ممکن است فکر کنید شاید بهتر باشد در ابتدای کار از تعریف اینترفیس آغاز نکنیم؛ اما این وضعیت مناسب‌تر است، چون باعث می‌شود تعریف رفتارها روشن‌تر باشد.

type ChatServer interface {
    Listen(address string) error
    Broadcast(command interface{}) error
    Start()
    Close()
}

سرور با استفاده از متد ()Listen به اتصال‌های ورودی گوش می‌دهد و متدهای ()start و ()Close به ترتیب برای آغاز و متوقف کردن سرور هستند و متد ()BroadCast نیز برای ارسال دستور به کلاینت‌های دیگر استفاده می‌شود.

اینک پیاده‌سازی واقعی سرور را می‌توانید مشاهده کنید. این پیاده‌سازی کاملاً سرراست است. در واقع یک سازه client اضافه شده است تا کلاینت‌ها و نام آن‌ها را پیگیری کند.

type TcpChatServer struct {
    listener net.Listener
    clients []*client
    mutex   *sync.Mutex
}
type client struct {
    conn   net.Conn
    name   string
    writer *protocol.CommandWriter
}
func (s *TcpChatServer) Listen(address string) error {
    l, err := net.Listen("tcp", address)
    if err == nil {
        s.listener = l
     }
     log.Printf("Listening on %v", address)
    return err
}
func (s *TcpChatServer) Close() {
    s.listener.Close()
}
func (s *TcpChatServer) Start() {
    for {
        // XXX: need a way to break the loop
        conn, err := s.listener.Accept()
        if err != nil {
            log.Print(err)
        } else {
           // handle connection
           client := s.accept(conn)
           go s.serve(client)
        }
    }
}

زمانی که سرور اتصال را می‌پذیرد، یک سازه client ایجاد می‌کند تا کلاینت‌ها را پیگیری کند. در ای زمان باید از mutex استفاده کنیم تا از شرایط رقابت جلوگیری کنیم. Goroutine همه مشکلات را حل نمی‌کند و همچنان باید همه شرایط رقابت را به صورت دستی رفع کنیم.

func (s *TcpChatServer) accept(conn net.Conn) *client {
    log.Printf("Accepting connection from %v, total clients: %v", conn.RemoteAddr().String(), len(s.clients)+1)
    s.mutex.Lock()
    defer s.mutex.Unlock()
    client := &client{
        conn:   conn,
        writer: protocol.NewCommandWriter(conn),
    }
    s.clients = append(s.clients, client)
    return client
}
func (s *TcpChatServer) remove(client *client) {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    // remove the connections from clients array
    for i, check := range s.clients {
        if check == client {
            s.clients = append(s.clients[:i], s.clients[i+1:]...)
        }
    }
    log.Printf("Closing connection from %v", client.conn.RemoteAddr().String())
    client.conn.Close()
}

متد اصلی serve برای خواندن پیام از کلاینت و مدیریت متناسب هر پیام است. از آنجا که دو متد خواندن و نوشتن پروتکل داریم، سرور صرفاً باید در سطوح بالا با پیام‌ها تعامل کند و نیازی به ارتباط با جریان بایت وجود ندارد. اگر سرور دستور SendCommand را دریافت کند، تنها کاری که انجام می‌دهد این است که پیام را به همه کلاینت‌های دیگر انتشار می‌دهد.

func (s *TcpChatServer) serve(client *client) {
    cmdReader := protocol.NewCommandReader(client.conn)
    defer s.remove(client)
    for {
        cmd, err := cmdReader.Read()
        if err != nil && err != io.EOF {
            log.Printf("Read error: %v", err)
        }
        if cmd != nil {
            switch v := cmd.(type) {
            case protocol.SendCommand:
                go s.Broadcast(protocol.MessageCommand{
                    Message: v.Message,
                    Name:    client.name,
                })
            case protocol.NameCommand:
                client.name = v.Name
            }
        }
        if err == io.EOF {
            break
        }
    }
}

و در نهایت متد ()Broadcast به صورت زیر است:

func (s *TcpChatServer) Broadcast(command interface{}) error {
    for _, client := range s.clients {
        // TODO: handle error here?
        client.writer.Write(command)
    }
    return nil
}

سرور را می‌توان به سادگی با دستورهای زیر راه‌اندازی کرد:

var s server.ChatServer
s = server.NewServer()
s.Listen(":3333")
// start the server
s.Start()

کد کامل منبع سرور را می‌توانید در این لینک (+) مشاهده کنید.

کلاینت

در زمینه کلاینت کار خود را با اینترفیس به صورت زیر آغاز می‌کنیم:

type ChatClient interface {
    Dial(address string) error
    Send(command interface{}) error
    SendMessage(message string) error
    SetName(name string) error
    Start()
    Close()
    Incoming() chan protocol.MessageCommand
}

کلاینت می‌تواند با استفاده از متد ()Dial به سرور وصل شود و متدهای ()Start و ()Close برای آغاز و متوقف کردن کلاینت هستند. ()Send برای ارسال دستور به سرور استفاده می‌شوند و ()SetName و ()SendMessage متدهای پوششی برای تعیین نام نمایشی و ارسال پیام چت هستند. در نهایت متد ()Incoming یک کانال برای بازیابی پیام‌های چت از سرور بازمی‌گرداند.

سازه کلاینت و سازنده آن را می‌توان به صورت زیر تعریف کرد. کد زیر برخی متغیرهای خصوصی دارد که conn را برای اتصال‌ها در نظر می‌گیرد و reader/writer به عنوان یک سازه پوششی برای ارسال دستورها استفاده می‌شوند.

type TcpChatClient struct {
    conn      net.Conn
    cmdReader *protocol.CommandReader
    cmdWriter *protocol.CommandWriter
    name      string
    incoming  chan protocol.MessageCommand
}
func NewClient() *TcpChatClient {
   return &TcpChatClient{
       incoming: make(chan protocol.MessageCommand),
   }
}

اکثر متدها کاملاً ساده هستند. Dial اتصالی با سرور ایجاد می‌کند و سپس reader و writer پروتکل ایجاد می‌شود.

func (c *TcpChatClient) Dial(address string) error {
    conn, err := net.Dial("tcp", address)
    if err == nil {
        c.conn = conn
    }
    c.cmdReader = protocol.NewCommandReader(conn)
    c.cmdWriter = protocol.NewCommandWriter(conn)
    return err
}

سپس متد ()Send از cmdWriter برای ارسال دستور به سرور استفاده می‌کند.

func (c *TcpChatClient) Send(command interface{}) error {
   return c.cmdWriter.Write(command)
}

متدهای دیگر بسیار ساده هستند و نیازی به توضیح وجود ندارد. مهم‌ترین متد کلاینت، متد ()Start است که به پیام‌های ورودی گوش می‌دهد و سپس آن را به کانال بازمی‌گرداند.

func (c *TcpChatClient) Start() {
  for {
      cmd, err := c.cmdReader.Read()
      if err == io.EOF {
          break
      } else if err != nil {
          log.Printf("Read error %v", err)
      }
      if cmd != nil {
         switch v := cmd.(type) {
         case protocol.MessageCommand:
            c.incoming <- v
         default:
            log.Printf("Unknown command: %v", v)
        }
      }
   }
}

کد منبع کلاینت را می‌توانید در این لینک (+) مشاهده کنید.

رابط کاربری مبتنی بر متن (TUI)

با این که کدهای فوق کاملاً کاربردی هستند؛ اما مشاهده همه چیز در عمل کار دشواری محسوب می‌شود. بنابراین باید یک رابط کاربری (UI) برای کلاینت تعریف کنیم. البته اصطلاح رابط کاربری ترمینال برای منظور ما مناسب‌تر است. Go بسته‌های زیادی برای طراحی رابط کاربری ترمینال دارد؛ اما tui-go تنها بسته‌ای است که در حال حاضر از ناحیه متنی پشتیبانی می‌کند و مثال چت (+) مناسبی نیز دارد. کد آن بسیار کوتاه است و نیازی به توضیح ندارد. کد کامل را می‌توانید در این لینک (+) مشاهده کنید.

سخن پایانی

سرور چت Go یک تمرین جالب محسوب می‌شود. این راهنما باعث می‌شود که اطلاعات خود را در مورد برنامه‌نویسی شبکه با TCP مرور کنید و همچنین مواردی را در مورد رابط کاربری ترمینال بیاموزید. در مرحله بعد می‌توانیم ویژگی‌های دیگری مانند اتاق‌های چت چندکاربره با حفظ پیام‌ها و یا حتی مدیریت خطا و تست‌های واحد را نیز به این اپلیکیشن اضافه کنیم.

اگر این مطلب برایتان مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۴ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
nqbao

نظر شما چیست؟

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