diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannel.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannel.java index ad1a385d9a83..88090a70fdb9 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannel.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannel.java @@ -18,7 +18,6 @@ import java.io.Closeable; import java.io.IOException; -import java.io.Serializable; import java.nio.channels.ReadableByteChannel; /** @@ -28,7 +27,7 @@ * * This class is @{link Serializable}, which allows incremental reads. */ -public interface BlobReadChannel extends ReadableByteChannel, Serializable, Closeable { +public interface BlobReadChannel extends ReadableByteChannel, Closeable { /** * Overridden to remove IOException. @@ -46,4 +45,27 @@ public interface BlobReadChannel extends ReadableByteChannel, Serializable, Clos */ void chunkSize(int chunkSize); + /** + * Saves the read channel state. + * + * @return an object that contains the read channel state and can restore it afterwards. State + * object must implement {@link java.io.Serializable}. + */ + public State save(); + + /** + * A common interface for all classes that implement the internal state of a + * {@code BlobReadChannel}. + * + * Implementations of this class must implement {@link java.io.Serializable} to ensure that the + * state of a channel can be correctly serialized. + */ + public interface State { + + /** + * Returns a {@code BlobReadChannel} whose internal state reflects the one saved in the + * invocation object. + */ + public BlobReadChannel restore(); + } } diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannelImpl.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannelImpl.java index 79fe2fd1e531..8c94e84cd505 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannelImpl.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobReadChannelImpl.java @@ -19,14 +19,15 @@ import static com.google.gcloud.RetryHelper.runWithRetries; import com.google.api.services.storage.model.StorageObject; +import com.google.common.base.MoreObjects; import com.google.gcloud.RetryHelper; import com.google.gcloud.spi.StorageRpc; import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; +import java.io.Serializable; import java.nio.ByteBuffer; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Callable; /** @@ -35,7 +36,6 @@ class BlobReadChannelImpl implements BlobReadChannel { private static final int DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024; - private static final long serialVersionUID = 4821762590742862669L; private final StorageOptions serviceOptions; private final BlobId blob; @@ -45,10 +45,10 @@ class BlobReadChannelImpl implements BlobReadChannel { private boolean endOfStream; private int chunkSize = DEFAULT_CHUNK_SIZE; - private transient StorageRpc storageRpc; - private transient StorageObject storageObject; - private transient int bufferPos; - private transient byte[] buffer; + private StorageRpc storageRpc; + private StorageObject storageObject; + private int bufferPos; + private byte[] buffer; BlobReadChannelImpl(StorageOptions serviceOptions, BlobId blob, Map requestOptions) { @@ -59,19 +59,18 @@ class BlobReadChannelImpl implements BlobReadChannel { initTransients(); } - private void writeObject(ObjectOutputStream out) throws IOException { + @Override + public State save() { + StateImpl.Builder builder = StateImpl.builder(serviceOptions, blob, requestOptions) + .position(position) + .isOpen(isOpen) + .endOfStream(endOfStream) + .chunkSize(chunkSize); if (buffer != null) { - position += bufferPos; - buffer = null; - bufferPos = 0; - endOfStream = false; + builder.position(position + bufferPos); + builder.endOfStream(false); } - out.defaultWriteObject(); - } - - private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { - in.defaultReadObject(); - initTransients(); + return builder.build(); } private void initTransients() { @@ -148,4 +147,116 @@ public byte[] call() { } return toWrite; } + + static class StateImpl implements BlobReadChannel.State, Serializable { + + private static final long serialVersionUID = 3889420316004453706L; + + private final StorageOptions serviceOptions; + private final BlobId blob; + private final Map requestOptions; + private final int position; + private final boolean isOpen; + private final boolean endOfStream; + private final int chunkSize; + + StateImpl(Builder builder) { + this.serviceOptions = builder.serviceOptions; + this.blob = builder.blob; + this.requestOptions = builder.requestOptions; + this.position = builder.position; + this.isOpen = builder.isOpen; + this.endOfStream = builder.endOfStream; + this.chunkSize = builder.chunkSize; + } + + public static class Builder { + private final StorageOptions serviceOptions; + private final BlobId blob; + private final Map requestOptions; + private int position; + private boolean isOpen; + private boolean endOfStream; + private int chunkSize; + + private Builder(StorageOptions options, BlobId blob, Map reqOptions) { + this.serviceOptions = options; + this.blob = blob; + this.requestOptions = reqOptions; + } + + public Builder position(int position) { + this.position = position; + return this; + } + + public Builder isOpen(boolean isOpen) { + this.isOpen = isOpen; + return this; + } + + public Builder endOfStream(boolean endOfStream) { + this.endOfStream = endOfStream; + return this; + } + + public Builder chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public State build() { + return new StateImpl(this); + } + } + + public static Builder builder( + StorageOptions options, BlobId blob, Map reqOptions) { + return new Builder(options, blob, reqOptions); + } + + @Override + public BlobReadChannel restore() { + BlobReadChannelImpl channel = new BlobReadChannelImpl(serviceOptions, blob, requestOptions); + channel.position = position; + channel.isOpen = isOpen; + channel.endOfStream = endOfStream; + channel.chunkSize = chunkSize; + return channel; + } + + @Override + public int hashCode() { + return Objects.hash(serviceOptions, blob, requestOptions, position, isOpen, endOfStream, + chunkSize); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof StateImpl)) { + return false; + } + final StateImpl other = (StateImpl) obj; + return Objects.equals(this.serviceOptions, other.serviceOptions) && + Objects.equals(this.blob, other.blob) && + Objects.equals(this.requestOptions, other.requestOptions) && + this.position == other.position && + this.isOpen == other.isOpen && + this.endOfStream == other.endOfStream && + this.chunkSize == other.chunkSize; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("blob", blob) + .add("position", position) + .add("isOpen", isOpen) + .add("endOfStream", endOfStream) + .toString(); + } + } } diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannel.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannel.java index 20b2ce087632..97ad591d2a74 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannel.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannel.java @@ -17,7 +17,6 @@ package com.google.gcloud.storage; import java.io.Closeable; -import java.io.Serializable; import java.nio.channels.WritableByteChannel; /** @@ -27,11 +26,38 @@ * data will only be visible after calling {@link #close()}. This class is serializable, to allow * incremental writes. */ -public interface BlobWriteChannel extends WritableByteChannel, Serializable, Closeable { +public interface BlobWriteChannel extends WritableByteChannel, Closeable { /** * Sets the minimum size that will be written by a single RPC. * Written data will be buffered and only flushed upon reaching this size or closing the channel. */ void chunkSize(int chunkSize); + + /** + * Saves the write channel state. + * + * @return an object that contains the write channel state and can restore it afterwards. State + * object must implement {@link java.io.Serializable}. + */ + public State save(); + + /** + * A common interface for all classes that implement the internal state of a + * {@code BlobWriteChannel}. + * + * Implementations of this class must implement {@link java.io.Serializable} to ensure that the + * state of a channel can be correctly serialized. + */ + public interface State { + + /** + * Returns a {@code BlobWriteChannel} whose internal state reflects the one saved in the + * invocation object. + * + * The original {@code BlobWriteChannel} and the restored one should not both be used. Closing + * one channel causes the other channel to close, subsequent writes will fail. + */ + public BlobWriteChannel restore(); + } } diff --git a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannelImpl.java b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannelImpl.java index 8cb95a797cf6..7d01238b0322 100644 --- a/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannelImpl.java +++ b/gcloud-java-storage/src/main/java/com/google/gcloud/storage/BlobWriteChannelImpl.java @@ -20,15 +20,16 @@ import static java.util.concurrent.Executors.callable; import com.google.api.services.storage.model.StorageObject; +import com.google.common.base.MoreObjects; import com.google.gcloud.RetryHelper; import com.google.gcloud.spi.StorageRpc; import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; +import java.io.Serializable; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Map; +import java.util.Objects; /** * Default implementation for BlobWriteChannel. @@ -48,8 +49,8 @@ class BlobWriteChannelImpl implements BlobWriteChannel { private boolean isOpen = true; private int chunkSize = DEFAULT_CHUNK_SIZE; - private transient StorageRpc storageRpc; - private transient StorageObject storageObject; + private StorageRpc storageRpc; + private StorageObject storageObject; BlobWriteChannelImpl(StorageOptions options, BlobInfo blobInfo, Map optionsMap) { @@ -59,11 +60,24 @@ class BlobWriteChannelImpl implements BlobWriteChannel { uploadId = storageRpc.open(storageObject, optionsMap); } - private void writeObject(ObjectOutputStream out) throws IOException { + BlobWriteChannelImpl(StorageOptions options, BlobInfo blobInfo, String uploadId) { + this.options = options; + this.blobInfo = blobInfo; + this.uploadId = uploadId; + initTransients(); + } + + @Override + public State save() { if (isOpen) { flush(true); } - out.defaultWriteObject(); + return StateImpl.builder(options, blobInfo, uploadId) + .position(position) + .buffer(buffer) + .limit(limit) + .isOpen(isOpen) + .chunkSize(chunkSize).build(); } private void flush(boolean compact) { @@ -87,13 +101,6 @@ public void run() { } } - private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { - in.defaultReadObject(); - if (isOpen) { - initTransients(); - } - } - private void initTransients() { storageRpc = options.storageRpc(); storageObject = blobInfo.toPb(); @@ -150,4 +157,125 @@ public void chunkSize(int chunkSize) { chunkSize = (chunkSize / MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE; this.chunkSize = Math.max(MIN_CHUNK_SIZE, chunkSize); } + + static class StateImpl implements State, Serializable { + + private static final long serialVersionUID = 8541062465055125619L; + + private final StorageOptions serviceOptions; + private final BlobInfo blobInfo; + private final String uploadId; + private final int position; + private final byte[] buffer; + private final int limit; + private final boolean isOpen; + private final int chunkSize; + + StateImpl(Builder builder) { + this.serviceOptions = builder.serviceOptions; + this.blobInfo = builder.blobInfo; + this.uploadId = builder.uploadId; + this.position = builder.position; + this.buffer = builder.buffer; + this.limit = builder.limit; + this.isOpen = builder.isOpen; + this.chunkSize = builder.chunkSize; + } + + public static class Builder { + private final StorageOptions serviceOptions; + private final BlobInfo blobInfo; + private final String uploadId; + private int position; + private byte[] buffer; + private int limit; + private boolean isOpen; + private int chunkSize; + + private Builder(StorageOptions options, BlobInfo blobInfo, String uploadId) { + this.serviceOptions = options; + this.blobInfo = blobInfo; + this.uploadId = uploadId; + } + + public Builder position(int position) { + this.position = position; + return this; + } + + public Builder buffer(byte[] buffer) { + this.buffer = buffer.clone(); + return this; + } + + public Builder limit(int limit) { + this.limit = limit; + return this; + } + + public Builder isOpen(boolean isOpen) { + this.isOpen = isOpen; + return this; + } + + public Builder chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public State build() { + return new StateImpl(this); + } + } + + public static Builder builder(StorageOptions options, BlobInfo blobInfo, String uploadId) { + return new Builder(options, blobInfo, uploadId); + } + + @Override + public BlobWriteChannel restore() { + BlobWriteChannelImpl channel = new BlobWriteChannelImpl(serviceOptions, blobInfo, uploadId); + channel.position = position; + channel.buffer = buffer.clone(); + channel.limit = limit; + channel.isOpen = isOpen; + channel.chunkSize = chunkSize; + return channel; + } + + @Override + public int hashCode() { + return Objects.hash(serviceOptions, blobInfo, uploadId, position, limit, isOpen, chunkSize, + Arrays.hashCode(buffer)); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (!(obj instanceof StateImpl)) { + return false; + } + final StateImpl other = (StateImpl) obj; + return Objects.equals(this.serviceOptions, other.serviceOptions) && + Objects.equals(this.blobInfo, other.blobInfo) && + Objects.equals(this.uploadId, other.uploadId) && + Objects.deepEquals(this.buffer, other.buffer) && + this.position == other.position && + this.limit == other.limit && + this.isOpen == other.isOpen && + this.chunkSize == other.chunkSize; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("blobInfo", blobInfo) + .add("uploadId", uploadId) + .add("position", position) + .add("isOpen", isOpen) + .toString(); + } + } } diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobReadChannelImplTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobReadChannelImplTest.java index c5c9a0e48612..9e6e159fe90e 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobReadChannelImplTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobReadChannelImplTest.java @@ -180,6 +180,46 @@ public void testReadClosed() { } } + @Test + public void testSaveAndRestore() throws IOException, ClassNotFoundException { + EasyMock.expect(optionsMock.storageRpc()).andReturn(storageRpcMock).times(2); + EasyMock.expect(optionsMock.retryParams()).andReturn(RetryParams.noRetries()).times(2); + EasyMock.replay(optionsMock); + byte[] firstResult = randomByteArray(DEFAULT_CHUNK_SIZE); + byte[] secondResult = randomByteArray(DEFAULT_CHUNK_SIZE); + ByteBuffer firstReadBuffer = ByteBuffer.allocate(42); + ByteBuffer secondReadBuffer = ByteBuffer.allocate(DEFAULT_CHUNK_SIZE); + EasyMock + .expect(storageRpcMock.read(BLOB_ID.toPb(), EMPTY_RPC_OPTIONS, 0, DEFAULT_CHUNK_SIZE)) + .andReturn(firstResult); + EasyMock + .expect(storageRpcMock.read(BLOB_ID.toPb(), EMPTY_RPC_OPTIONS, 42, DEFAULT_CHUNK_SIZE)) + .andReturn(secondResult); + EasyMock.replay(storageRpcMock); + reader = new BlobReadChannelImpl(optionsMock, BLOB_ID, EMPTY_RPC_OPTIONS); + reader.read(firstReadBuffer); + BlobReadChannel.State readerState = reader.save(); + BlobReadChannel restoredReader = readerState.restore(); + restoredReader.read(secondReadBuffer); + assertArrayEquals(Arrays.copyOf(firstResult, firstReadBuffer.capacity()), + firstReadBuffer.array()); + assertArrayEquals(secondResult, secondReadBuffer.array()); + } + + @Test + public void testStateEquals() { + EasyMock.expect(optionsMock.storageRpc()).andReturn(storageRpcMock).times(2); + EasyMock.replay(optionsMock); + EasyMock.replay(storageRpcMock); + reader = new BlobReadChannelImpl(optionsMock, BLOB_ID, EMPTY_RPC_OPTIONS); + BlobReadChannel secondReader = new BlobReadChannelImpl(optionsMock, BLOB_ID, EMPTY_RPC_OPTIONS); + BlobReadChannel.State state = reader.save(); + BlobReadChannel.State secondState = secondReader.save(); + assertEquals(state, secondState); + assertEquals(state.hashCode(), secondState.hashCode()); + assertEquals(state.toString(), secondState.toString()); + } + private static byte[] randomByteArray(int size) { byte[] byteArray = new byte[size]; RANDOM.nextBytes(byteArray); diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobWriteChannelImplTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobWriteChannelImplTest.java index 54135cc9990d..7e7d537fc119 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobWriteChannelImplTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/BlobWriteChannelImplTest.java @@ -27,7 +27,9 @@ import com.google.gcloud.spi.StorageRpc; import org.easymock.Capture; +import org.easymock.CaptureType; import org.easymock.EasyMock; +import org.junit.After; import org.junit.Test; import org.junit.Before; @@ -36,7 +38,6 @@ import java.util.Arrays; import java.util.Map; import java.util.Random; -import org.junit.After; public class BlobWriteChannelImplTest { @@ -192,6 +193,48 @@ public void testWriteClosed() throws IOException { } } + @Test + public void testSaveAndRestore() throws IOException { + EasyMock.expect(optionsMock.storageRpc()).andReturn(storageRpcMock).times(2); + EasyMock.expect(optionsMock.retryParams()).andReturn(RetryParams.noRetries()).times(2); + EasyMock.replay(optionsMock); + EasyMock.expect(storageRpcMock.open(BLOB_INFO.toPb(), EMPTY_RPC_OPTIONS)).andReturn(UPLOAD_ID); + Capture capturedBuffer = Capture.newInstance(CaptureType.ALL); + Capture capturedPosition = Capture.newInstance(CaptureType.ALL); + storageRpcMock.write(EasyMock.eq(UPLOAD_ID), EasyMock.capture(capturedBuffer), EasyMock.eq(0), + EasyMock.eq(BLOB_INFO.toPb()), EasyMock.captureLong(capturedPosition), + EasyMock.eq(DEFAULT_CHUNK_SIZE), EasyMock.eq(false)); + EasyMock.expectLastCall().times(2); + EasyMock.replay(storageRpcMock); + ByteBuffer buffer1 = randomBuffer(DEFAULT_CHUNK_SIZE); + ByteBuffer buffer2 = randomBuffer(DEFAULT_CHUNK_SIZE); + writer = new BlobWriteChannelImpl(optionsMock, BLOB_INFO, EMPTY_RPC_OPTIONS); + assertEquals(DEFAULT_CHUNK_SIZE, writer.write(buffer1)); + assertArrayEquals(buffer1.array(), capturedBuffer.getValues().get(0)); + assertEquals(new Long(0L), capturedPosition.getValues().get(0)); + BlobWriteChannel.State writerState = writer.save(); + BlobWriteChannel restoredWriter = writerState.restore(); + assertEquals(DEFAULT_CHUNK_SIZE, restoredWriter.write(buffer2)); + assertArrayEquals(buffer2.array(), capturedBuffer.getValues().get(1)); + assertEquals(new Long(DEFAULT_CHUNK_SIZE), capturedPosition.getValues().get(1)); + } + + @Test + public void testStateEquals() { + EasyMock.expect(optionsMock.storageRpc()).andReturn(storageRpcMock).times(2); + EasyMock.replay(optionsMock); + EasyMock.expect(storageRpcMock.open(BLOB_INFO.toPb(), EMPTY_RPC_OPTIONS)).andReturn(UPLOAD_ID) + .times(2); + EasyMock.replay(storageRpcMock); + writer = new BlobWriteChannelImpl(optionsMock, BLOB_INFO, EMPTY_RPC_OPTIONS); + BlobWriteChannel writer2 = new BlobWriteChannelImpl(optionsMock, BLOB_INFO, EMPTY_RPC_OPTIONS); + BlobWriteChannel.State state = writer.save(); + BlobWriteChannel.State state2 = writer2.save(); + assertEquals(state, state2); + assertEquals(state.hashCode(), state2.hashCode()); + assertEquals(state.toString(), state2.toString()); + } + private static ByteBuffer randomBuffer(int size) { byte[] byteArray = new byte[size]; RANDOM.nextBytes(byteArray); diff --git a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/SerializationTest.java b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/SerializationTest.java index 2dd1b8cbf895..444d516e5530 100644 --- a/gcloud-java-storage/src/test/java/com/google/gcloud/storage/SerializationTest.java +++ b/gcloud-java-storage/src/test/java/com/google/gcloud/storage/SerializationTest.java @@ -19,8 +19,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; +import com.google.common.collect.ImmutableMap; import com.google.gcloud.AuthCredentials; import com.google.gcloud.RetryParams; +import com.google.gcloud.spi.StorageRpc; import com.google.gcloud.storage.Acl.Project.ProjectRole; import org.junit.Test; @@ -32,6 +34,7 @@ import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.Collections; +import java.util.Map; public class SerializationTest { @@ -64,6 +67,7 @@ public class SerializationTest { Storage.BucketSourceOption.metagenerationMatch(1); private static final Storage.BucketTargetOption BUCKET_TARGET_OPTIONS = Storage.BucketTargetOption.metagenerationNotMatch(); + private static final Map EMPTY_RPC_OPTIONS = ImmutableMap.of(); @Test public void testServiceOptions() throws Exception { @@ -100,8 +104,40 @@ public void testModelAndRequests() throws Exception { } } + @Test + public void testReadChannelState() throws IOException, ClassNotFoundException { + StorageOptions options = StorageOptions.builder() + .projectId("p2") + .retryParams(RetryParams.getDefaultInstance()) + .authCredentials(AuthCredentials.noCredentials()) + .build(); + BlobReadChannel reader = + new BlobReadChannelImpl(options, BlobId.of("b", "n"), EMPTY_RPC_OPTIONS); + BlobReadChannel.State state = reader.save(); + BlobReadChannel.State deserializedState = serializeAndDeserialize(state); + assertEquals(state, deserializedState); + assertEquals(state.hashCode(), deserializedState.hashCode()); + assertEquals(state.toString(), deserializedState.toString()); + } + + @Test + public void testWriteChannelState() throws IOException, ClassNotFoundException { + StorageOptions options = StorageOptions.builder() + .projectId("p2") + .retryParams(RetryParams.getDefaultInstance()) + .authCredentials(AuthCredentials.noCredentials()) + .build(); + BlobWriteChannelImpl writer = new BlobWriteChannelImpl( + options, BlobInfo.builder(BlobId.of("b", "n")).build(), "upload-id"); + BlobWriteChannel.State state = writer.save(); + BlobWriteChannel.State deserializedState = serializeAndDeserialize(state); + assertEquals(state, deserializedState); + assertEquals(state.hashCode(), deserializedState.hashCode()); + assertEquals(state.toString(), deserializedState.toString()); + } + @SuppressWarnings("unchecked") - private T serializeAndDeserialize(T obj) + private T serializeAndDeserialize(T obj) throws IOException, ClassNotFoundException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {