当前位置:网站首页>Qu'est - il arrivé au cluster distribué websocket?

Qu'est - il arrivé au cluster distribué websocket?

2021-11-25 17:39:11 Pile technologique Java

Auteur:Qiu chengquan
Source::https://segmentfault.com/a/1190000017307713

Cause du problème

J'ai récemment eu du mal à communiquer entre plusieurs utilisateurs lors de projets,C'est vrai.WebSocketDemande de poignée de main,Et dans le clusterWebSocket SessionQuestions partagées.

Après quelques jours de recherches,Plusieurs implémentations distribuées sont résuméesWebSocketApproche groupée,DezuulÀspring cloud gatewayDifférentes tentatives de,Résumez cet article,J'espère pouvoir aider quelqu'un,Et partager des idées et des recherches dans ce domaine.

Voici ma description de scène

  • Ressources:4Serveurs.Un seul de ces serveurs asslNom de domaine certifié,Un.redis+mysqlServeur,Deux serveurs d'application(Cluster)
  • Application des restrictions de publication:Parce que la scène exige,Le site d'application nécessitesslUn nom de domaine certifié peut être publié.Donc,sslLe serveur de noms de domaine authentifié est utilisé lorsqueapiPasserelle,ResponsablehttpsDemandes etwss(Certifié de sécuritéws)Connexion.Communément appeléhttpsDésinstaller,Demande de l'utilisateurhttpsServeur de noms de domaine,Mais l'accès réel esthttp+ipForme de l'adresse.Tant que la passerelle est configurée haut,Oui.handleApplications multiples
  • Besoins:Application de connexion de l'utilisateur,Nécessite une configuration avec le serveurwssConnexion,Un seul message peut être envoyé entre différents rôles,Vous pouvez également envoyer des messages en groupe
  • Type de service d'application dans le cluster:Chaque instance de Cluster est responsablehttpService de demande apatride etwsService de connexion longue

Schéma d'architecture du système

Dans ma réalisation,Chaque serveur d'application est responsablehttp and wsDemande,En fait, vous pouvez aussiwsLe modèle de chat demandé a été créé séparément en tant que module.Du point de vue de la distribution,Les deux types d'implémentation sont similaires,Mais en termes de commodité,,Un service d'applicationhttp+wsLa méthode de demande est plus pratique.Les explications sont données ci - dessous.

Piles technologiques couvertes par cet article

  • Eureka Découverte et inscription des services
  • Redis SessionPartage
  • Redis Abonnement aux messages
  • Spring Boot
  • Zuul Passerelle
  • Spring Cloud Gateway Passerelle
  • Spring WebSocket Gérer les connexions longues
  • Ribbon Équilibrage de la charge
  • Netty Accords multiplesNIOCadre de communication réseau
  • Consistent Hash Algorithme de hachage cohérent

Tous ceux qui croient pouvoir faire ce pas connaissent la pile de technologie que j'ai énumérée ci - dessus,Si ce n'est pas déjà fait,Vous pouvez d'abord aller en ligne pour trouver le tutoriel d'introduction.Tout ce qui suit est pertinent pour la technologie ci - dessus,Par défaut, tout le monde le sait....

Analyse de faisabilité technique

Je vais décriresessionCaractéristiques,Et à partir de ces caractéristiques,nRésoudre le traitement dans l'architecture distribuéewsSchéma de regroupement demandé

WebSocketSessionAvecHttpSession

InSpringIntégréWebSocketÀ l'intérieur,ChaquewsLes connexions ont toutes une correspondancesession:WebSocketSession,InSpring WebSocketMoyenne,Nous construisonswsAprès la connexion, vous pouvez communiquer avec le client de la même façon:

protected void handleTextMessage(WebSocketSession session, TextMessage message) {
   System.out.println("Message reçu par le serveur: "+ message );
   //send message to client
   session.sendMessage(new TextMessage("message"));
}

Alors la question se pose:wsDesessionImpossible de sérialiser versredis,Donc dans le cluster,Nous ne pouvons pas tout mettreWebSocketSessionTous mis en cacheredisEn courssessionPartage.Chaque serveur a son propresession.Au contraire,HttpSession,redisPeut soutenirhttpsessionPartage,Mais pas pour l'instant.websocket sessionProgrammes partagés,Alors vas - y.redis websocket sessionPartager cette route ne marchera pas..

Certains pourraient penser:Je ne peux pas mettresessinCache des informations clés versredis,Serveur dans le cluster à partir deredisPrends - le.sessionMessages clés et reconstructionwebsocket session...Je dis juste que si quelqu'un peut essayer ça,,Dis - moi juste...

C'est tout ce qui précède.websocket sessionAvechttp sessionDifférences de partage,Dans l'ensemble,http sessionShare a déjà une solution,Et c'est simple,Dès que les dépendances pertinentes sont introduites:spring-session-data-redisEtspring-boot-starter-redis,Vous pouvez en trouver un en lignedemoJoue et tu sauras quoi faire..Etwebsocket sessionLe scénario partagé est dû àwebsocketMise en œuvre sous - jacente,On ne peut pas vraiment faire ça.websocket sessionPartage.

Évolution des solutions

NettyAvecSpring WebSocket

Au début,,J'ai essayé.nettyC'est fait.websocketConfiguration du serveur.InnettyÀ l'intérieur,Non.websocket sessionUn tel concept,De même,channel,Chaque connexion client représente unechannel.À l'avantwsDemande d'adoptionnettyPort sur écoute,Allons - y.websocketAccord en courswsAprès la connexion de poignée de main,Par quelques colonneshandler(Modèle de chaîne de responsabilité)Traitement des messages.Avecwebsocket sessionDe même,,Le serveur a unchannel,On peut passer parchannelCommuniquer avec les clients

/**
* TODO Selon le serveurid,Assigner à différentsgroup
*/
private static final ChannelGroup GROUP = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);

@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
   //retainAugmenter le nombre de références,Empêcher l'invalidation des références d'appel suivantes
   System.out.println("Serveur reçu de " + ctx.channel().id() + " Message de: " + msg.text());
   //Envoyer un message àgroupTout ce qu'il y a dedanschannel,C'est - à - dire envoyer un message au client
   GROUP.writeAndFlush(msg.retain());
}

Alors,Utilisé par le serveurnettyToujours.spring websocket?Voici quelques exemples des avantages et des inconvénients de ces deux méthodes de mise en oeuvre

UtilisernettyRéalisationwebsocket

J'ai jouénettyTout le monde saitnettyOui, le modèle de thread estnioModèle,Très forte concurrence,spring5Le modèle de Threading réseau précédent étaitservletRéalisé,EtservletNon, pas du tout.nioModèle,Donc, dansspring5Après,springLa mise en œuvre du réseau sous - jacent a adopténetty.Si on l'utilise seulnettyPour développerwebsocketServeur,La vitesse est absolue.,Toutefois, les problèmes suivants peuvent se poser::

  1. Intégration gênante avec d'autres applications du système,InrpcAu moment de l'appel,Je n'aime pas ça.springcloud- Oui.feignCommodité de l'invocation du Service
  2. La logique opérationnelle peut être mise en œuvre à plusieurs reprises
  3. UtilisernettyIl peut être nécessaire de répéter la fabrication des roues
  4. Comment se connecter au Registre des services,C'est aussi un problème.
  5. restfulServices etwsLes services doivent être mis en œuvre séparément,Si dansnettyRéalisation supérieurerestfulServices,On peut imaginer les problèmes.,AvecspringGuichet uniquerestfulJe crois que beaucoup de gens s'y habituent..

Utiliserspring websocketRéalisationwsServices

spring websocketA étéspringbootBien intégré,Donc, dansspringbootDéveloppement supérieurwsLe service était très pratique,C'est très simple..

Spring Boot Les bases ne sont pas présentées,Ce tutoriel pratique est recommandé:
https://github.com/javastacks/spring-boot-best-practice

Première étape:Ajouter une dépendance

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Deuxième étape:Ajouter une classe de configuration

@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    registry.addHandler(myHandler(), "/")
        .setAllowedOrigins("*");
}

@Bean
 public WebSocketHandler myHandler() {
     return new MessageHandler();
 }
}

Troisième étape:Mise en œuvre de la classe d'écoute des messages

@Component
@SuppressWarnings("unchecked")
public class MessageHandler extends TextWebSocketHandler {
   private List<WebSocketSession> clients = new ArrayList<>();

   @Override
   public void afterConnectionEstablished(WebSocketSession session) {
       clients.add(session);
       System.out.println("uri :" + session.getUri());
       System.out.println("Connexion établie: " + session.getId());
       System.out.println("current seesion: " + clients.size());
   }

   @Override
   public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
       clients.remove(session);
       System.out.println("Déconnecter: " + session.getId());
   }

   @Override
   protected void handleTextMessage(WebSocketSession session, TextMessage message) {
       String payload = message.getPayload();
       Map<String, String> map = JSONObject.parseObject(payload, HashMap.class);
       System.out.println("Données reçues" + map);
       clients.forEach(s -> {
           try {
               System.out.println("Envoyer un message à: " + session.getId());
               s.sendMessage(new TextMessage("Le serveur renvoie les informations reçues," + payload));
           } catch (Exception e) {
               e.printStackTrace();
           }
       });
   }
}

De cecidemoMoyenne,Utiliserspring websocketRéalisationwsLa commodité du service que tout le monde peut imaginer.Pour mieux ciblerspring cloudUne grande famille.,J'ai finalement adoptéspring websocketRéalisationwsServices.

Donc mon architecture de service d'application est comme ça:Une application est responsable à la foisrestfulServices,Aussi responsable.wsServices.Non.wsLe module de service est divisé parce qu'il est divisé pour être utiliséfeignPour faire un appel de service.D'abord, je suis paresseux.,La deuxième Division diffère de la non - division entre plus d'un niveau de serviceioAppelez,C'est pour ça qu'on n'a pas fait ça..

DezuulTransition technologique versspring cloud gateway

Pour réaliserwebsocketCluster,Nous devons inévitablementzuulTransition versspring cloud gateway.Pour les raisons suivantes:

zuul1.0Version non prise en chargewebsocketAvant,zuul 2.0Soutien initialwebsocket,zuul2.0Open source il y a quelques mois,Mais2.0La version n'est passpring bootIntégration,Et la documentation n'est pas saine.La transformation est donc nécessaire,Et la transformation est facile à réaliser.

IngatewayMoyenne,Pour réalisersslAuthentification et équilibrage dynamique de la charge de routage,ymlCertaines des configurations suivantes dans le fichier sont nécessaires,Évitez de creuser ici à l'avance..

Spring Boot Les bases ne sont pas présentées,Ce tutoriel pratique est recommandé:
https://github.com/javastacks/spring-boot-best-practice

server:
  port: 443
  ssl:
    enabled: true
    key-store: classpath:xxx.jks
    key-store-password: xxxx
    key-store-type: JKS
    key-alias: alias
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      httpclient:
        ssl:
          handshake-timeout-millis: 10000
          close-notify-flush-timeout-millis: 3000
          close-notify-read-timeout-millis: 0
          useInsecureTrustManager: true
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
      routes:
      - id: dc
        uri: lb://dc
        predicates:
        - Path=/dc/**
      - id: wecheck
        uri: lb://wecheck
        predicates:
        - Path=/wecheck/**

Si vous voulez vous amuserhttpsDésinstaller,Nous devons également configurer unfilter,Sinon, une erreur se produit lors de la demande de passerellenot an SSL/TLS record

@Component
public class HttpsToHttpFilter implements GlobalFilter, Ordered {
  private static final int HTTPS_TO_HTTP_FILTER_ORDER = 10099;
  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
      URI originalUri = exchange.getRequest().getURI();
      ServerHttpRequest request = exchange.getRequest();
      ServerHttpRequest.Builder mutate = request.mutate();
      String forwardedUri = request.getURI().toString();
      if (forwardedUri != null && forwardedUri.startsWith("https")) {
          try {
              URI mutatedUri = new URI("http",
                      originalUri.getUserInfo(),
                      originalUri.getHost(),
                      originalUri.getPort(),
                      originalUri.getPath(),
                      originalUri.getQuery(),
                      originalUri.getFragment());
              mutate.uri(mutatedUri);
          } catch (Exception e) {
              throw new IllegalStateException(e.getMessage(), e);
          }
      }
      ServerHttpRequest build = mutate.build();
      ServerWebExchange webExchange = exchange.mutate().request(build).build();
      return chain.filter(webExchange);
  }

  @Override
  public int getOrder() {
      return HTTPS_TO_HTTP_FILTER_ORDER;
  }
}

Pour que nous puissions utilisergatewayPour désinstallerhttpsC'est demandé.,Jusqu'à présent,Notre cadre de base est en place,La passerelle peut être transmisehttpsDemande,Peut également être transmiswssDemande.Ensuite, il y a beaucoup à beaucoup d'utilisateurssessionUne solution de communication interopérable.Et puis...,Je serai basé sur l'élégance du programme,Commencez par le scénario le moins élégant.

sessionDiffusion

C'est le plus simplewebsocketSolutions de communication groupées.Le scénario est le suivant::

EnseignantsAIl voulait envoyer des messages à ses élèves.

  • Demande de message de l'enseignant à la passerelle,Contenu{Je suis professeur.A,Je veux mettrexxxEnvoyer un message à mes élèves}
  • Message reçu par la passerelle,Obtenir tous les ClustersipAdresse,Appeler les demandes des enseignants un par un
  • Demande d'acquisition par serveur dans le cluster,Selon les enseignantsARecherche d'information pour les étudiants locauxsession,Il y a un appelsendMessageMéthodes,Aucune demande ignorée

sessionLa mise en œuvre de la radiodiffusion est simple,Mais il y a un défaut mortel.:Gaspillage de puissance de calcul,Lorsque le serveur n'a pas de destinataire de messagesessionQuand,C'est comme gaspiller la force de calcul d'une traversée cyclique,Le programme peut être prioritaire en cas de faible demande simultanée,Facile à réaliser.

spring cloudPour obtenir des informations sur chaque serveur d'un cluster de services, utilisez la méthode suivante

@Resource
private EurekaClient eurekaClient;

Application app = eurekaClient.getApplication("service-name");
//instanceInfoY compris un serveurip,portAttendez les nouvelles.
InstanceInfo instanceInfo = app.getInstances().get(0);
System.out.println("ip address: " + instanceInfo.getIPAddr());

Le serveur doit maintenir la table de cartographie des relations,De l'utilisateuridAvecsessionCartographier,sessionAjouter une relation de cartographie à la table de cartographie lorsqu'elle est établie,sessionSupprimer les relations d'association dans la table de cartographie après la déconnexion

Mise en œuvre d'un algorithme de hachage cohérent(Points saillants de cet article)

C'est l'implémentation la plus élégante que je trouve,Il faut du temps pour comprendre ce scénario.,Si vous regardez avec patience,Je suis sûr que tu auras quelque chose..Encore une fois.,Si vous ne connaissez pas l'algorithme de hachage de cohérence, regardez d'abord ici,Supposons que l'anneau de hachage soit trouvé dans le sens des aiguilles d'une montre..

Tout d'abord,,Pour appliquer l'idée d'un algorithme de hachage cohérent à notrewebsocketCluster,Nous devons résoudre les nouveaux problèmes suivants::

  • Noeud de ClusterDOWN,Affecte la cartographie de l'anneau de hachage à l'état OuiDOWNNode of.
  • Noeud de ClusterUP,Affecte l'ancienkeyImpossible de cartographier le noeud correspondant.
  • Partage de la lecture et de l'écriture des anneaux de hachage.

Dans un Cluster,Il y aura toujours un serviceUP/DOWNLa question de.

Pour les noeudsDOWNLes problèmes sont analysés comme suit::

Un serveurDOWNQuand,Qu'il possèdewebsocket sessionLa connexion se ferme automatiquement,Et l'avant reçoit une notification.Les erreurs de cartographie qui affectent l'anneau de hachage.On a juste besoin de surveiller les serveurs.DOWNQuand,Supprimer les noeuds réels et imaginaires correspondants au - dessus de l'anneau de hachage,Évitez d'envoyer la passerelle à l'état OuiDOWNSur le serveur de.

Méthode de réalisation:IneurekaService de Cluster d'écoute du Centre de gouvernanceDOWNÉvénements,Et mettre à jour l'anneau de hachage en temps opportun.

Pour les noeudsUPLes problèmes sont analysés comme suit::

Supposons maintenant qu'il y ait des services dans le cluster CacheBEn ligne,De ce serveuripL'adresse est juste cartographiée àkey1Et cacheAEntre.Alorskey1L'utilisateur correspondant s'enfuit chaque fois qu'il envoie un message CacheBEnvoyer un message,Il s'est avéré qu'il n'y avait pas de message.,Parce que CacheBNon.key1Correspondantsession.

Nous avons maintenant deux solutions.

ProgrammeASimple.,Grande action:

eurekaÉcouter sur le noeudUPAprès l'événement,Sur la base des informations existantes sur les grappes,Mettre à jour l'anneau de hachage.Et déconnecter toutsessionConnexion,Reconnecter le client,Le client se connecte au noeud de l'anneau de hachage mis à jour,Pour éviter que le message ne soit pas livré.

ProgrammeBComplexe,Petit mouvement:

Voyons d'abord s'il n'y a pas de noeuds virtuels.,Hypothèses CacheCEt CacheAServeur en ligne entre CacheB.Toutes les cartes CacheCÀ CacheBLes utilisateurs de CacheBRegarde à l'intérieursessionEnvoyer un message.C'est - à - dire CacheBUne fois en ligne,Qui affecte CacheCÀ CacheBMessage envoyé par l'utilisateur entre.Donc nous devons juste mettre CacheADéconnecter CacheCÀ CacheBPour l'utilisateur desession,Reconnecter les clients.

Ensuite, il y a un noeud virtuel,Supposons que le noeud de lumière soit un noeud virtuel.Nous utilisons de longues parenthèses pour représenter le résultat d'une cartographie de zone qui appartient à un Cache.D'abordCLe noeud n'est pas en ligne.Tout le monde doit comprendre.,Tous lesBLes noeuds virtuels deBNoeud,Donc toutBLa partie du noeud dans le sens contraire des aiguilles d'une montre est cartographiéeB(Parce que nous exigeons que l'anneau de hachage cherche dans le sens des aiguilles d'une montre).

Et puisCSituation en ligne du noeud,Vous pouvez voir que certaines zones sontCC'est occupé..

Nous pouvons le savoir à partir de ce qui précède:Noeud en ligne,Il y aura beaucoup de noeuds virtuels correspondants en ligne en même temps,Nous avons donc besoin d'une gamme de segmentskeyCorrespondantsessionDéconnecter(Partie rouge de l'image ci - dessus).L'algorithme est un peu compliqué.,La mise en œuvre varie d'une personne à l'autre,Vous pouvez essayer d'implémenter votre propre algorithme.

Où placer l'anneau de hachage?

  • gatewayCréer et maintenir localement des anneaux de hachage.QuandwsQuand j'ai demandé à entrer,Obtenir localement l'anneau de hachage et obtenir des informations sur le serveur de cartographie,AvantwsDemande.Ça a l'air bien.,Mais ce n'est pas vraiment souhaitable.,Pensez au serveur ci - dessusDOWNSeulement pareurekaÉcouter,AlorseurekaJ'écoute.DOWNAprès l'événement,Besoin de passerioPour informergatewaySupprimer le noeud correspondant?Apparemment, c'est trop gênant.,Oui.eurekaLes responsabilités sont réparties entregateway,Pas recommandé..
  • eurekaCréation,Et mettreredisLecture et écriture partagées.C'est faisable.,QuandeurekaÉcouter le ServiceDOWNQuand,Modifier l'anneau de hachage et pousser versredisAllez..Réduire au minimum le temps de réponse à la demande,Nous ne pouvons pas laissergatewayChaque transmissionwsAllez - y quand vous le demandez.redisPrenez l'anneau de hachage une fois.La probabilité de modification de l'anneau de hachage est très faible.,gatewayIl suffit d'appliquerredisMode d'abonnement au message pour,L'abonnement à l'événement de modification de l'anneau de hachage peut résoudre ce problème.

Jusqu'à présent, notrespring websocketLe Cluster est presque construit.,Le plus important est l'algorithme de hachage cohérent.Il y a un dernier goulot d'étranglement technologique,Comment la passerelle est - elle basée surwsDemande transmise au serveur Cluster spécifié?

Réponse à l'équilibrage de la charge.spring cloud gatewayOuzuulSont intégrés par défautribbonComme équilibrage de charge,Nous avons juste besoin de construirewsEnvoyé par le client sur demandeuser id,RéécritureribbonAlgorithme d'équilibrage de la charge,Selonuser idEn courshash,Et chercher sur l'anneau de hachageip,Et vawsDemande transmise àipC'est fini..Le processus est illustré dans la figure ci - dessous.:

La prochaine fois que les utilisateurs communiquent,Il suffit deidEn courshash,Obtenir la correspondance sur l'anneau de hachageip,Vous savez que vous avez crééwsAu moment de la connexionsessionSur quel serveur existe - t - il?!

spring cloud Finchley.RELEASE Dans la versionribbonUn endroit imparfait

Le sujet l'a découvert dans la pratique.ribbonDeux imperfections......

  • Selon la méthode de recherche en ligne,SuccessionAbstractLoadBalancerRuleAprès avoir dépassé la politique d'équilibrage de la charge,Les demandes pour plusieurs applications différentes deviennent confuses.SieurekaIl y en a deux.service AEtB,Après avoir dépassé la politique d'équilibrage de la charge,DemandeAOuBServices,Ne sera finalement cartographié qu'à l'un des services.Très étrange.!C'est possible.spring cloud gatewayLe site Web doit fournir une stratégie correcte pour réécrire l'équilibrage de la chargedemo.
  • Un algorithme de hachage cohérent nécessite unkey,Similaireuser id,SelonkeyEn courshashRechercher ensuite sur l'anneau de hachage et retournerip.MaisribbonPas parfait.chooseFonctionkeyParamètres,Il est mort.default!

On ne peut pas faire ça??Il y a en fait une alternative viable et temporaire!

Comme le montre la figure ci - dessous,Le client envoie unhttpDemande(ContientidParamètres)À la passerelle,Passerelle basée suridEn courshash,Rechercher dans l'anneau de hachageipAdresse,Oui.ipAdresse retournée au client,Le client est ensuite basé suripAdressewsDemande.

Parce queribbonImparfaitkeyTraitement,Nous ne pouvons pas être ici pour le momentribbonMise en œuvre d'un algorithme de hachage cohérent.Une demande ne peut être faite que deux fois indirectement par l'intermédiaire du client(Une fois.http,Une fois.ws)Pour mettre en œuvre un hachage cohérent.J'espère que bientôtribbonPeut mettre à jour ce défaut!Laissez - nouswebsocketLe Cluster est un peu plus élégant.

PostScript

C'est ce que j'ai exploré ces derniers jours..A rencontré de nombreux problèmes,Et résoudre les problèmes un par un,Liste des deuxwebsocketSolutions groupées.Le premier estsessionDiffusion,Le deuxième est le hachage de cohérence.

Les deux scénarios présentent des avantages et des inconvénients pour différents scénarios.,Non utilisé dans cet articleActiveMQ,KarfaMise en file d'attente des messages,J'essaie juste de passer à travers mes propres pensées.,La mise en file d'attente des messages n'est pas nécessaire pour permettre une communication longue et connectée entre plusieurs utilisateurs.J'espère vous donner une idée différente de la normale.

Recommandations récentes:

1.1,000+ Dow. JavaQuestions d'entrevue et Organisation des réponses(2021Dernière édition)

2.Arrête de remplir l'écran. if/ else C'est,Essayez le mode Stratégie,Ca sent bon!!!

3.Merde!!Java Dans xx ≠ null Quelle nouvelle syntaxe??

4.Spring Boot 2.6 Publication officielle,Une grande vague de nouvelles caractéristiques..

5.《JavaManuel de développement(Songshan Edition)》Dernière publication,Téléchargement rapide!

C'est bien.,N'oublie pas d'être gentil.+Avant.!

版权声明
本文为[Pile technologique Java]所创,转载请带上原文链接,感谢
https://chowdera.com/2021/11/20211125173616791C.html

随机推荐