先从单机模式下的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进行交互。