消息队列之activeMQ

消息队列之RabbitMQ

1.kafka介绍

kafka是由scala语言开发的一个多分区,多副本的并且居于zookeeper协调的分布式的发布-订阅消息系统。具有高吞吐、可持久化、可水平扩展、支持流处理等特性;能够支撑海量数据的数据传递;并且将消息持久化到磁盘中,并对消息创建了备份保证了数据的安全。kafka在保证了较高的处理速度的同时,又能保证数据处理的低延迟和数据的零丢失。

kafka的特性:

  1. 高吞吐量,低延迟:kafka每秒可以处理几十万条消息,延迟最低大概毫秒,每个主题可以分为多个分区,消费组对分区进行消费操作
  2. 可扩展性:支持热扩展
  3. 持久性,可靠性:消息被持久化到本地磁盘,并且支持数据备份
  4. 容错性:允许集群中节点失败,如副本的数量为n,则允许n-1个节点失败
  5. 高并发:允许上千个客户端同时读写
  6. 可伸缩性:kafka在运行期间可以轻松的扩展或者收缩;可以扩展一个kafka主题来包含更多的分区

kafka的主要应用场景:

  • 消息处理
  • 网站跟踪
  • 指标存储
  • 日志聚合
  • 流式处理
  • 事件朔源

基本流程:

kafka的关键角色:

  • Producer:生产者即数据的发布者,该角色将消息发布到kafka的topic中
  • Consumer:消费者,可以从broker中读取数据
  • Consumer Group:每个Consumer属于一个特定的Consumer Group(可为每个Consumer指定group name,若不指定group name则属于默认的group)
  • Topic:划分数据的所属类的一个类别属性
  • Partition:topic中的数据分割为一个或多个partition,每个topic中至少含有一个partition
  • Partition offset:每条消息都有一个当前partition下的唯一的64字节的offset,它指名了这条消息的起始位置
  • Replicas of Partition:副本,是一个分区的备份
  • Broker:kafka集群中包含一个或多个服务器 ,服务器的节点称为broker
  • Leader:每个partition由多个副本,其中有且仅有一个作为leader,leader是当前负责数据的读写的partition
  • Follower:Follower跟随Leader,所有的写请求都是通过leader路由,数据变更会广播到所有的follower上,follower与leader的数据保持同步
  • AR:分区中所有的副本统称为AR
  • ISR:所有与leader部分保持一定程度的副本组成ISR
  • OSR:与leader副本同步滞后过多的副本
  • HW:高水位,标识了一个特定的offset,消费者只能拉去到这个offset之前的消息
  • LEO:即日志末端位移,记录了该副本底层日志中的下一条消息的位移值

2.kafka的安装

安装kafka的前提是安装zookeeper以及jdk环境。我这里安装的版本是jdk1.8.0_20,kafka_2.11-1.0.0,zookeeper-3.4.14。kafka与jdk的版本一定要对应。我之前用的kafka_2.12_2.3.0,就不行

1.将kafka的文件上传到home目录下并解压缩到/usr/local目录下

root@localhost home]# tar -xvzf kafka_2.11-1.0.0.tgz -C /usr/local

2.进入kafka的config

[root@localhost local]# cd /usr/local/kafka_2.11-1.0.0/config

3.编辑server.properties文件

# 如果是集群环境中,则每个broker.id要设置为不同
broker.id=0
# 将下面这一行打开,这相当于kafka对外提供服务的入口
listeners=PLAINTEXT://192.168.189.150:9092
# 日志存储位置:log.dirs=/tmp/kafka_logs 改为
log.dirs=/usr/local/kafka_2.11-1.0.0/logs
# 修改zookeeper的地址
zookeeper.connect=192.168.189.150:2181
# 修改zookeeper的连接超时时长,默认为6000(可能会超时)
zookeeper.connection.timeout.ms=10000

3.启动zookeeper

因为我是配置的zookeeper集群,所以需要将三台zookeeper都启动。只启动单台服务器zookeeper在选举的时候将不可进行(当整个集群超过半数机器宕机,zookeeper会认为集群处于不可用状态)

[root@localhost ~]# zkServer.sh start
# 查看状态
[root@localhost ~]# zkServer.sh status

4.启动kafka

[root@localhost kafka_2.11-1.0.0]# bin/kafka-server-start.sh config/server.properties
# 也可以使用后台启动的方式,如果不使用后台启动,则在启动后操作需要新开一个窗口才能操作
[root@localhost kafka_2.11-1.0.0]# bin/kafka-server-start.sh -daemon config/server.properties

5.创建一个主题

# --zookeeper: 指定了kafka所连接的zookeeper的服务地址
# --partitions: 指定了分区的个数
# --replication-factor: 指定了副本因子
[root@localhost kafka_2.11-1.0.0]# bin/kafka-topics.sh --zookeeper localhost:2181 --create --topic charon --partitions 2 --replication-factor 1
Created topic "charon".

6.展示所有的主题(验证创建的主题是否有问题)

[root@localhost kafka_2.11-1.0.0]# bin/kafka-topics.sh --zookeeper localhost:2181 --list
charon

7.查看某个主题的详情

[root@localhost kafka_2.11-1.0.0]# bin/kafka-topics.sh --zookeeper localhost:2181 --describe --topic charon
Topic:charon PartitionCount:2 ReplicationFactor:1 Configs:
Topic: charon Partition: 0 Leader: 0 Replicas: 0 Isr: 0
Topic: charon Partition: 1 Leader: 0 Replicas: 0 Isr: 0

8.新开一个窗口启动消费者接收消息.

--bootstrap-server:指定连接kafka集群的地址,9092是kafka服务的端口。因为我的配置文件中配置的是具体地址,所以需要写明具体地址。否则会报 [Producer clientId=console-producer] Connection to node -1 could not be established. Broker may not be available.的错

[root@localhost kafka_2.11-1.0.0]# bin/kafka-console-consumer.sh --bootstrap-server 192.168.189.150:9092 --topic charon

9.新开一个窗口启动生产者产生消息

--bootstrap-server:指定连接kafka集群的地址,9092是kafka服务的端口。因为我的配置文件中配置的是地址。

[root@localhost kafka_2.11-1.0.0]# bin/kafka-console-producer.sh --broker-list 192.168.189.150:9092 --topic charon

10.产生消息并消费消息

# 生产者生产消息
>hello charon good evening
# 消费者这边接收到的消息
hello charon good evening

当然上面这种方式,只有在同一个网段才能实现。

3.生产者和消费者

kafka生产流程:

1)producer先从zookeeper的 "/brokers/.../state"节点找到该partition的leader

2)producer将消息发送给该leader

3)leader将消息写入本地log

4)followers从leader pull消息,写入本地log后向leader发送ACK

5)leader收到所有ISR中的replication的ACK后,增加HW(high watermark,最后commit 的offset)并向producer发送ACK

消费组:

kafka消费者是消费组的一部分,当多个消费者形成一个消费组来消费主题的时候,每个消费者都会收到来自不同分区的消息。假如消费者都在同一个消费者组里面,则是工作-队列模型。假如消费者在不同的消费组里面,则是发布-订阅模型。

当单个消费者无法跟上数据的生成速度时,就可以增加更多的消费者来分担负载,每个消费者只处理部分分区的消息,从而实现单个应用程序的横向伸缩。但是千万不要让消费者的数量少于分区的数量,因为此时会有多余的消费者空闲。

当有多个应用程序都需要从kafka获取消息时,让每个应用程序对应一个消费者组,从而使每个应用程序都能获取一个或多个topic的全部消息。每个消费者对应一个线程,如果要在同一个消费者组中运行多个消费者,需要让每个消费者运行在自己的线程中。

4.代码实践

1.添加依赖:

<!--添加kafka的依赖-->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka_2.11</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-streams</artifactId>
<version>1.0.0</version>
</dependency>

生产者代码:

package kafka;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; /**
* @className: Producer
* @description: kafka的生产者
* @author: charon
* @create: 2021-01-18 08:52
*/
public class Producer { /**topic*/
private static final String topic = "charon"; public static void main(String[] args) {
// 配置kafka的属性
Properties properties = new Properties();
// 设置地址
properties.put("bootstrap.servers","192.168.189.150:9092");
// 设置应答类型,默认值为0。(0:生产者不会等待kafka的响应;1:kafka的leader会把这条消息写到本地日志文件中,但不会等待集群中其他机器的成功响应;
// -1(all):leader会等待所有的follower同步完成,确保消息不会丢失,除非kafka集群中的所有机器挂掉,保证可用性)
properties.put("acks","all");
// 设置重试次数,大于0,客户端会在消息发送失败是重新发送
properties.put("reties",0);
// 设置批量大小,当多条消息需要发送到同一个分区时,生产者会尝试合并网络请求,提交效率
properties.put("batch.size",10000);
// 生产者设置序列化方式,默认为:org.apache.kafka.common.serialization.StringSerializer
properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 创建生产者
KafkaProducer producer = new KafkaProducer(properties);
for (int i = 0; i < 5; i++) {
String message = "hello,charon message "+ i ;
producer.send(new ProducerRecord(topic,message));
System.out.println("生产者发送消息:" + message);
}
producer.close();
}
}

消费者代码:

package kafka;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringSerializer; import java.util.Arrays;
import java.util.List;
import java.util.Properties; /**
* @className: Consumer
* @description: kafka的消费者
* @author: charon
* @create: 2021-01-18 08:53
*/
public class Consumer implements Runnable{ /**topic*/
private static final String topic = "charon"; /**kafka消费者*/
private static KafkaConsumer kafkaConsumer; /**消费消息*/
private static ConsumerRecords<String,String> msgList; public static void main(String[] args) {
// 配置kafka的属性
Properties properties = new Properties();
// 设置地址
properties.put("bootstrap.servers","192.168.189.150:9092");
// 消费者设置反序列化方式
properties.put("key.deserializer", StringDeserializer.class.getName());
properties.put("value.deserializer", StringDeserializer.class.getName());
// 设置消费组
properties.put("group.id","test01");
// 设置允许自动提交
properties.put("enable.auto.commit","true");
// 设置自动提交的时间间隔
properties.put("auto.commit.interval.ms","1000");
// 设置连接的超时市场
properties.put("session.timeout.ms","30000");
// 创建消费者
kafkaConsumer = new KafkaConsumer(properties);
// 指定分区
kafkaConsumer.subscribe(Arrays.asList(topic));
Consumer consumer = new Consumer();
new Thread(consumer).start();
// kafkaConsumer.close();
} @Override
public void run() {
for (;;){
// 获取数据的超时1000ms
msgList = kafkaConsumer.poll(1000);
if(null != msgList && msgList.count() > 0){
for (ConsumerRecord<String,String> consumerRecord: msgList ) {
System.out.println("消费者接受到消息,开始消费:" + consumerRecord);
System.out.println("topic= "+consumerRecord.topic()+" ,partition= "+consumerRecord.partition()+" ,offset= "+consumerRecord.offset()+" ,value="+consumerRecord.value()+"\n");
}
}else{
// 如果没有接受到数据,则阻塞一段时间
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

5.提交和偏移量

kafka不会像activemq那样需要得到消费者确认,所以消费者需要追踪kafka的消息消费到分区中的哪个位置了,这个位置就叫偏移量。把更新分区当前位置的操作叫做提交。如果消费者发生崩溃或者有新的消费者加入群组,就会触发再均衡,完成再均衡之后,每个消费者可能分配到新的分区上,而不是之前处理的那个,为了能够继续之前的工作,消费者需要读取每个分区最后一次提交的偏移量,然后从偏移量指定的地方继续处理。

这样的话就可能会有以下两种情况:

1.提交的偏移量小于客户端处理的偏移量

如果提交的偏移量小于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息就会被重新处理。

2.提交的偏移量大于客户端处理的偏移量

如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息就会丢失。

kafka的提交方式:

  • 自动提交模式:消费者拉取数据之后自动提交偏移量,不关心后续对消息的处理是否正确。优点是:消费快,适用于数据一致性弱的业务场景,缺点为:消息容易丢失或者重复消费

    将enable.auto.commit被设为 true

  • 手动提交模式:消费者拉取数据之后做业务处理,而且需要业务处理完成才算真正消费成功。缺点:在broker对提交请求做出回应之前,应用程序会一直阻塞,会限制应用程序的吞吐量

    将enable.auto.commit被设为 false;

    在消息处理完成后手动调用consumer.commitSync();

  • 异步提交:只需要发送提交请求,无需等待broker的响应

    在消息处理完成后手动调用consumer.commitAsync();这个方法也支持回调,在broker作出响应时会执行回调,回调经常被用于记录提交失败将错误信息和偏移量记录下来,如果重新提交,则需要注意提交的顺序。

6.再均衡监听器

在为消费者分配新的分区或者移除旧的分区时,可以通过消费者API执行一些应用程序代码,在调用subscribe(Pattern pattern, ConsumerRebalanceListener listener)时,可以传入一个再均衡监听器。

需要实现的两个方法:

  • public void onPartitionRevoked(Collection partitions);

    在再均衡开始之前和消费者停止读取消息之后被调用,如果在这里提交偏移量,下一个接管分区的消费者就知道从哪里开始读取了,要注意提交的是最近处理过的偏移量,而不是批次中还在处理的最后一个偏移量。

  • public void onPartitionAssigned(Collection partitions)

    在重新分配分区之后和消费者开始夫区消息之前被调用

7.kafka消息重复和丢失分析

首先来看看kafka的应答类型:

  • ack=0:生产者无需等待来自broker的确认而继续发送下一批消息(数据传输效率最高,但可靠性最低)
  • ack=1:生产者在ISR中的leader已成功收到数据并写入到本地日志文件中,但不会等待集群中其他follower的成功响应
  • ack=-1:生产者需要等待ISR中的所有的follower同步完成,确保消息不会丢失,除非kafka集群中的所有机器挂掉,保证可用性(可靠性最高,但也不能保证数据不丢失)

如果是单机环境中,三者没有区别。

kafka的消息重复和丢失可能发生在三个阶段:

1.生产者阶段的原因为:生产者发送的消息没有收到正确的broker的响应,导致生产者重试。

生产者发送一条消息,broker罗盘以后因为网络等种种原因,发送端得到一个发送失败的响应或者网络中断,然后prodcuer收到一个可恢复的exception重试消息导致消息重试。

重试过程:

  1. new KafkaProducer()后创建一个后台线程KafkaThread扫描RecordAccumulator中是否有消息;
  2. 调用KafkaProducer.send()发送消息,实际上只是把消息保存到RecordAccumulator中;
  3. 后台线程KafkaThread扫描到RecordAccumulator中有消息后,将消息发送到kafka集群;
  4. 如果发送成功,那么返回成功;
  5. 如果发送失败,那么判断是否允许重试。如果不允许重试,那么返回失败的结果;如果允许重试,把消息再保存到RecordAccumulator中,等待后台线程KafkaThread扫描再次发送;

解决方式:

1.启动kafka的幂等性。要启动kafka的幂等性,需要修改配置文件中的:enable.idempotenmce=true,同时要求ack=all且retries>1。如果要提高数据的可靠性,还需要min.insync.replicas这个参数配合,如果ISR的副本数少于min.insync.replicas则会产生异常,原因:消息被拒绝,同步副本数量少于所需的数量

幂等性的原理:

每个生产者都有一个PID,服务端回通过PID关联记录每个生产者的状态,每个生产者的每个消息会带上一个递增的序列(sequence),服务端会记录每个生产者对应的当前的最大的序列(PID+seq),如果新的消息带上的序列不大于当前的最大的seq就拒绝这条消息,如果消息落盘会同时更新最大的seq,这个时候重发的消息会呗服务器拒掉从而避免了消息重复。

2.设置ack=0,即不需要确认,不重试。但可能会丢失数据,所以适用于吞吐量指标重要性高于数据丢失,例如:日志收集。

2.生产者和broker阶段的原因:

  1. ack=0,不重试。生产者发送消息后,不管结果如何,如果发送失败数据也就丢失了。

  2. ack=1,leader宕机(crash)了,生产者发送消息完,只等待leader写入成功就返回了,leader宕机了,这是follower还没来得及同步,那么消息就丢失了。

  3. unclean.leader.election.enable 配置为true。允许选举ISR以外的副本作为leader,会导致数据丢失,默认为fase(非ISR中的副本不能参与选举)。

    生产者发送完异步消息,只等待leader写入成功就返回了,leader宕机了,这时ISR中没有follower,leader从OSR中选举,因为OSR中本来落后于leader而造成数据丢失。

解决方式:

1.配置:ack=-1,retries>1,unclean.leader.election.enable=false

生产者发送完消息,等待follower同步完在返回,如果异常则重试,这时副本的数量可能影响吞吐量,最大不超过5个,一般三个就够了。

2.配置:min.insync.replicas > 1

当生产者将ack设置为all或-1时,min.insync副本指定必须确认写操作成功的最小副本数量,如果不能满足这个最小值,则生产者将引发一个异常。当一起使用时,min.insync.replicas和ack允许执行更大的持久性保证。

3.失败的offset单独记录

生产者发送消息,回自动重试,遇到不可恢复的异常会抛出,这时可以捕获异常记录到数据库或缓存中,进行单独处理。

3.消费阶段的原因:数据消费完没有及时提交offset到broker。消息消费端在消费过程中挂掉没有及时提交offset到broker,另一个消费者启动拿到之前记录的offset开始消费,由于offset的滞后性可能会导致新启动的客户端有少量重复消费。

解决方式:

1.取消自动提交,每次消费完或者程序退出时手动提交,这也没有办法保证不会有重复。

2.做幂等性,尽量让下游做幂等或者尽量每消费一条消息都记录offset。对于少书严格的场景可能需要吧offset或唯一ID和下游状态更新放在同一个数据库里做事务来保证精确的一次更新或者在下游数据库表里同时记录消费的offset。然后更新数据的时候用消费位点做乐观锁拒绝掉旧的位点的数据更新。

参考文章:

https://www.cnblogs.com/qingyunzong/p/9004509.html

https://www.cnblogs.com/frankdeng/p/9310704.html

https://www.jianshu.com/p/6845469d99e6

https://www.cnblogs.com/wangzhuxing/p/10124308.html

消息队列之kafka的更多相关文章

  1. 消息队列与Kafka

    2019-04-09 关键词: 消息队列.为什么使用消息队列.消息队列的好处.消息队列的意义.Kafka是什么 本篇文章系本人就当前所掌握的知识关于 消息队列 与 kafka 知识点的一些简要介绍,不 ...

  2. redis 的消息队列 VS kafka

    redis push/pop VS pub/sub (1)push/pop每条消息只会有一个消费者消费,而pub/sub可以有多个 对于任务队列来说,push/pop足够,但真的在做分布式消息分发的时 ...

  3. 消息队列之 Kafka

    转 https://www.jianshu.com/p/2c4caed49343 消息队列之 Kafka 预流 2018.01.15 16:27* 字数 3533 阅读 1114评论 0喜欢 12 K ...

  4. 【知识点】同样是消息队列,Kafka凭什么速度那么快?

    同样是消息队列,Kafka凭什么速度那么快? 作者 | MrZhangxd Kafka的消息是保存或缓存在磁盘上的,一般认为在磁盘上读写数据是会降低性能的,因为寻址会比较消耗时间,但是实际上,Kafk ...

  5. Spring Cloud(7):事件驱动(Stream)分布式缓存(Redis)及消息队列(Kafka)

    分布式缓存(Redis)及消息队列(Kafka) 设想一种情况,服务A频繁的调用服务B的数据,但是服务B的数据更新的并不频繁. 实际上,这种情况并不少见,大多数情况,用户的操作更多的是查询.如果我们缓 ...

  6. 用过消息队列?Kafka?能否手写一个消息队列?懵

    是否有同样的经历?面试官问你做过啥项目,我一顿胡侃,项目利用到了消息队列,kafka,rocketMQ等等. 好的,那请开始你的表演,面试官递过一支笔:给我手写一个消息队列!!WHAT? 为了大家遇到 ...

  7. 【消息队列】kafka是如何保证消息不被重复消费的

    一.kafka自带的消费机制 kafka有个offset的概念,当每个消息被写进去后,都有一个offset,代表他的序号,然后consumer消费该数据之后,隔一段时间,会把自己消费过的消息的offs ...

  8. 【消息队列】kafka是如何保证高可用的

    一.kafka一个最基本的架构认识 由多个broker组成,每个broker就是一个节点:创建一个topic,这个topic可以划分为多个partition,每个partition可以存在于不同的br ...

  9. 消息队列之Kafka——从架构技术重新理解Kafka

    Apache Kafka® 是 一个分布式流处理平台. 这到底意味着什么呢? 我们知道流处理平台有以下三种特性: 可以让你发布和订阅流式的记录.这一方面与消息队列或者企业消息系统类似. 可以储存流式的 ...

  10. 01 . 消息队列之(Kafka+ZooKeeper)

    消息队列简介 什么是消息队列? 首先,我们来看看什么是消息队列,维基百科里的解释翻译过来如下: 队列提供了一种异步通信协议,这意味着消息的发送者和接受者不需要同时与消息保持联系,发送者发送的消息会存储 ...

随机推荐

  1. vue第三单元(webpack的应用-能根据具体的需求构建对应的开发环境)

    第三单元(webpack的应用-能根据具体的需求构建对应的开发环境) #课程目标 理解什么是单页面应用. 掌握单页面和多页面的差异. 了解单页面的实现原理. 掌握模块化的方式实现webpack配置,区 ...

  2. [日常摸鱼]bzoj1218[HNOI2003]激光炸弹-二维前缀

    题意:二维网格一些格子有权值,求用边长为$r$的正方形能覆盖到格子权值和的最大值,格子大小$ \leq 5000$ 非常裸的二维前缀,然而 题目下标从0开始! QAQ 要是比赛就要爆零啦- #incl ...

  3. Python十大装B语法!你会几种?

    本文的文字及图片来源于网络,仅供学习.交流使用,不具有任何商业用途,如有问题请及时联系我们以作处理 Python 是一种代表简单思想的语言,其语法相对简单,很容易上手.不过,如果就此小视 Python ...

  4. python字符串、列表通过值找索引/键

    python透过"值"找字符串和列表中的索引和键. 1 #!usr/bin/env python3 2 #-*- coding=utf-8 -*- 3 4 ''' 5 python ...

  5. Blogs实现导航菜单

    #1.隐藏默认导航菜单 #header{display:none;} /*在页面定制CSS里面最前面添加如下代码,最好添加在最前面*/ #2.添加页首html代码 <!-- 添加博客导航栏信息开 ...

  6. [Python] iupdatable包使用说明

    iudatable包是我对常用函数进行的封装后发布的一个python包. 安装 iupdatable 包 pip install iupdatable 更新 iupdatable 包 pip inst ...

  7. checkBox判断是否选中的方法

    这里可以分为两种情况:JQuery对象和DOM对象: 通常我们用JQuery判断元素的属性的时候喜欢用 attr("attrName"); 但是尝试过的同学可能都知道,这种方法判断 ...

  8. tomcat能正常启动,但是http://localhost:8080/网页就是打不开,报404

    问题描述: 在IDE中创建了一个新的Servers,并且加入一个Tomcat.然后启动服务,进入浏览器,输入localhost:8080进入,显示错误.服务是可以正常启动的,而且没有任何异常. 问题描 ...

  9. [数据库]000 - 🍳Sysbench 数据库压力测试工具

    000 - Sysbench 数据库压力测试工具 sysbench 是一个开源的.模块化的.跨平台的多线程性能测试工具,可以用来进行CPU.内存.磁盘I/O.线程.数据库的性能测试.目前支持的数据库有 ...

  10. Js HTML DOM动画

    基础页面 为了演示如何通过 JavaScript 来创建 html 动画,我们将使用一张简单的网页: 实例 我的第一部 JavaScript 动画 我的动画在这里. 创建动画容器 所有动画都应该与容器 ...