Kafka:生产者详解

文章目录

    • 生产者概述
    • Kafka生产者客户端开发
      • 1. 配置参数和构建生产者实例
      • 2. 三种消息发送方式
      • 3. 序列化器
        • 1. Kafka自带序列化器
        • 2. 自定义序列化器
      • 4.分区器
      • 5.拦截器
    • 具体问题探究
      • 1. 数据的有序性如何保证
      • 2. Exactly Once如何保证
      • 3. 数据可靠性保证
      • 4. 故障处理机制
    • 参考资料

生产者概述

生产者是负责向Kafka发送消息的应用程序,通常应用于:记录用户的活动、物联网硬件数据的写入、保存日志消息和缓存即将写入到数据库的数据等。
生产者向Kafka发送消息的主要步骤如下:

  1. 创建一个ProducerRecord对象,包含目标主题、内容以及键和分区信息。在发送ProducerRecord对象时,生成者要先把键和值对象序列化成字节数组
  2. 下一步,数据被发送分区器,如果没有指定分区,那么分区器就会根据ProducerRecord对象的键来选择一个分区。然后该数据会被发送到对应主题分区的批次中,最后有一个独立的线程将这些记录发送到对应的broker中。
  3. 服务器在收到这些信息时会返回一个响应,如果消息写入成功,则返回一个RecordMetaData对象(包含主题、分区信息和对应的偏移量);如果写入失败,则会返回一个错误,生成者在收到错误以后会尝试重新发送消息,几次之后还是失败则返回错误消息
    Kafka:生产者详解_第1张图片

Kafka生产者客户端开发

步骤如下:

  1. 配置生产者客户端参数及创建相应的生产者实例
  2. 构建待发送的消息
  3. 发送消息
  4. 关闭生产者实例

1. 配置参数和构建生产者实例

生成者配置参数:

  1. bootstrap.servers:broker 的地址清单,地址的格式为 host:port
  2. key.serializervalue.serializer:将key/value值序列化,Kafka 客户端默认提供了 ByteArraySerializer、StringSerializer 和 IntegerSerializer。
  3. acks:acks 参数指定了必须要有多少个分区副本收到消息,生产者才会认为消息写入是成功的。
    这个参数对消息丢失的可能性有重要影响。
    • acks=0: 生产者在成功写入消息之前不会等待任何来自服务器的响应。也就是说,如果当中出现了问题,导致服务器没有收到消息,那么生产者就无从得知,消息也就丢失了。
    • acks=1: 只要集群的首领节点收到消息,生产者就会收到一个来自服务器的成功响应。如果消息无法到达首领节点(比如首领节点崩溃,新的首领还没有被选举出来),生产者会收到一个错误响应,为了避免数据丢失,生产者会重发消息。不过,如果一个没有收到消息的节点成为新首领,消息还是会丢失。
    • acks=all: 只有当所有参与复制的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应。这种模式是最安全的,但延迟要高于acks=1时的。
  4. buffer.memory:该参数用来设置生产者内存缓冲区的大小,生产者用它缓冲要发送到服务器的消息.
  5. compression.type: 指定消息被发送给broker之前使用哪种压缩算法进行压缩。可以设置为:snappy、 gzip 或 lz4。snappy 压缩算法占用较少的 CPU,却能提供较好的性能和相当可观的压缩比,如果比较关注性能和网络带宽,可以使用这种算法。 gzip 压缩算法一般会占用较多的 CPU,但会提供更高的压缩比,所以如果网络带宽比较有限,可以使用这种算法。
  6. retries:生产者从服务器收到的错误有可能是临时性的错误,在这种情况下, retries 参数的值决定了生产者可以重发消息的次数,如果达到这个次数,生产者会放弃重试并返回错误。默认情况下,生产者会在每次重试之间等待 100ms,不过可以通过retry.backoff.ms 参数来改变这个时间间隔。
  7. batch.size:该参数指定了一个批次可以使用的内存大小,按照字节数计算(而不是消息个数)。当批次被填满,批次里的所有消息会被发送出去。建议不要把批次设置太小,因为生产者频繁地发送消息,会增加额外的开销。
  8. linger.ms:该参数指定了生产者在发送批次之前等待更多消息加入批次的时间。 KafkaProducer 会在批次填满或 linger.ms 达到上限时把批次发送出去。
  9. max.in.flight.requests.per.connection:该参数指定了生产者在收到服务器响应之前可以发送多少个消息。它的值越高,就会占用越多的内存,不过也会提升吞吐量。把它设为 1 可以保证消息是按照发送的顺序写入服务器的,即使发生了重试。
  10. timeout.ms、 request.timeout.ms 和 metadata.fetch.timeout.ms:request.timeout.ms 指定了生产者在发送数据时等待服务器返回响应的时间, metadata.fetch.timeout.ms 指定了生产者在获取元数据(比如目标分区的首领是谁)时等待服务器返回响应的时间。timeout.ms 指定了 broker 等待同步副本返回消息确认的时间,与asks 的配置相匹配。
  11. max.block.ms:指定了在调用 send() /partitionsFor()方法获取元数据时生产者的堵塞时间。
  12. max.request.size:该参数用于控制生产者发送的请求大小。它可以指能发送的单个消息的最大值,也可以指单个请求里所有消息总的大小。
  13. receive.buffer.bytes 和 send.buffer.bytes:分别代表 TCP socket 接收和发送数据包的缓冲区大小
public class KafkaProducerAns {
    public static final String brokerList="node01:9092";
    public static final String topic="topic-demo";

    public static Properties initConfig(){
        Properties prop = new Properties();
        // broker 的地址清单,地址的格式为 host:port
        prop.put("bootstrap.servers",brokerList);
        //broker 希望接收到的消息的键和值都是字节数组。
        prop.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        prop.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        //重试次数
        prop.put("retries", 1);
        //批次大小
        prop.put("batch.size", 16384);
        //等待时间
        prop.put("linger.ms", 1);
        //RecordAccumulator缓冲区大小
        prop.put("buffer.memory", 33554432);
        return prop;
    }
    public static void main(String[] args) {
        Properties properties = initConfig();
        KafkaProducer<String,String> producer = new KafkaProducer<>(properties);
        ProducerRecord<String, String> record = new ProducerRecord<>(topic, "Hello Kafka");
        try {
            producer.send(record);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

注意:ProducerRecord不仅仅只有消息,只是与业务相关的消息体只有一个value属性,以下为ProducerRecord类定义,包含主题、分区号、键、值、消息的时间戳
Kafka:生产者详解_第2张图片

2. 三种消息发送方式

发送消息的方式有三种:

  • 忘记并发送:我们把消息发送给服务器,但并不关心它是否正常到达,这种方法性能是最高的,但是可靠性是最差的
try {
     producer.send(record);
  } catch (Exception e) {
      e.printStackTrace();
  }
  • 同步发送:使用 send() 方法发送消息,它会返回一个 Future 对象,Future 对象的 get()方法等待 Kafka 响应。如果服务器返回错误, get() 方法会抛出异常。如果没有发生错误,我们会得到一个 RecordMetadata 对象,可以用它获取消息的偏移量。
try {
     producer.send(record).get();
  } catch (Exception e) {
      e.printStackTrace();
  }
  • 异步发送:只发送消息,而不等待响应。调用 send() 方法,并指定一个回调函数,服务器在返回响应时调用该函数。
    RecordMetadata对象包含了消息的一些元数据,比如主题、分区号、分区的偏移量等;KafkaProducer中一般会发生两种类型的Exception可重试的异常和不可重试的异常
    可重试异常,如果配置了retries参数,那么在指定的重试次数内自行恢复,就不会抛出异常。比如:NetworkException表示网络异常,这个有可能是由于网络瞬间故障而导致的异常,可以通过重试解决;同样的还有LeaderNotAvailableExceptionUnknownTopicOrPartitionExceptionNotEnoughReplicasException等;

注意:onCompletion方法两个参数是互斥的,消息发送成功时,metadata不为null而exception为null;
消息发送异常时,metadata为null而exception不为null;

  try {
            producer.send(record, new Callback() {
                @Override
                public void onCompletion(RecordMetadata metadata, Exception exception) {
                    if (exception!=null){
                        exception.printStackTrace();
                    }else {
                        System.out.println(metadata.topic()+"-"+
                                metadata.partition()+":"+metadata.offset());
                    }
                }
            });
        } catch (Exception e) {
            e.printStackTrace();
        }

另外,如果如下发送多条消息,对同一个分区而言,如果record1于record2之前发送,KafkaProducer可以保证对应的callback1在callback2之前调用,也就是说回调函数也可以保证分区有序。

 producer.send(record1,callback1);
 producer.send(record2,callback1);
  /**
     * Asynchronously send a record to a topic. Equivalent to send(record, null).
     * See {@link #send(ProducerRecord, Callback)} for details.
     */
    @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record) {
        return send(record, null);
    }
  /**
     * Asynchronously send a record to a topic and invoke the provided callback when the send has been acknowledged.
     * 

* The send is asynchronous and this method will return immediately once the record has been stored in the buffer of * records waiting to be sent. This allows sending many records in parallel without blocking to wait for the * response after each one. * @param record The record to send * @param callback A user-supplied callback to execute when the record has been acknowledged by the server (null * indicates no callback) */ @Override public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) { // intercept the record, which can be potentially modified; this method does not throw exceptions ProducerRecord<K, V> interceptedRecord = this.interceptors == null ? record : this.interceptors.onSend(record); return doSend(interceptedRecord, callback); }

注意: send()方法包含两个重载方法,返回类型为Future,send方法本身是异步的,通过返回的Future对象可以是调用放稍后获得发送结果

在消息发送完以后,需要调用KafkaProducer的close()方法来回收资源。

producer.close();

3. 序列化器

生产者需要用序列化器(Serializer)把对象转换成字节数组才能通过网络发送给Kafka。

1. Kafka自带序列化器

上述有提到,常见的序列化器有ByteArraySerializer、StringSerializer 和 IntegerSerializer等等。他们都实现了org.apache.kafka.common.serialization.Serializer接口。该接口包含三个方法:
**configure()**方法用来配置当前类,**serialize()**方法用来执行序列化操作,**close()**方法用来关闭当前序列化器(一般情况下,close()是一个空方法,如果实现了此方法,则必须保证此方法的幂等性).

public interface Serializer<T> extends Closeable {
    public void configure(Map<String, ?> configs, boolean isKey);
    public byte[] serialize(String topic, T data);
    public void close();

StringSerializer的实现:
configure方法:在创建KafkaProducer实例时调用的,主要用来确认编码格式;
serialize方法:将String类型转成byte[]类型

public class StringSerializer implements Serializer<String> {
    private String encoding = "UTF8";
    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.serializer.encoding" : "value.serializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null)
            encodingValue = configs.get("serializer.encoding");
        if (encodingValue != null && encodingValue instanceof String)
            encoding = (String) encodingValue;
    }
    @Override
    public byte[] serialize(String topic, String data) {
        try {
            if (data == null)
                return null;
            else
                return data.getBytes(encoding);
        } catch (UnsupportedEncodingException e) {
            throw new SerializationException("Error when serializing string to byte[] due to unsupported encoding " + encoding);
        }
    }
    @Override
    public void close() {
        // nothing to do
    }
}

如果Kafka客户端提供的几种序列化器都无法满足需求,则可以选择Avro、Thrift等序列化工具实现,或者使用自定义序列化器

2. 自定义序列化器

假设发送一个Company对象如下,自定义一个CompanySerializer

public class Company {
    private String Name;
    private String address;
    public Company() {
    }

    public String getName() {
        return Name;
    }
    public void setName(String name) {
        Name = name;
    }
    public String getAddress() {
        return address;
    }
    public void setAddress(String address) {
        this.address = address;
    }
}

CompanySerializer:

public class CompanySerializer implements Serializer<Company> {
    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {

    }

    @Override
    public byte[] serialize(String topic, Company data) {
        if (data==null){
            return null;
        }
        byte[] name,address;
        try {
            if (data.getName()!=null){
                name=data.getName().getBytes("UTF-8");
            }else {
                name=new byte[0];
            }
            if (data.getAddress()!=null){
                address=data.getAddress().getBytes("UTF-8");
            }else {
                address=new byte[0];
            }
            ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + name.length + address.length);
            buffer.putInt(name.length);
            buffer.put(name);
            buffer.putInt(address.length);
            buffer.put(address);
            return buffer.array();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return new byte[0];
    }

    @Override
    public void close() {

    }
}

CompanySerializer的使用,直接将value.serializer的参数设置为CompanySerializer全限定名:

 prop.put("value.serializer", CompanySerializer.class.getName());

注意:自定义序列化器很简单,但是不建议使用,因为在不同版本的序列化器和反序列化器之间调试兼容性问题着实是个挑战,同时如果序列化器发生改动,所有消费数据的程序都要改动。建议使用Avro等序列化工具。

4.分区器

  1. 分区的原因

    • 方便在集群中扩展,每个Partition可以通过调整以适应它所在的机器,而一个topic又可以有多个Partition组成,因此整个集群就可以适应任意大小的数据了;
    • 可以提高并发,因为可以以Partition为单位读写了。
  2. 分区的原则
    生产者通过封装一个ProducerRecord对象发送数据给Kafka,ProducerRecord 对象包含了目标主题、键和值。ProducerRecord 对象可以只包含目标主题和值,键可以设置为默认的 null,不过大多数应用程序会用到键。
    键有两个用途:可以作为消息的附加信息,也可以用来决定消息该被写到主题的哪个分区。
    拥有相同键的消息将通过散列算法被发送到同一个分区;
    如果键值为 null,并且使用了默认的分区器,那么记录将被随机地发送到主题内各个可用的分区上。分区器使用轮询(Round Robin)算法将消息均衡地分布到各个分区上。

  3. 自定义分区策略
    Kafka中默认通过的分区器是org.apache.kafka.clients.producer.internals.DefaultPartitioner。源码如下,它继承了Partitioner 接口实现了partition()方法和close方法。

public class DefaultPartitioner implements Partitioner {
    private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();
    public void configure(Map<String, ?> configs) {}
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
    	//获取topic分区列表及其个数
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        //如果键为null
        if (keyBytes == null) {
        	//使用轮询方式获取分区号,为可用分区中的任意一个
            int nextValue = nextValue(topic);
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            // 键值不为空,通过散列算法寻找对应分区号
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    private int nextValue(String topic) {
        AtomicInteger counter = topicCounterMap.get(topic);
        if (null == counter) {
            counter = new AtomicInteger(new Random().nextInt());
            AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
            if (currentCounter != null) {
                counter = currentCounter;
            }
        }
        return counter.getAndIncrement();
    }
    public void close() {}
}

**注意:**只有在不改变主题分区数量的情况下,键与分区之间的映射才能保持不变。如果增加了分区,就很难保证key与分区之间的映射关系。

我们也可以自定义分区器,继承Partitioner 接口即可。如下,将默认分区器中,改变key为null时不会选择不可用分区。

public class DemoPartitioner implements Partitioner {
    private final AtomicInteger counter=new AtomicInteger(0);
    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitionInfos = cluster.partitionsForTopic(topic);
        int numPartitions = partitionInfos.size();
        if (null== keyBytes){
            return counter.getAndIncrement() % numPartitions;
            
        }else {
            return Utils.toPositive(Utils.murmur2(keyBytes))%numPartitions;
        }
    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> configs) {

    }
}

使用自定义分区器:

 prop.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,DemoPartitioner.class.getName());

5.拦截器

      拦截器是Kafka0.10.0中引入 一个功能,生产者拦截器可以用来在发送消息前做一些准备工作,比如过滤消息、修改消息内容等,也可以用来发送回调逻辑前做一些定制化的需求,比如统计类工作。
生产者拦截器通过继承org.apache.kafka.clients.producer.ProducerInterceptor接口实现,ProducerInterceptor包含三个方法:

  1. 生产者在将消息序列化和计算分区之前,会调用onSend方法来对消息进行定制化操作。(注意:不要修改ProducerInterceptor中的kv,topic等信息)
  2. 生产者会在消息被应答(Acknowledgement)前或者消息发送失败时调用 onAcknowledgement方法,优先于用户设置的Callback之前执行。
public interface ProducerInterceptor<K, V> extends Configurable {
    public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record);
    public void onAcknowledgement(RecordMetadata metadata, Exception exception);
    public void close();
 }

案例:自定义一个拦截器,在发送消息前给每条消息前缀加上"prefix-",并计算发送消息的成功率:

public class ProducerInterceptotPrefix implements ProducerInterceptor<String,String> {
    private volatile long sendSuccess=0;
    private volatile long sendFailure=0;
    
    @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        String newValue = "prefix1-" + record.value();
        return new ProducerRecord<>(record.topic(),record.partition(),
                record.timestamp(),record.key(),newValue);
    }
    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        if (exception==null){
            sendSuccess++;
        }else {
            sendFailure++;
        }
    }
    @Override
    public void close() {
        double successRatio = sendSuccess / (sendSuccess + sendFailure);
        System.out.println("[INFO] 发送成功率"+String.format("%f",successRatio*100)+"%");
    }
    @Override
    public void configure(Map<String, ?> configs) {
    }
}

使用:

prop.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,ProducerInterceptotPrefix.class.getName());

具体问题探究

1. 数据的有序性如何保证

Kafka可以保证一个分区里的消息是有序的,也就是说,如果生产者按照一定的顺序发送消息, broker 就会按照这个顺序把它们写入分区,消费者也会按照同样的顺序读取它们。

1. 为什么只保证单个分区内数据有序?
      如果Kafka要保证多个partition有序,不仅broker保存的数据要保持顺序,消费时也要按序消费。假设partition1堵了,为了有序,那partition2以及后续的分区也不能被消费,这种情况下,Kafka 就退化成了单一队列,毫无并发性可言,极大降低系统性能。因此Kafka使用多partition的概念,并且只保证单partition有序。这样不同partiiton之间不会干扰对方。
2. 调参与优化
       通过调整retries(失败重试次数)和max.in.flight.requests.per.connection(生产者收到响应前可以发送多少个消息)可以影响消息发送顺序;如果对生产者吞吐量要求比较大,对消息顺序要求不高时候,设置retries大于0,同时把max.in.flight.requests.per.connection设为比1大的整数,那么,如果第一个批次消息写入失败,而第二个批次写入成功, broker 会重试写入第一个批次。如果此时第一个批次也写入成功,那么两个批次的顺序就反过来了;如果要求消息必须是有序的,那么建议将max.in.flight.requests.per.connection设为1,且retries不为0,这样当第一批次消息发送失败时,也不会有第二个批次发送消息给broker,不过这样也严重影响生产者的吞吐量。

2. Exactly Once如何保证

     将服务器的ACK级别设置为-1(all),可以保证Producer到Server之间不会丢失数据,即At Least Once语义。相对的,将服务器ACK级别设置为0,可以保证生产者每条消息只会被发送一次,即At Most Once语义。At Least Once可以保证数据不丢失,但是不能保证数据不重复;相对的,At Least Once可以保证数据不重复,但是不能保证数据不丢失。但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即Exactly Once语义。在0.11版本以前的Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。

   0.11版本的Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指Producer不论向Server发送多少次重复数据,Server端都只会持久化一条。幂等性结合At Least Once语义,就构成了Kafka的Exactly Once语义。即:
At Least Once + 幂等性 = Exactly Once
   要启用幂等性,只需要将Producer的参数中enable.idompotence设置为true即可。Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。

   开启幂等性的Producer在初始化的时候会被分配一个PID,发往同一Partition的消息会附带Sequence Number。而Broker端会对做缓存,当具有相同主键的消息提交时,Broker只会持久化一条。但是PID重启就会变化,同时不同的Partition也具有不同主键,所以幂等性无法保证跨分区跨会话的Exactly Once。

3. 数据可靠性保证

   Kafka生产者通过ack机制保证数据的可靠性。
   ack机制:为保证producer发送的数据,能可靠的发送到指定的topic,topic的每个partition收到producer发送的数据后,都需要向producer发送ack(acknowledgement确认收到),如果producer收到ack,就会进行下一轮的发送,否则重新发送数据。

  1. 副本数据同步策略
方案 优点 缺点
半数以上完成同步,就发送ack 延迟低 选举新的leader时,容忍n台节点的故障,需要2n+1个副本
全部完成同步,才发送ack 选举新的leader时,容忍n台节点的故障,需要n+1个副本 延迟高

Kafka选择了第二种方案,原因如下:

  • 同样为了容忍n台节点的故障,第一种方案需要2n+1个副本,而第二种方案只需要n+1个副本,而Kafka的每个分区都有大量的数据,第一种方案会造成大量数据的冗余。
  • 虽然第二种方案的网络延迟会比较高,但网络延迟对Kafka的影响较小。
  1. ISR
        采用第二种方案之后,设想以下情景:leader收到数据,所有follower都开始同步数据,但有一个follower,因为某种故障,迟迟不能与leader进行同步,那leader就要一直等下去,直到它完成同步,才能发送ack。这个问题怎么解决呢?
        Leader维护了一个动态的in-sync replica set (ISR),意为和leader保持同步的follower集合。当ISR中的follower完成数据的同步之后,leader就会给producer发送ack。如果follower长时间未向leader同步数据,则该follower将被踢出ISR,该时间阈值由replica.lag.time.max.ms参数设定。Leader发生故障之后,就会从ISR中选举新的leader。

4. 故障处理机制

Kafka:生产者详解_第3张图片

  1. follower故障
    follower发生故障后会被临时踢出ISR,待该follower恢复后,follower会读取本地磁盘记录的上次的HW,并将log文件高于HW的部分截取掉,从HW开始向leader进行同步。等该follower的LEO大于等于该Partition的HW,即follower追上leader之后,就可以重新加入ISR了。
  2. leader故障
    leader发生故障之后,会从ISR中选出一个新的leader,之后,为保证多个副本之间的数据一致性,其余的follower会先将各自的log文件高于HW的部分截掉,然后从新的leader同步数据。
    注意: 这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

参考资料

  1. 《Kafka权威指南》
  2. 《深入理解Kafka核心技术与实践原理》
  3. 尚硅谷Kafka视频(B站)

你可能感兴趣的:(大数据组件,大数据,kafka,java)