001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.pageStore;
018
019import java.io.File;
020import java.io.IOException;
021import java.nio.ByteBuffer;
022import java.nio.MappedByteBuffer;
023import java.nio.channels.FileChannel;
024import java.nio.channels.FileChannel.MapMode;
025import java.nio.charset.Charset;
026import java.nio.file.StandardOpenOption;
027import java.nio.file.attribute.UserDefinedFileAttributeView;
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.Comparator;
031import java.util.HashSet;
032import java.util.List;
033import java.util.Set;
034
035import org.apache.wicket.WicketRuntimeException;
036import org.apache.wicket.page.IManageablePage;
037import org.apache.wicket.pageStore.disk.NestedFolders;
038import org.apache.wicket.util.file.Files;
039import org.apache.wicket.util.io.IOUtils;
040import org.apache.wicket.util.lang.Args;
041import org.apache.wicket.util.lang.Bytes;
042import org.slf4j.Logger;
043import org.slf4j.LoggerFactory;
044
045/**
046 * A storage of pages in files.
047 * <p>
048 * All pages passed into this store are restricted to be {@link SerializedPage}s.
049 * <p>
050 * While {@link DiskPageStore} uses a single file per session, this implementation stores each page
051 * in its own file. This improves on a {@link DiskPageStore disadvantage of DiskPageStore} surfacing
052 * with alternating Ajax requests from different browser tabs.  
053 */
054public class FilePageStore extends AbstractPersistentPageStore implements IPersistentPageStore
055{
056        private static final String ATTRIBUTE_PAGE_TYPE = "user.wicket_page_type";
057
058        private static final String FILE_SUFFIX = ".data";
059
060        private static final Logger log = LoggerFactory.getLogger(FilePageStore.class);
061
062        private final Bytes maxSizePerSession;
063
064        private final NestedFolders folders;
065
066        /**
067         * Create a store that supports {@link SerializedPage}s only.
068         * 
069         * @param applicationName
070         *            name of application
071         * @param fileStoreFolder
072         *            folder to store to
073         * @param maxSizePerSession
074         *            maximum size per session
075         * 
076         * @see SerializingPageStore
077         */
078        public FilePageStore(String applicationName, File fileStoreFolder, Bytes maxSizePerSession)
079        {
080                super(applicationName);
081                
082                this.folders = new NestedFolders(new File(fileStoreFolder, applicationName + "-filestore"));
083                this.maxSizePerSession = Args.notNull(maxSizePerSession, "maxSizePerSession");
084        }
085
086        /**
087         * Pages are always serialized, so versioning is supported.
088         */
089        @Override
090        public boolean supportsVersioning()
091        {
092                return true;
093        }
094
095        private File getPageFile(String sessionId, int id, boolean create)
096        {
097                File folder = folders.get(sessionId, create);
098
099                return new File(folder, id + FILE_SUFFIX);
100        }
101
102        @Override
103        protected IManageablePage getPersistedPage(String sessionIdentifier, int id)
104        {
105                File file = getPageFile(sessionIdentifier, id, false);
106                if (file.exists() == false)
107                {
108                        return null;
109                }
110
111                byte[] data;
112                try
113                {
114                        data = readFile(file);
115                }
116                catch (IOException ex)
117                {
118                        log.warn("cannot read page data for session {} page {}", sessionIdentifier, id, ex);
119                        return null;
120                }
121
122                return new SerializedPage(id, "unknown", data);
123        }
124
125        /**
126         * Read a file.
127         * <p>
128         * Note: This implementation uses a {@link FileChannel}.
129         * 
130         * @param file
131         *            file to read
132         * @throws IOException
133         */
134        protected byte[] readFile(File file) throws IOException
135        {
136                byte[] data;
137
138                FileChannel channel = FileChannel.open(file.toPath());
139                try
140                {
141                        int size = (int)channel.size();
142                        MappedByteBuffer buf = channel.map(MapMode.READ_ONLY, 0, size);
143                        data = new byte[size];
144                        buf.get(data);
145                }
146                finally
147                {
148                        IOUtils.closeQuietly(channel);
149                }
150
151                return data;
152        }
153
154        @Override
155        protected void removePersistedPage(String sessionIdentifier, IManageablePage page)
156        {
157                File file = getPageFile(sessionIdentifier, page.getPageId(), false);
158                if (file.exists())
159                {
160                        if (!file.delete())
161                        {
162                                log.warn("cannot remove page data for session {} page {}", sessionIdentifier, page.getPageId());
163                        }
164                }
165        }
166
167        @Override
168        protected void removeAllPersistedPages(String sessionIdentifier)
169        {
170                folders.remove(sessionIdentifier);
171        }
172
173        @Override
174        protected void addPersistedPage(String sessionIdentifier, IManageablePage page)
175        {
176                if (page instanceof SerializedPage == false)
177                {
178                        throw new WicketRuntimeException("FilePageStore works with serialized pages only");
179                }
180                SerializedPage serializedPage = (SerializedPage)page;
181
182                byte[] data = serializedPage.getData();
183
184                File file = getPageFile(sessionIdentifier, serializedPage.getPageId(), true);
185                try
186                {
187                        writeFile(file, data);
188                }
189                catch (IOException ex)
190                {
191                        log.warn("cannot store page data for session {} page {}", sessionIdentifier,
192                                serializedPage.getPageId(), ex);
193                }
194
195                setPageType(file, serializedPage.getPageType());
196
197                checkMaxSize(sessionIdentifier);
198        }
199
200        /**
201         * Write a file with given data.
202         * <p>
203         * Note: This implementation uses a {@link FileChannel} with
204         * {@link StandardOpenOption#TRUNCATE_EXISTING}. This might fail on Windows systems with
205         * "The requested operation cannot be performed on a file with a user-mapped section open", so subclasses
206         * can omit this option to circumvent this error, although this prevents files from shrinking when pages become smaller.
207         * Alternatively a completely different implementation can be chosen.
208         * 
209         * @param file
210         *            file to write
211         * @param data
212         *            data to write
213         * @throws IOException
214         */
215        protected void writeFile(File file, byte[] data) throws IOException
216        {
217                FileChannel channel = FileChannel.open(file.toPath(), StandardOpenOption.CREATE,
218                        StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
219                try
220                {
221                        ByteBuffer buffer = ByteBuffer.wrap(data);
222                        channel.write(buffer);
223                }
224                finally
225                {
226                        IOUtils.closeQuietly(channel);
227                }
228        }
229
230        private void checkMaxSize(String sessionIdentifier)
231        {
232                File[] files = Files.list(folders.get(sessionIdentifier, true));
233                Arrays.sort(files, new LastModifiedComparator());
234
235                long total = 0;
236                for (File candidate : files)
237                {
238                        total += candidate.length();
239
240                        if (total > maxSizePerSession.bytes())
241                        {
242                                if (!Files.remove(candidate))
243                                {
244                                        log.warn("cannot remove page data for session {} page {}", sessionIdentifier, candidate.getName());
245                                }
246                        }
247                }
248        }
249
250        public static class LastModifiedComparator implements Comparator<File>
251        {
252                @Override
253                public int compare(File f1, File f2)
254                {
255                        return Long.compare(f2.lastModified(), f1.lastModified());
256                }
257        }
258
259        @Override
260        public Set<String> getSessionIdentifiers()
261        {
262                Set<String> sessionIdentifiers = new HashSet<>();
263
264                for (File folder : folders.getAll())
265                {
266                        sessionIdentifiers.add(folder.getName());
267                }
268
269                return sessionIdentifiers;
270        }
271
272        @Override
273        public List<IPersistedPage> getPersistedPages(String sessionIdentifier)
274        {
275                List<IPersistedPage> pages = new ArrayList<>();
276
277                File folder = folders.get(sessionIdentifier, false);
278                if (folder.exists())
279                {
280                        File[] files = Files.list(folder);
281                        Arrays.sort(files, new LastModifiedComparator());
282                        for (File file : files)
283                        {
284                                String name = file.getName();
285                                if (name.endsWith(FILE_SUFFIX))
286                                {
287                                        int pageId;
288                                        try
289                                        {
290                                                pageId = Integer.parseInt(name.substring(0, name.length() - FILE_SUFFIX.length()), 10);
291                                        }
292                                        catch (Exception ex)
293                                        {
294                                                log.debug("unexpected file {}", file.getAbsolutePath());
295                                                continue;
296                                        }
297
298                                        String pageType = getPageType(file);
299
300                                        pages.add(new PersistedPage(pageId, pageType, file.length()));
301                                }
302                        }
303                }
304
305                return pages;
306        }
307
308        /**
309         * Get the type of page from the given file.
310         * <p>
311         * This is an optional operation that returns <code>null</code> in case of any error. 
312         * 
313         * @param file
314         * @return pageType
315         */
316        protected String getPageType(File file)
317        {
318                String pageType = null;
319                try
320                {
321                        UserDefinedFileAttributeView view = getAttributeView(file);
322                        
323                        ByteBuffer buffer = ByteBuffer.allocate(view.size(ATTRIBUTE_PAGE_TYPE));
324                        view.read(ATTRIBUTE_PAGE_TYPE, buffer);
325                        buffer.flip();
326                        pageType = Charset.defaultCharset().decode(buffer).toString();
327                }
328                catch (Exception ex)
329                {
330                        log.debug("cannot get pageType for {}", file);
331                }
332
333                return pageType;
334        }
335
336        private UserDefinedFileAttributeView getAttributeView(File file)
337        {
338                return java.nio.file.Files
339                        .getFileAttributeView(file.toPath(), UserDefinedFileAttributeView.class);
340        }
341
342        /**
343         * Set the type of page on the given file.
344         * <p>
345         * This is an optional operation that silently fails in case of an error.
346         * 
347         * @param file
348         * @param pageType
349         */
350        protected void setPageType(File file, String pageType)
351        {
352                try
353                {
354                        UserDefinedFileAttributeView view = getAttributeView(file);
355
356                        view.write(ATTRIBUTE_PAGE_TYPE, Charset.defaultCharset().encode(pageType));
357                }
358                catch (Exception ex)
359                {
360                        log.debug("cannot set pageType for {}", file, ex);
361                }
362        }
363
364        @Override
365        public Bytes getTotalSize()
366        {
367                long total = 0;
368
369                for (File folder : folders.getAll())
370                {
371                        for (File file : Files.list(folder))
372                        {
373                                String name = file.getName();
374                                if (name.endsWith(FILE_SUFFIX))
375                                {
376                                        total += file.length();
377                                }
378                        }
379                }
380
381                return Bytes.bytes(total);
382        }
383}