Explicación detallada del modelo de comunicación seata-golang del marco de transacciones distribuidas

Head picture.png

Autor | Liu Xiaomin Yu Yu

1. Introducción

En el mundo Java, netty es un marco de comunicación de red de alto rendimiento ampliamente utilizado por todos. Muchos marcos de RPC se implementan basándose en netty. En el mundo de golang, getty es también una biblioteca de comunicación en red de alto rendimiento similar a netty. getty fue desarrollado originalmente por Yu Yu, la persona a cargo del proyecto dubbogo, y utilizado en dubbo-go como biblioteca de comunicación subyacente . Con la donación de dubbo-go a la Fundación Apache, con el esfuerzo conjunto de los socios de la comunidad, getty finalmente ingresó a la familia apache y pasó a llamarse dubbo-getty .

En 18 años, cuando practicaba microservicios en la empresa, el mayor problema que encontré fue el problema de las transacciones distribuidas. En el mismo año, Ali abrió sus soluciones de transacciones distribuidas en la comunidad y rápidamente seguí este proyecto. Al principio, se llamaba fescar, y luego cambió su nombre a seata. Como estoy muy interesado en la tecnología de código abierto, agregué muchos grupos comunitarios, en ese momento también presté mucha atención al proyecto dubbo-go y me sumergí silenciosamente en él. Con la comprensión de seata, surgió gradualmente la idea de hacer una versión go de un marco de transacciones distribuidas.

Para hacer una versión golang de un marco de transacciones distribuidas, la primera pregunta es cómo implementar la comunicación RPC. Dubbo-go es un muy buen ejemplo, así que comencé a estudiar el getty subyacente de dubbo-go.

2. Cómo implementar la comunicación RPC basada en getty

El diagrama de modelo general del marco getty es el siguiente:

1.png

A continuación se describe el proceso de comunicación RPC de seata-golang junto con los códigos relacionados.

1. Establecer una conexión

Para realizar la comunicación RPC, primero debemos establecer una conexión de red. Comencemos con client.go .

func (c *client) connect() {
    var (
        err error
        ss  Session
    )

    for {
        // 建立一个 session 连接
        ss = c.dial()
        if ss == nil {
            // client has been closed
            break
        }
        err = c.newSession(ss)
        if err == nil {
            // 收发报文
            ss.(*session).run()
            // 此处省略部分代码

            break
        }
        // don't distinguish between tcp connection and websocket connection. Because
        // gorilla/websocket/conn.go:(Conn)Close also invoke net.Conn.Close()
        ss.Conn().Close()
    }
}

connect()Métodos dial()Método obtener una conexión de sesión en el Método Dial ():

func (c *client) dial() Session {
    switch c.endPointType {
    case TCP_CLIENT:
        return c.dialTCP()
    case UDP_CLIENT:
        return c.dialUDP()
    case WS_CLIENT:
        return c.dialWS()
    case WSS_CLIENT:
        return c.dialWSS()
    }

    return nil
}

Nos preocupa que haya una conexión TCP, así que continúe con el c.dialTCP()método:

func (c *client) dialTCP() Session {
    var (
        err  error
        conn net.Conn
    )

    for {
        if c.IsClosed() {
            return nil
        }
        if c.sslEnabled {
            if sslConfig, err := c.tlsConfigBuilder.BuildTlsConfig(); err == nil && sslConfig != nil {
                d := &net.Dialer{Timeout: connectTimeout}
                // 建立加密连接
                conn, err = tls.DialWithDialer(d, "tcp", c.addr, sslConfig)
            }
        } else {
            // 建立 tcp 连接
            conn, err = net.DialTimeout("tcp", c.addr, connectTimeout)
        }
        if err == nil && gxnet.IsSameAddr(conn.RemoteAddr(), conn.LocalAddr()) {
            conn.Close()
            err = errSelfConnect
        }
        if err == nil {
            // 返回一个 TCPSession
            return newTCPSession(conn, c)
        }

        log.Infof("net.DialTimeout(addr:%s, timeout:%v) = error:%+v", c.addr, connectTimeout, perrors.WithStack(err))
        <-wheel.After(connectInterval)
    }
}

Hasta ahora, sabemos cómo getty establece una conexión TCP y regresa a TCPSession.

2. Envía y recibe mensajes

Entonces, ¿cómo envía y recibe mensajes? Volvamos al método de conexión y miremos hacia abajo. Hay una línea ss.(*session).run()después de esta línea de código. El código es una operación muy simple. Suponemos que la lógica de esta línea de código debe incluir el envío y la recepción de mensajes. lógica de texto, y luego ingresa el run()método:

func (s *session) run() {
    // 省略部分代码

    go s.handleLoop()
    go s.handlePackage()
}

<br /> Jugué aquí dos goroutine, handleLoopy handlePackageveo literalmente a nuestro sospechoso en el handleLoop()método: <br />

func (s *session) handleLoop() {
    // 省略部分代码

    for {
        // A select blocks until one of its cases is ready to run.
        // It choose one at random if multiple are ready. Otherwise it choose default branch if none is ready.
        select {
        // 省略部分代码

        case outPkg, ok = <-s.wQ:
            // 省略部分代码

            iovec = iovec[:0]
            for idx := 0; idx < maxIovecNum; idx++ {
        // 通过 s.writer 将 interface{} 类型的 outPkg 编码成二进制的比特
                pkgBytes, err = s.writer.Write(s, outPkg)
                // 省略部分代码

                iovec = append(iovec, pkgBytes)

                //省略部分代码
            }
            // 将这些二进制比特发送出去
            err = s.WriteBytesArray(iovec[:]...)
            if err != nil {
                log.Errorf("%s, [session.handleLoop]s.WriteBytesArray(iovec len:%d) = error:%+v",
                    s.sessionToken(), len(iovec), perrors.WithStack(err))
                s.stop()
                // break LOOP
                flag = false
            }

        case <-wheel.After(s.period):
            if flag {
                if wsFlag {
                    err := wsConn.writePing()
                    if err != nil {
                        log.Warnf("wsConn.writePing() = error:%+v", perrors.WithStack(err))
                    }
                }
                // 定时执行的逻辑,心跳等
                s.listener.OnCron(s)
            }
        }
    }
}

Por el código anterior, encontramos que handleLoop()el método es enviar la lógica de procesamiento de mensajes, el mensaje RPC debe enviarse mediante la primera s.writercodificación en bits binarios y transmitirse a través de la conexión TCP establecida. Esto s.writercorresponde a Writer RPC Interface es un marco de interfaz que debe lograrse.

Continúe mirando los handlePackage()métodos:

func (s *session) handlePackage() {
    // 省略部分代码

    if _, ok := s.Connection.(*gettyTCPConn); ok {
        if s.reader == nil {
            errStr := fmt.Sprintf("session{name:%s, conn:%#v, reader:%#v}", s.name, s.Connection, s.reader)
            log.Error(errStr)
            panic(errStr)
        }

        err = s.handleTCPPackage()
    } else if _, ok := s.Connection.(*gettyWSConn); ok {
        err = s.handleWSPackage()
    } else if _, ok := s.Connection.(*gettyUDPConn); ok {
        err = s.handleUDPPackage()
    } else {
        panic(fmt.Sprintf("unknown type session{%#v}", s))
    }
}

En el handleTCPPackage()método:

func (s *session) handleTCPPackage() error {
    // 省略部分代码

    conn = s.Connection.(*gettyTCPConn)
    for {
        // 省略部分代码

        bufLen = 0
        for {
            // for clause for the network timeout condition check
            // s.conn.SetReadTimeout(time.Now().Add(s.rTimeout))
            // 从 TCP 连接中收到报文
            bufLen, err = conn.recv(buf)
            // 省略部分代码

            break
        }
        // 省略部分代码

        // 将收到的报文二进制比特写入 pkgBuf
        pktBuf.Write(buf[:bufLen])
        for {
            if pktBuf.Len() <= 0 {
                break
            }
            // 通过 s.reader 将收到的报文解码成 RPC 消息
            pkg, pkgLen, err = s.reader.Read(s, pktBuf.Bytes())
            // 省略部分代码

      s.UpdateActive()
            // 将收到的消息放入 TaskQueue 供 RPC 消费端消费
            s.addTask(pkg)
            pktBuf.Next(pkgLen)
            // continue to handle case 5
        }
        if exit {
            break
        }
    }

    return perrors.WithStack(err)
}

A partir de la lógica del código anterior, analizamos que el consumidor de RPC necesita decodificar el mensaje de bit binario recibido de la conexión TCP en un mensaje que RPC puede consumir. Este trabajo lo implementa el lector, por lo que necesitamos construir la capa de comunicación RPC. Implementar la interfaz del lector correspondiente al lector.

3. Cómo desacoplar la lógica de procesamiento de mensajes de red en la parte inferior de la lógica empresarial

Todos sabemos que netty logra el desacoplamiento de la lógica de red subyacente y la lógica empresarial a través del hilo del jefe y el hilo del trabajador. Entonces, ¿cómo se implementa getty?

En el handlePackage()último método, vemos que el mensaje recibido se coloca en s.addTask(pkg)este método, luego se analiza:

func (s *session) addTask(pkg interface{}) {
    f := func() {
        s.listener.OnMessage(s, pkg)
        s.incReadPkgNum()
    }
    if taskPool := s.EndPoint().GetTaskPool(); taskPool != nil {
        taskPool.AddTaskAlways(f)
        return
    }
    f()
}

pkgLos parámetros se pasan a un método anónimo, que finalmente se introduce taskPool. Este método es muy importante.Cuando escribí el código seata-golang más tarde, encontré un pozo, que analizaré más adelante.

Luego miramos la definición de taskPool :

// NewTaskPoolSimple build a simple task pool
func NewTaskPoolSimple(size int) GenericTaskPool {
    if size < 1 {
        size = runtime.NumCPU() * 100
    }
    return &taskPoolSimple{
        work: make(chan task),
        sem:  make(chan struct{}, size),
        done: make(chan struct{}),
    }
}

runtime.NumCPU() * 100Se construye un canal con un tamaño de búfer de tamaño (predeterminado   ) sem. Mira el método AddTaskAlways(t task):

func (p *taskPoolSimple) AddTaskAlways(t task) {
    select {
    case <-p.done:
        return
    default:
    }

    select {
    case p.work <- t:
        return
    default:
    }
    select {
    case p.work <- t:
    case p.sem <- struct{}{}:
        p.wg.Add(1)
        go p.worker(t)
    default:
        goSafely(t)
    }
}

La tarea agregada será consumida por len (p.sem) goroutines primero. Si no hay goroutine libre, se iniciará una goroutine temporal para ejecutar t (). Es equivalente a len (p.sem) goroutines para formar un grupo de goroutine.Las gorutinas en el grupo procesan la lógica empresarial, en lugar de ejecutar la lógica empresarial mediante la goroutine que procesa los paquetes de red, logrando así el desacoplamiento. Una de las trampas encontradas al escribir seata-golang es que olvidar establecer taskPool provocó la misma goroutine para procesar la lógica de negocios y procesar la lógica de mensajes de red subyacente. Bloqueé toda la goroutine mientras esperaba que se completara una tarea en la lógica de negocios. Para que no se puedan recibir mensajes durante el período de bloqueo.

4. Realización concreta

Consulte getty.go para obtener el siguiente código :

// Reader is used to unmarshal a complete pkg from buffer
type Reader interface {
    Read(Session, []byte) (interface{}, int, error)
}

// Writer is used to marshal pkg and write to session
type Writer interface {
    // if @Session is udpGettySession, the second parameter is UDPContext.
    Write(Session, interface{}) ([]byte, error)
}

// ReadWriter interface use for handle application packages
type ReadWriter interface {
    Reader
    Writer
}
// EventListener is used to process pkg that received from remote session
type EventListener interface {
    // invoked when session opened
    // If the return error is not nil, @Session will be closed.
    OnOpen(Session) error

    // invoked when session closed.
    OnClose(Session)

    // invoked when got error.
    OnError(Session, error)

    // invoked periodically, its period can be set by (Session)SetCronPeriod
    OnCron(Session)

    // invoked when getty received a package. Pls attention that do not handle long time
    // logic processing in this func. You'd better set the package's maximum length.
    // If the message's length is greater than it, u should should return err in
    // Reader{Read} and getty will close this connection soon.
    //
    // If ur logic processing in this func will take a long time, u should start a goroutine
    // pool(like working thread pool in cpp) to handle the processing asynchronously. Or u
    // can do the logic processing in other asynchronous way.
    // !!!In short, ur OnMessage callback func should return asap.
    //
    // If this is a udp event listener, the second parameter type is UDPContext.
    OnMessage(Session, interface{})
}

Mediante el análisis de todo el código getty, siempre que logremos   ReadWritermediante el códec de mensaje RPC, y luego implementemos EventListenerel proceso lógico especificado correspondiente a los mensajes RPC, ReadWriterlograremos e EventListerimplementaremos el RPC del lado de inyección de cliente y servidor, las comunicaciones RPC se pueden lograr.

4.1 Implementación del protocolo de codificación y decodificación

La siguiente es la definición del protocolo seata:

2.png

Al lograr la interfaz ReadWriter RpcPackageHandler, el mensaje de llamada del método Codec del mismo de acuerdo con el códec de formato anterior:

// 消息编码为二进制比特
func MessageEncoder(codecType byte, in interface{}) []byte {
    switch codecType {
    case SEATA:
        return SeataEncoder(in)
    default:
        log.Errorf("not support codecType, %s", codecType)
        return nil
    }
}

// 二进制比特解码为消息体
func MessageDecoder(codecType byte, in []byte) (interface{}, int) {
    switch codecType {
    case SEATA:
        return SeataDecoder(in)
    default:
        log.Errorf("not support codecType, %s", codecType)
        return nil, 0
    }
}

4.2 Implementación del lado del cliente

Veamos la EventListenerimplementación del lado del cliente RpcRemotingClient:

func (client *RpcRemoteClient) OnOpen(session getty.Session) error {
    go func() 
        request := protocal.RegisterTMRequest{AbstractIdentifyRequest: protocal.AbstractIdentifyRequest{
            ApplicationId:           client.conf.ApplicationId,
            TransactionServiceGroup: client.conf.TransactionServiceGroup,
        }}
    // 建立连接后向 Transaction Coordinator 发起注册 TransactionManager 的请求
        _, err := client.sendAsyncRequestWithResponse(session, request, RPC_REQUEST_TIMEOUT)
        if err == nil {
      // 将与 Transaction Coordinator 建立的连接保存在连接池供后续使用
            clientSessionManager.RegisterGettySession(session)
            client.GettySessionOnOpenChannel <- session.RemoteAddr()
        }
    }()

    return nil
}

// OnError ...
func (client *RpcRemoteClient) OnError(session getty.Session, err error) {
    clientSessionManager.ReleaseGettySession(session)
}

// OnClose ...
func (client *RpcRemoteClient) OnClose(session getty.Session) {
    clientSessionManager.ReleaseGettySession(session)
}

// OnMessage ...
func (client *RpcRemoteClient) OnMessage(session getty.Session, pkg interface{}) {
    log.Info("received message:{%v}", pkg)
    rpcMessage, ok := pkg.(protocal.RpcMessage)
    if ok {
        heartBeat, isHeartBeat := rpcMessage.Body.(protocal.HeartBeatMessage)
        if isHeartBeat && heartBeat == protocal.HeartBeatMessagePong {
            log.Debugf("received PONG from %s", session.RemoteAddr())
        }
    }

    if rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST ||
        rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST_ONEWAY {
        log.Debugf("msgId:%s, body:%v", rpcMessage.Id, rpcMessage.Body)

        // 处理事务消息,提交 or 回滚
        client.onMessage(rpcMessage, session.RemoteAddr())
    } else {
        resp, loaded := client.futures.Load(rpcMessage.Id)
        if loaded {
            response := resp.(*getty2.MessageFuture)
            response.Response = rpcMessage.Body
            response.Done <- true
            client.futures.Delete(rpcMessage.Id)
        }
    }
}

// OnCron ...
func (client *RpcRemoteClient) OnCron(session getty.Session) {
  // 发送心跳
    client.defaultSendRequest(session, protocal.HeartBeatMessagePing)
}

clientSessionManager.RegisterGettySession(session) La lógica se analizará a continuación.

4.3 Implementación del Coordinador de transacciones en el servidor

Ver el código DefaultCoordinator:

func (coordinator *DefaultCoordinator) OnOpen(session getty.Session) error {
    log.Infof("got getty_session:%s", session.Stat())
    return nil
}

func (coordinator *DefaultCoordinator) OnError(session getty.Session, err error) {
    // 释放 TCP 连接
  SessionManager.ReleaseGettySession(session)
    session.Close()
    log.Errorf("getty_session{%s} got error{%v}, will be closed.", session.Stat(), err)
}

func (coordinator *DefaultCoordinator) OnClose(session getty.Session) {
    log.Info("getty_session{%s} is closing......", session.Stat())
}

func (coordinator *DefaultCoordinator) OnMessage(session getty.Session, pkg interface{}) {
    log.Debugf("received message:{%v}", pkg)
    rpcMessage, ok := pkg.(protocal.RpcMessage)
    if ok {
        _, isRegTM := rpcMessage.Body.(protocal.RegisterTMRequest)
        if isRegTM {
      // 将 TransactionManager 信息和 TCP 连接建立映射关系
            coordinator.OnRegTmMessage(rpcMessage, session)
            return
        }

        heartBeat, isHeartBeat := rpcMessage.Body.(protocal.HeartBeatMessage)
        if isHeartBeat && heartBeat == protocal.HeartBeatMessagePing {
            coordinator.OnCheckMessage(rpcMessage, session)
            return
        }

        if rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST ||
            rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST_ONEWAY {
            log.Debugf("msgId:%s, body:%v", rpcMessage.Id, rpcMessage.Body)
            _, isRegRM := rpcMessage.Body.(protocal.RegisterRMRequest)
            if isRegRM {
        // 将 ResourceManager 信息和 TCP 连接建立映射关系
                coordinator.OnRegRmMessage(rpcMessage, session)
            } else {
                if SessionManager.IsRegistered(session) {
                    defer func() {
                        if err := recover(); err != nil {
                            log.Errorf("Catch Exception while do RPC, request: %v,err: %w", rpcMessage, err)
                        }
                    }()
          // 处理事务消息,全局事务注册、分支事务注册、分支事务提交、全局事务回滚等
                    coordinator.OnTrxMessage(rpcMessage, session)
                } else {
                    session.Close()
                    log.Infof("close a unhandled connection! [%v]", session)
                }
            }
        } else {
            resp, loaded := coordinator.futures.Load(rpcMessage.Id)
            if loaded {
                response := resp.(*getty2.MessageFuture)
                response.Response = rpcMessage.Body
                response.Done <- true
                coordinator.futures.Delete(rpcMessage.Id)
            }
        }
    }
}

func (coordinator *DefaultCoordinator) OnCron(session getty.Session) {

}

coordinator.OnRegTmMessage(rpcMessage, session)Registre Transaction Manager, coordinator.OnRegRmMessage(rpcMessage, session)registre Resource Manager. Consulte a continuación el análisis lógico específico.

Mensaje en el coordinator.OnTrxMessage(rpcMessage, session)método, de acuerdo con el código de tipo de enrutamiento de mensajes a una lógica específica entre:

    switch msg.GetTypeCode() {
    case protocal.TypeGlobalBegin:
        req := msg.(protocal.GlobalBeginRequest)
        resp := coordinator.doGlobalBegin(req, ctx)
        return resp
    case protocal.TypeGlobalStatus:
        req := msg.(protocal.GlobalStatusRequest)
        resp := coordinator.doGlobalStatus(req, ctx)
        return resp
    case protocal.TypeGlobalReport:
        req := msg.(protocal.GlobalReportRequest)
        resp := coordinator.doGlobalReport(req, ctx)
        return resp
    case protocal.TypeGlobalCommit:
        req := msg.(protocal.GlobalCommitRequest)
        resp := coordinator.doGlobalCommit(req, ctx)
        return resp
    case protocal.TypeGlobalRollback:
        req := msg.(protocal.GlobalRollbackRequest)
        resp := coordinator.doGlobalRollback(req, ctx)
        return resp
    case protocal.TypeBranchRegister:
        req := msg.(protocal.BranchRegisterRequest)
        resp := coordinator.doBranchRegister(req, ctx)
        return resp
    case protocal.TypeBranchStatusReport:
        req := msg.(protocal.BranchReportRequest)
        resp := coordinator.doBranchReport(req, ctx)
        return resp
    default:
        return nil
    }

4.4 análisis del administrador de sesiones

Cliente: establezca la conexión con el Coordinador de transacciones desde la conexión a través de clientSessionManager.RegisterGettySession(session)la conexión almacenada en serverSessions = sync.Map{}este mapa. La clave del mapa es RemoteAddress obtenida de la sesión, que es la dirección del Coordinador de transacciones, y el valor es la sesión. De esta manera, el lado del cliente puede registrar Transaction Manager y Resource Manager con Transaction Coordinator a través de una sesión en el mapa. Consulte el código específico getty_client_session_manager.go.

Una vez que Transaction Manager y Resource Manager están registrados con Transaction Coordinator, se puede utilizar una conexión para enviar mensajes TM o mensajes RM. Usamos RpcContext para identificar una información de conexión:

type RpcContext struct {
    Version                 string
    TransactionServiceGroup string
    ClientRole              meta.TransactionRole
    ApplicationId           string
    ClientId                string
    ResourceSets            *model.Set
    Session                 getty.Session
}

Cuando se recibe un mensaje de transacción, necesitamos construir dicho RpcContext para la lógica de procesamiento de la transacción posterior. Por lo tanto, construiremos el siguiente mapa para almacenar en caché la relación de mapeo:

var (
    // session -> transactionRole
    // TM will register before RM, if a session is not the TM registered,
    // it will be the RM registered
    session_transactionroles = sync.Map{}

    // session -> applicationId
    identified_sessions = sync.Map{}

    // applicationId -> ip -> port -> session
    client_sessions = sync.Map{}

    // applicationId -> resourceIds
    client_resources = sync.Map{}
)

Por lo tanto, Transaction Manager y un Resource Manager respectivamente coordinator.OnRegTmMessage(rpcMessage, session)y coordinator.OnRegRmMessage(rpcMessage, session)registrados cuando el Transaction Coordinator, se almacenarán en caché en el mapa de cliente_sessions anterior applicationId, ip, relación de puerto con la sesión, (en la presente aplicación se puede almacenar en caché applicationId client_resources resourceIds map con una pluralidad de Resource Manager ) Relación. Cuando sea necesario, podemos construir un RpcContext a través de la relación de mapeo anterior. La implementación de esta parte es muy diferente a la versión java de seata, quienes estén interesados ​​pueden aprender más sobre ella. Consulte el código específico getty_session_manager.go.

Hasta ahora, hemos terminado de analizar el mecanismo de todo el modelo de comunicación RPC de seata-golang .

3. El futuro de seata-golang

seata-golang  comenzó a desarrollarse en abril de este año, y básicamente se dio cuenta de la intercomunicación con la versión java del protocolo seata 1.2 en agosto, realizó el modo AT (coordina automáticamente el compromiso y la reversión de transacciones distribuidas) para la base de datos mysql, realizó el modo TCC y el terminal TC Utilice mysql para almacenar datos y convertir TC en una aplicación sin estado para admitir la implementación de alta disponibilidad. La siguiente figura muestra el principio del modo AT:

3.png

En el futuro, aún queda mucho trabajo por hacer, tales como: soporte para el centro de registro, soporte para el centro de configuración, intercomunicación de protocolo con la versión java de seata 1.4, soporte para otras bases de datos, implementación del coordinador de transacciones de balsa, etc., y esperamos solucionar el problema de las transacciones distribuidas. Los desarrolladores interesados ​​pueden unirse para crear un marco completo de transacciones distribuidas de golang.

Referencia

Sobre el Autor

Liu Xiaomin (GitHubID dk-lockdown), actualmente trabaja en la sucursal de h3c Chengdu, es bueno en el uso del lenguaje Java / Go, ha estudiado en direcciones técnicas nativas de la nube y relacionadas con microservicios, y actualmente se especializa en transacciones distribuidas.

Yu Yu (github @AlexStocks), proyecto dubbo-go y líder de la comunidad, programador con más de diez años de experiencia laboral de primera línea en el desarrollo de infraestructura del lado del servidor, y participó sucesivamente en la mejora de proyectos conocidos como Muduo / Pika / Dubbo / Sentinel-go Actualmente se dedica a la orquestación de contenedores y trabajo de malla de servicios en el Departamento de Trusted Native de Ant Financial.

Supongo que te gusta

Origin blog.51cto.com/13778063/2563003
Recomendado
Clasificación