/* * Copyright 2012 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.appengine.tools.cloudstorage.dev; import static com.google.appengine.api.datastore.Entity.KEY_RESERVED_PROPERTY; import static com.google.appengine.api.datastore.Query.FilterOperator.GREATER_THAN_OR_EQUAL; import static com.google.common.base.Preconditions.checkNotNull; import com.google.appengine.api.NamespaceManager; import com.google.appengine.api.ThreadManager; import com.google.appengine.api.blobstore.BlobInfo; import com.google.appengine.api.blobstore.BlobInfoFactory; import com.google.appengine.api.blobstore.BlobKey; import com.google.appengine.api.blobstore.BlobstoreService; import com.google.appengine.api.blobstore.BlobstoreServiceFactory; import com.google.appengine.api.datastore.Blob; import com.google.appengine.api.datastore.Cursor; import com.google.appengine.api.datastore.DatastoreService; import com.google.appengine.api.datastore.DatastoreServiceFactory; import com.google.appengine.api.datastore.Entity; import com.google.appengine.api.datastore.EntityNotFoundException; import com.google.appengine.api.datastore.FetchOptions; import com.google.appengine.api.datastore.Key; import com.google.appengine.api.datastore.KeyFactory; import com.google.appengine.api.datastore.Query; import com.google.appengine.api.datastore.Query.FilterPredicate; import com.google.appengine.api.datastore.QueryResultIterator; import com.google.appengine.api.datastore.Transaction; import com.google.appengine.tools.cloudstorage.BadRangeException; import com.google.appengine.tools.cloudstorage.GcsFileMetadata; import com.google.appengine.tools.cloudstorage.GcsFileOptions; import com.google.appengine.tools.cloudstorage.GcsFilename; import com.google.appengine.tools.cloudstorage.ListItem; import com.google.appengine.tools.cloudstorage.RawGcsService; import com.google.apphosting.api.ApiProxy; import com.google.apphosting.api.ApiProxy.Delegate; import com.google.apphosting.api.ApiProxy.Environment; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.Iterables; import com.google.common.util.concurrent.Futures; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.channels.WritableByteChannel; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * Implementation of {@code RawGcsService} for dev_appserver. For now, uses datastore and * fileService so that the viewers can be re-used. * * This class is not a perfect singleton. If methods are called concurrently from * different threads, it is possible that multiple instances of BlobStorage may be used. */ @SuppressWarnings("deprecation") final class LocalRawGcsService implements RawGcsService { static final int CHUNK_ALIGNMENT_BYTES = 256 * 1024; private DatastoreService datastore; private BlobStorageAdapter blobStorage; private BlobstoreService blobstoreService; private static final String GOOGLE_STORAGE_FILE_KIND = "__GsFileInfo__"; private static final String ENTITY_KIND_PREFIX = "_ah_FakeCloudStorage__"; private static final String OPTIONS_PROP = "options"; private static final String CREATION_TIME_PROP = "time"; private static final String FILE_LENGTH_PROP = "length"; private static final String BLOBSTORE_META_KIND = "__BlobInfo__"; private static final HashMap<GcsFilename, List<ByteBuffer>> inMemoryData = new HashMap<>(); private static class BlobStorageAdapter { private static final String BLOB_KEY_CLASS_NAME = BlobKey.class.getName(); private static BlobStorageAdapter instance; private final Delegate<?> apiProxyDelegate; private final Object delegate; private final Method storeBlobMethod; private final Method fetchBlobMethod; private final Constructor<?> blobKeyConstructor; BlobStorageAdapter(Delegate<?> apiProxyDelegate) throws Exception { this.apiProxyDelegate = apiProxyDelegate; Method m = apiProxyDelegate.getClass().getDeclaredMethod("getService", String.class); m.setAccessible(true); Object bs = m.invoke(apiProxyDelegate, "blobstore"); Field f = bs.getClass().getDeclaredField("blobStorage"); f.setAccessible(true); delegate = f.get(bs); Class<?> delegateClass = delegate.getClass(); Class<?> blobKeyClass = Class.forName( BLOB_KEY_CLASS_NAME, true, delegateClass.getClassLoader()); blobKeyConstructor = blobKeyClass.getDeclaredConstructor(String.class); storeBlobMethod = delegateClass.getDeclaredMethod("storeBlob", blobKeyClass); storeBlobMethod.setAccessible(true); fetchBlobMethod = delegateClass.getDeclaredMethod("fetchBlob", blobKeyClass); fetchBlobMethod.setAccessible(true); } private Object getParam(BlobKey blobKey) throws IOException { if (blobKey.getClass() == storeBlobMethod.getParameterTypes()[0]) { return blobKey; } try { return blobKeyConstructor.newInstance(blobKey.getKeyString()); } catch (Exception e) { throw new IOException(e); } } public OutputStream storeBlob(BlobKey blobKey) throws IOException { Object param = getParam(blobKey); try { return (OutputStream) storeBlobMethod.invoke(delegate, param); } catch (IllegalAccessException | IllegalArgumentException e) { throw new IllegalStateException("Failed to invoke blobStorage", e); } catch (InvocationTargetException e) { Throwable targetException = e.getTargetException(); if (targetException instanceof IOException) { throw ((IOException) targetException); } throw new IOException(e); } } public InputStream fetchBlob(BlobKey blobKey) throws IOException { Object param = getParam(blobKey); try { return (InputStream) fetchBlobMethod.invoke(delegate, param); } catch (IllegalAccessException | IllegalArgumentException e) { throw new IllegalStateException("Failed to invoke blobStorage", e); } catch (InvocationTargetException e) { Throwable targetException = e.getTargetException(); if (targetException instanceof IOException) { throw ((IOException) targetException); } throw new IOException(e); } } private static BlobStorageAdapter getInstance() throws IOException { Delegate<?> apiProxyDelegate = ApiProxy.getDelegate(); if (instance == null || instance.apiProxyDelegate != apiProxyDelegate) { try { instance = new BlobStorageAdapter(apiProxyDelegate); } catch (Exception e) { throw new IOException(e); } } return instance; } } private void ensureInitialized() throws IOException { blobStorage = BlobStorageAdapter.getInstance(); blobstoreService = BlobstoreServiceFactory.getBlobstoreService(); datastore = DatastoreServiceFactory.getDatastoreService(); } static final class Token implements RawGcsCreationToken { private static final long serialVersionUID = 954846981243798905L; private final GcsFilename filename; private final GcsFileOptions options; private final long offset; Token(GcsFilename filename, GcsFileOptions options, long offset) { this.options = options; this.filename = checkNotNull(filename, "Null filename"); this.offset = offset; } @Override public GcsFilename getFilename() { return filename; } @Override public long getOffset() { return offset; } @Override public String toString() { return getClass().getSimpleName() + "(" + filename + ", " + offset + ")"; } @Override public final boolean equals(Object o) { if (o == this) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Token other = (Token) o; return offset == other.offset && Objects.equals(filename, other.filename) && Objects.equals(options, other.options); } @Override public final int hashCode() { return Objects.hash(filename, offset, options); } } @Override public Token beginObjectCreation(GcsFilename filename, GcsFileOptions options, long timeoutMillis) throws IOException { ensureInitialized(); inMemoryData.put(filename, new ArrayList<ByteBuffer>()); return new Token(filename, options, 0); } private Token append(RawGcsCreationToken token, ByteBuffer chunk) { Token t = (Token) token; if (!chunk.hasRemaining()) { return t; } int chunksize = chunk.remaining(); ByteBuffer inMemoryBuffer = ByteBuffer.allocate(chunksize); inMemoryBuffer.put(chunk); inMemoryBuffer.flip(); inMemoryData.get(t.filename).add(inMemoryBuffer); return new Token(t.filename, t.options, t.offset + chunksize); } private static ScheduledThreadPoolExecutor writePool; static { try { writePool = new ScheduledThreadPoolExecutor(1, ThreadManager.backgroundThreadFactory()); } catch (Exception e) { writePool = new ScheduledThreadPoolExecutor(1); } } /** * Runs calls in a background thread so that the results will actually be asynchronous. * * @see com.google.appengine.tools.cloudstorage.RawGcsService#continueObjectCreationAsync( * com.google.appengine.tools.cloudstorage.RawGcsService.RawGcsCreationToken, * java.nio.ByteBuffer, long) */ @Override public Future<RawGcsCreationToken> continueObjectCreationAsync(final RawGcsCreationToken token, final ByteBuffer chunk, long timeoutMillis) { try { ensureInitialized(); } catch (IOException e) { throw new RuntimeException(e); } final Environment environment = ApiProxy.getCurrentEnvironment(); return writePool.schedule(new Callable<RawGcsCreationToken>() { @Override public RawGcsCreationToken call() throws Exception { ApiProxy.setEnvironmentForCurrentThread(environment); return append(token, chunk); } }, 50, TimeUnit.MILLISECONDS); } private Key makeKey(GcsFilename filename) { return makeKey(filename.getBucketName(), filename.getObjectName()); } private Key makeKey(String bucket, String object) { String origNamespace = NamespaceManager.get(); try { NamespaceManager.set(""); return KeyFactory.createKey(ENTITY_KIND_PREFIX + bucket, object); } finally { NamespaceManager.set(origNamespace); } } private Key makeBlobstoreKey(GcsFilename filename) { String origNamespace = NamespaceManager.get(); try { NamespaceManager.set(""); return KeyFactory.createKey( null, BLOBSTORE_META_KIND, getBlobKeyForFilename(filename).getKeyString()); } finally { NamespaceManager.set(origNamespace); } } private Query makeQuery(String bucket) { String origNamespace = NamespaceManager.get(); try { NamespaceManager.set(""); return new Query(ENTITY_KIND_PREFIX + bucket); } finally { NamespaceManager.set(origNamespace); } } @Override public void finishObjectCreation(RawGcsCreationToken token, ByteBuffer chunk, long timeoutMillis) throws IOException { ensureInitialized(); Token t = append(token, chunk); int totalBytes = 0; BlobKey blobKey = getBlobKeyForFilename(t.filename); try (WritableByteChannel outputChannel = Channels.newChannel(blobStorage.storeBlob(blobKey))) { for (ByteBuffer buffer : inMemoryData.get(t.filename)){ totalBytes += buffer.remaining(); outputChannel.write(buffer); } inMemoryData.remove(t.filename); } String mimeType = t.options.getMimeType(); if (Strings.isNullOrEmpty(mimeType)) { mimeType = "application/octet-stream"; } BlobInfo blobInfo = new BlobInfo( blobKey, mimeType, new Date(), getPathForGcsFilename(t.filename), totalBytes); String namespace = NamespaceManager.get(); try { NamespaceManager.set(""); String blobKeyString = blobInfo.getBlobKey().getKeyString(); Entity blobInfoEntity = new Entity(GOOGLE_STORAGE_FILE_KIND, blobKeyString); blobInfoEntity.setProperty(BlobInfoFactory.CONTENT_TYPE, blobInfo.getContentType()); blobInfoEntity.setProperty(BlobInfoFactory.CREATION, blobInfo.getCreation()); blobInfoEntity.setProperty(BlobInfoFactory.FILENAME, blobInfo.getFilename()); blobInfoEntity.setProperty(BlobInfoFactory.SIZE, blobInfo.getSize()); datastore.put(blobInfoEntity); } finally { NamespaceManager.set(namespace); } Entity e = new Entity(makeKey(t.filename)); ByteArrayOutputStream bout = new ByteArrayOutputStream(); try (ObjectOutputStream oout = new ObjectOutputStream(bout)) { oout.writeObject(t.options); } e.setUnindexedProperty(OPTIONS_PROP, new Blob(bout.toByteArray())); e.setUnindexedProperty(CREATION_TIME_PROP, System.currentTimeMillis()); e.setUnindexedProperty(FILE_LENGTH_PROP, totalBytes); datastore.put(null, e); } @Override public GcsFileMetadata getObjectMetadata(GcsFilename filename, long timeoutMillis) throws IOException { ensureInitialized(); Entity entity; try { entity = datastore.get(null, makeKey(filename)); return createGcsFileMetadata(entity, filename); } catch (EntityNotFoundException ex1) { try { entity = datastore.get(null, makeBlobstoreKey(filename)); return createGcsFileMetadataFromBlobstore(entity, filename); } catch (EntityNotFoundException ex2) { return null; } } } private GcsFileMetadata createGcsFileMetadata(Entity entity, GcsFilename filename) throws IOException { GcsFileOptions options; try (ObjectInputStream in = new ObjectInputStream( new ByteArrayInputStream(((Blob) entity.getProperty(OPTIONS_PROP)).getBytes()))) { options = (GcsFileOptions) in.readObject(); } catch (ClassNotFoundException e1) { throw new RuntimeException(e1); } Date creationTime = null; if (entity.getProperty(CREATION_TIME_PROP) != null) { creationTime = new Date((Long) entity.getProperty(CREATION_TIME_PROP)); } long length; if (entity.getProperty(FILE_LENGTH_PROP) != null) { length = (Long) entity.getProperty(FILE_LENGTH_PROP); } else { ByteBuffer chunk = ByteBuffer.allocate(1024); long totalBytesRead = 0; try (ReadableByteChannel readChannel = Channels.newChannel( blobStorage.fetchBlob(getBlobKeyForFilename(filename)))) { long bytesRead = 0; bytesRead = readChannel.read(chunk); while (bytesRead != -1) { totalBytesRead += bytesRead; chunk.clear(); bytesRead = readChannel.read(chunk); } } length = totalBytesRead; } return new GcsFileMetadata(filename, options, null, length, creationTime); } private GcsFileMetadata createGcsFileMetadataFromBlobstore(Entity entity, GcsFilename filename) { return new GcsFileMetadata( filename, GcsFileOptions.getDefaultInstance(), "", (Long) entity.getProperty("size"), (Date) entity.getProperty("creation")); } @Override public Future<GcsFileMetadata> readObjectAsync( ByteBuffer dst, GcsFilename filename, long offset, long timeoutMillis) { Preconditions.checkArgument(offset >= 0, "%s: offset must be non-negative: %s", this, offset); try { ensureInitialized(); GcsFileMetadata meta = getObjectMetadata(filename, timeoutMillis); if (meta == null) { return Futures.immediateFailedFuture( new FileNotFoundException(this + ": No such file: " + filename)); } if (offset >= meta.getLength()) { return Futures.immediateFailedFuture(new BadRangeException( "The requested range cannot be satisfied. bytes=" + Long.toString(offset) + "-" + Long.toString(offset + dst.remaining()) + " the file is only " + meta.getLength())); } int read = 0; try (ReadableByteChannel readChannel = Channels.newChannel( blobStorage.fetchBlob(getBlobKeyForFilename(filename)))) { if (offset > 0) { long bytesRemaining = offset; ByteBuffer seekBuffer = ByteBuffer.allocate(1024); while (bytesRemaining > 0) { if (bytesRemaining < seekBuffer.limit()) { seekBuffer.limit((int) bytesRemaining); } read = readChannel.read(seekBuffer); if (read == -1) { return Futures.immediateFailedFuture(new BadRangeException( "The requested range cannot be satisfied; seek failed with " + Long.toString(bytesRemaining) + " bytes remaining.")); } bytesRemaining -= read; seekBuffer.clear(); } read = 0; } while (read != -1 && dst.hasRemaining()) { read = readChannel.read(dst); } } return Futures.immediateFuture(meta); } catch (IOException e) { return Futures.immediateFailedFuture(e); } } @Override public boolean deleteObject(GcsFilename filename, long timeoutMillis) throws IOException { ensureInitialized(); Transaction tx = datastore.beginTransaction(); Key key = makeKey(filename); try { datastore.get(tx, key); datastore.delete(tx, key); blobstoreService.delete(getBlobKeyForFilename(filename)); } catch (EntityNotFoundException ex) { return false; } finally { if (tx.isActive()) { tx.commit(); } } return true; } private String getPathForGcsFilename(GcsFilename filename) { return new StringBuilder() .append("/gs/") .append(filename.getBucketName()) .append('/') .append(filename.getObjectName()) .toString(); } private BlobKey getBlobKeyForFilename(GcsFilename filename) { return blobstoreService.createGsBlobKey(getPathForGcsFilename(filename)); } @Override public int getChunkSizeBytes() { return CHUNK_ALIGNMENT_BYTES; } @Override public void putObject(GcsFilename filename, GcsFileOptions options, ByteBuffer content, long timeoutMillis) throws IOException { ensureInitialized(); Token token = beginObjectCreation(filename, options, timeoutMillis); finishObjectCreation(token, content, timeoutMillis); } @Override public void composeObject(Iterable<String> source, GcsFilename dest, long timeoutMillis) throws IOException { ensureInitialized(); int size = Iterables.size(source); if (size > 32) { throw new IOException("Compose attempted with too many components. Limit is 32"); } if (size < 2) { throw new IOException("You must provide at least two source components."); } Token token = beginObjectCreation(dest, GcsFileOptions.getDefaultInstance(), timeoutMillis); for (String filename : source) { GcsFilename sourceGcsFilename = new GcsFilename(dest.getBucketName(), filename); appendFileContentsToToken(sourceGcsFilename, token); } finishObjectCreation(token, ByteBuffer.allocate(0), timeoutMillis); } private Token appendFileContentsToToken(GcsFilename source, Token token) throws IOException { ByteBuffer chunk = ByteBuffer.allocate(1024); try (ReadableByteChannel readChannel = Channels.newChannel( blobStorage.fetchBlob(getBlobKeyForFilename(source)))) { while (readChannel.read(chunk) != -1) { chunk.flip(); token = append(token, chunk); chunk.clear(); } } return token; } @Override public void copyObject(GcsFilename source, GcsFilename dest, GcsFileOptions fileOptions, long timeoutMillis) throws IOException { ensureInitialized(); GcsFileMetadata meta = getObjectMetadata(source, timeoutMillis); if (meta == null) { throw new FileNotFoundException(this + ": No such file: " + source); } if (fileOptions == null) { fileOptions = meta.getOptions(); } Token token = beginObjectCreation(dest, fileOptions, timeoutMillis); appendFileContentsToToken(source, token); finishObjectCreation(token, ByteBuffer.allocate(0), timeoutMillis); } @Override public ListItemBatch list(String bucket, String prefix, String delimiter, String marker, int maxResults, long timeoutMillis) throws IOException { ensureInitialized(); Query query = makeQuery(bucket); int prefixLength; if (!Strings.isNullOrEmpty(prefix)) { Key keyPrefix = makeKey(bucket, prefix); query.setFilter(new FilterPredicate(KEY_RESERVED_PROPERTY, GREATER_THAN_OR_EQUAL, keyPrefix)); prefixLength = prefix.length(); } else { prefixLength = 0; } FetchOptions fetchOptions = FetchOptions.Builder.withDefaults(); if (marker != null) { fetchOptions.startCursor(Cursor.fromWebSafeString(marker)); } List<ListItem> items = new ArrayList<>(maxResults); Set<String> prefixes = new HashSet<>(); QueryResultIterator<Entity> dsResults = datastore.prepare(query).asQueryResultIterator(fetchOptions); while (items.size() < maxResults && dsResults.hasNext()) { Entity entity = dsResults.next(); String name = entity.getKey().getName(); if (prefixLength > 0 && !name.startsWith(prefix)) { break; } if (!Strings.isNullOrEmpty(delimiter)) { int delimiterIdx = name.indexOf(delimiter, prefixLength); if (delimiterIdx > 0) { name = name.substring(0, delimiterIdx + 1); if (prefixes.add(name)) { items.add(new ListItem.Builder().setName(name).setDirectory(true).build()); } continue; } } GcsFilename filename = new GcsFilename(bucket, name); GcsFileMetadata metadata = createGcsFileMetadata(entity, filename); ListItem listItem = new ListItem.Builder() .setName(name) .setLength(metadata.getLength()) .setLastModified(metadata.getLastModified()) .build(); items.add(listItem); } Cursor cursor = dsResults.getCursor(); String nextMarker = null; if (items.size() == maxResults && cursor != null) { nextMarker = cursor.toWebSafeString(); } return new ListItemBatch(items, nextMarker); } @Override public int getMaxWriteSizeByte() { return 10_000_000; } }