源码阅读(4)从单节点模式了解整体流程

57 阅读4分钟

先从单机模式下的example代码来简单了解整个流程。

1、第一步:构建server

c, cancel := jocko.NewTestServer(&testing.T{}, func(cfg *config.Config) {
		cfg.Bootstrap = true
		cfg.BootstrapExpect = 1
		cfg.StartAsLeader = true
	}, nil)
if err := c.Start(context.Background()); err != nil {
	fmt.Fprintf(os.Stderr, "failed to start cluster: %v\n", err)
	os.Exit(1)
}

首先是创建TestServer,这里的cfg配置中设置了集群的节点个数为1,并且这个唯一的节点是leader节点,在集群分析篇,会专门分析这个流程。然后启动这个服务,下面进入启动这个server的代码。

func (s *Server) Start(ctx context.Context) error {
	protocolAddr, err := net.ResolveTCPAddr("tcp", s.config.Addr)
	if err != nil {
		return err
	}
    // 监听指定端口
	if s.protocolLn, err = net.ListenTCP("tcp", protocolAddr); err != nil {
		return err
	}

    // 启动一个goroutine循环接收客户端的连接请求,对requestCh是生产者
	go func() {
		for {
			select {
			... ...
			default:
				conn, err := s.protocolLn.Accept()
				.. ...
				go s.handleRequest(conn)
			}
		}
	}()

    // 启动一个goroutine循环处理服务器对请求的返回结果,对responseCh是消费者
	go func() {
		for {
			select {
			... ...
			case respCtx := <-s.responseCh:
    			... ...
				if err := s.handleResponse(respCtx); err != nil {
                    ... ...
				}
			}
		}
	}()

    // 启动一个goroutine,处理请求并且返回结果,对requestCh是消费者,对responseCh是生产者
	go s.handler.Run(ctx, s.requestCh, s.responseCh)

	return nil
}

服务端使用两个channel和三个goroutine完成对客户端的请求解析、执行以及返回结果。

2、第二步:创建与jocko的链接

客户端代码

conn, err := jocko.Dial("tcp", c.Addr().String())
if err != nil {
    fmt.Fprintf(os.Stderr, "error connecting to broker: %v\n", err)
	os.Exit(1)
}

这里建立的连接是通过Jocko.Dail 进行的,这里可以看到入参形式与go 语言中的net.Dialer API是一样的。

服务端代码

func (d *Dialer) Dial(network, address string) (*Conn, error) {
	return d.DialContext(context.Background(), network, address)
}


func (d *Dialer) DialContext(ctx context.Context, network, address string) (*Conn, error) {
	... ...
    
	c, err := d.dialContext(ctx, network, address)
	if err != nil {
		return nil, err
	}
	return NewConn(c, d.ClientID)
}

func (d *Dialer) dialContext(ctx context.Context, network, address string) (conn net.Conn, err error) {
	... ...
    
	conn, err = (&net.Dialer{
		LocalAddr:     d.LocalAddr,
		FallbackDelay: d.FallbackDelay,
		KeepAlive:     d.KeepAlive,
	}).DialContext(ctx, network, address) // 构建TCP链接

    ... ...
}

通过上面的代码可以看到,这里还有对go语言中的net.Dialer进行包装。,最终返回的是Jocko定义的Conn结构体,下面是该结构体核心内容:

type Conn struct {
    ... ...
	conn          net.Conn
	rbuf          bufio.Reader
	wbuf          bufio.Writer
	clientID      string
    ... ...
}

其中包含net.Conn类型的连接、对TCP连接的读写buf封装。对于一个请求到服务器的连接来说,核心就是解析请求内容、执行请求、返回结果。

3、第三步:发送创建topic的请求

创建完链接之后,客户端发起创建topic的请求

客户端代码

resp, err := conn.CreateTopics(&protocol.CreateTopicRequests{
		Requests: []*protocol.CreateTopicRequest{{
			Topic:             topic,
			NumPartitions:     numPartitions,
			ReplicationFactor: 1,
		}},
	})

请求的参数包含:创建topic的名称,分区个数以及副本因子。下面介绍服务端如何处理这个请求的。

服务端代码

func (c *Conn) CreateTopics(req *protocol.CreateTopicRequests) (*protocol.CreateTopicsResponse, error) {
	var resp protocol.CreateTopicsResponse
	err := c.writeOperation(func(deadline time.Time, id int32) error {
		return c.writeRequest(req)
	}, func(deadline time.Time, size int) error {
		return c.readResponse(&resp, size, req.Version())
	})
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

writeOperation是对写操作的封装,传入两个函数:一个是写操作请求的封装,一个是对写操作结果的返回。

func (c *Conn) writeOperation(write wop, read rop) error {
	return c.do(&c.wdeadline, write, read)
}

func (c *Conn) do(d *connDeadline, write wop, read rop) error {
	id, err := c.doRequest(d, write)
	if err != nil {
		return err
	}
	deadline, size, lock, err := c.waitResponse(d, id)
	if err != nil {
		return err
	}

	if err = read(deadline, size); err != nil {
		switch err.(type) {
		case protocol.Error:
		default:
			c.conn.Close()
		}
	}

	d.unsetConnReadDeadline()
	lock.Unlock()
	return err
}

接下来分别介绍包装的两个函数

1)写操作请求的封装

func (c *Conn) doRequest(d *connDeadline, write wop) (int32, error) {
	c.wlock.Lock()
	c.correlationID++
	id := c.correlationID
	err := write(d.setConnWriteDeadline(c.conn), id)
	d.unsetConnWriteDeadline()
	if err != nil {
		c.conn.Close()
	}
	c.wlock.Unlock()
	return c.correlationID, nil
}

func (c *Conn) writeRequest(body protocol.Body) error {
	req := &protocol.Request{
		CorrelationID: c.correlationID,
		ClientID:      c.clientID,
		Body:          body,
	}

	b, err := protocol.Encode(req)
	if err != nil {
		return err
	}
	_, err = c.wbuf.Write(b)
	if err != nil {
		return err
	}
	return c.wbuf.Flush()
}

在doRequest代码中对写操作请求开始处理,同时设置了链接的超时时间,最终在writeRequest中使用protocol.Encode中对请求进行包装,然后将这个解析的结果放到Conn的bufio.Writer中,该结果就会进入第一步中服务端的sever.start中对请求处理的流程中。

2)写操作结果的返回

func (c *Conn) waitResponse(d *connDeadline, id int32) (deadline time.Time, size int, lock *sync.Mutex, err error) {
	for {
		var rsz int32
		var rid int32

		c.rlock.Lock()
		deadline = d.setConnReadDeadline(c.conn)

		if rsz, rid, err = c.peekResponseSizeAndID(); err != nil {
			d.unsetConnReadDeadline()
			c.conn.Close()
			c.rlock.Unlock()
			return
		}

		if id == rid {
			c.skipResponseSizeAndID()
			size, lock = int(rsz-4), &c.rlock
			return
		}

		c.rlock.Unlock()
		runtime.Gosched()
	}
}

func (c *Conn) readResponse(resp protocol.VersionedDecoder, size int, version int16) error {
	b, err := c.rbuf.Peek(size)
	if err != nil {
		return err
	}
	err = protocol.Decode(b, resp, version)
	c.rbuf.Discard(size)
	return err
}

首先等待返回结果,然后从Conn的bufio.Reader中就进行解析,最终包装成protocol.CreateTopicsResponse 返回。

总结

通过查看单机example创建topic的流程,我们了解了大概的整体流程,这里引出来两个重要结构体Conn和Server。其中Conn是对go net.Conn进行封装,并且对请求的参数进行包装,以及对返回的结果进行解析和返回。Server结构体是对这些结果进行处理以及填充返回结果,两个结构体之间通过go的net.Conn进行交互。