com.mtgi.analytics.XmlBehaviorEventPersisterImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.mtgi.analytics.XmlBehaviorEventPersisterImpl.java

Source

/* 
 * Copyright 2008-2009 the original author or authors.
 * The contents of this file are subject to the Mozilla Public License
 * Version 1.1 (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.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 */

package com.mtgi.analytics;

import static java.util.UUID.randomUUID;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.Queue;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.jmx.export.annotation.ManagedAttribute;
import org.springframework.jmx.export.annotation.ManagedOperation;
import org.springframework.jmx.export.annotation.ManagedOperationParameter;
import org.springframework.jmx.export.annotation.ManagedResource;

import com.mtgi.io.RelocatableFile;
import com.sun.xml.fastinfoset.stax.StAXDocumentSerializer;

/**
 * Behavior Tracking persister which writes events to an XML log file,
 * either as plain text or FastInfoset binary XML.  Which format is
 * selected by {@link #setBinary(boolean)}.  Log rotation can be accomplished
 * by {@link #rotateLog()}.
 */
@ManagedResource(objectName = "com.mtgi.analytics:name=BeetLog", description = "Perform maintenance on beet XML logfiles")
public class XmlBehaviorEventPersisterImpl implements BehaviorEventPersister, InitializingBean, DisposableBean {
    private static final Log log = LogFactory.getLog(XmlBehaviorEventPersisterImpl.class);

    private static final SimpleDateFormat DEFAULT_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
    private static final Pattern FILE_NAME_PATTERN = Pattern.compile("^(.+)\\.b?xml(?:\\.gz)?");

    private boolean binary;
    private boolean compress;
    private File file;
    private SimpleDateFormat dateFormat = DEFAULT_DATE_FORMAT;

    private XMLStreamWriter writer;
    private OutputStream stream;

    /** Set to true to log in FastInfoset binary XML format.  Defaults to false. */
    @ManagedAttribute(description = "Can be used to switch between binary and text XML.  Changes take affect after the next log rotation.")
    public void setBinary(boolean binary) {
        this.binary = binary;
    }

    @ManagedAttribute(description = "Can be used to switch between binary and text XML.  Changes take affect after the next log rotation.")
    public boolean isBinary() {
        return binary;
    }

    @ManagedAttribute(description = "Can be used to turn on/off log file compression.  Changes take affect after the next log rotation.")
    public boolean isCompress() {
        return compress;
    }

    /** Set to true to log in ZLIB compressed format.  Changes take affect after the next log rotation.  Defaults to false. */
    @ManagedAttribute(description = "Can be used to turn on/off log file compression.  Changes take affect after the next log rotation.")
    public void setCompress(boolean compress) {
        this.compress = compress;
    }

    /** override the default log name date format */
    public void setDateFormat(String dateFormat) {
        this.dateFormat = new SimpleDateFormat(dateFormat);
    }

    /**
     * JMX operation to list archived xml data files available for download.
     */
    @ManagedOperation(description = "List all archived performance data log files available for download")
    public StringBuffer listLogFiles() {

        final Pattern pattern = getArchiveNamePattern();

        //scan parent directory for matches.
        File[] files = file.getParentFile().listFiles(new FileFilter() {
            public boolean accept(File child) {
                String childName = child.getName();
                return pattern.matcher(childName).matches();
            }
        });
        Arrays.sort(files, FileOrder.INST);

        StringBuffer ret = new StringBuffer(
                "<html><body><table><tr><th>File</th><th>Size</th><th>Modified</th></tr>");
        for (File f : files)
            ret.append("<tr><td>").append(f.getName()).append("</td><td>").append(f.length()).append("</td><td>")
                    .append(new Date(f.lastModified()).toString()).append("</td></tr>");
        ret.append("</table></body></html>");
        return ret;
    }

    @ManagedOperation(description = "directly access a performance log file by name; use listLogFiles to determine valid file names")
    @ManagedOperationParameter(name = "name", description = "The relative name of the file to download; see listLogFiles")
    public RelocatableFile downloadLogFile(String name) throws IOException {
        if (!getArchiveNamePattern().matcher(name).matches())
            throw new IllegalArgumentException(name + " does not appear to be a performance data file");
        File data = new File(file.getParentFile(), name);
        if (!data.isFile())
            throw new FileNotFoundException(file.getAbsolutePath());
        return new RelocatableFile(data);
    }

    /** set the destination log file.  The file extension will be modified based on compression / binary settings. */
    @Required
    public void setFile(String path) {
        this.file = new File(path);
    }

    public String getFile() {
        return file == null ? null : file.getAbsolutePath();
    }

    public void afterPropertiesSet() throws Exception {
        File dir = file.getCanonicalFile().getParentFile();
        if (!dir.isDirectory() && !dir.mkdirs())
            throw new IOException("Unable to create directories for log file " + file.getAbsolutePath());

        //open for business.
        rotateLog();
    }

    public void destroy() throws Exception {
        closeWriter();
    }

    @ManagedAttribute(description = "Report the current size of the XML log, in bytes")
    public long getFileSize() {
        return file.length();
    }

    public void persist(Queue<BehaviorEvent> events) {
        try {
            BehaviorEventSerializer serializer = new BehaviorEventSerializer();

            for (BehaviorEvent event : events) {
                if (event.getId() == null)
                    event.setId(randomUUID());

                BehaviorEvent parent = event.getParent();
                if (parent != null && parent.getId() == null)
                    parent.setId(randomUUID());

                synchronized (this) {
                    serializer.serialize(writer, event);
                    writer.writeCharacters("\n");
                }
            }

            synchronized (this) {
                writer.flush();
                stream.flush();
            }

        } catch (Exception error) {
            log.error("Error persisting events; discarding " + events.size() + " events without saving", error);
            events.clear();
        }
    }

    /**
     * Force a rotation of the log.  The new archive log will be named <code>[logfile].yyyyMMddHHmmss</code>.
     * If a file of that name already exists, an extra _N will be appended to it, where N is an
     * integer sufficiently large to make the name unique.  This method can be called
     * externally by the Quartz scheduler for periodic rotation, or by an administrator
     * via JMX.
     */
    @ManagedOperation(description = "Force a rotation of the behavior tracking log")
    public String rotateLog() throws IOException, XMLStreamException {
        StringBuffer msg = new StringBuffer();
        synchronized (this) {
            //flush current writer and close streams.
            closeWriter();

            //rotate old log data to timestamped archive file.
            File archived = moveToArchive();
            if (archived == null)
                msg.append("No existing log data.");
            else
                msg.append(archived.getAbsolutePath());

            //update output file name, in case binary/compress settings changed since last rotate.
            file = getLogFile(file);

            //perform archive again, in case our file name changed and has pointed us at a preexisting file;
            //this can happen if the system wasn't shut down cleanly the last time.
            moveToArchive();

            //open a new stream, optionally compressed.
            if (compress)
                stream = new GZIPOutputStream(new FileOutputStream(file));
            else
                stream = new BufferedOutputStream(new FileOutputStream(file));

            //open a new writer over the stream.
            if (binary) {
                StAXDocumentSerializer sds = new StAXDocumentSerializer();
                sds.setOutputStream(stream);
                writer = sds;
            } else {
                writer = XMLOutputFactory.newInstance().createXMLStreamWriter(stream);
            }

            writer.writeStartDocument();
            writer.writeStartElement("event-log");
        }
        return msg.toString();
    }

    /** 
     * if the current target log location exists, move it to archive location, returning the newly created archive file 
     * @return the archive file; or null if the current target location does not yet exist
     * @throws IOException if an archive file was needed, but could not by created
     */
    private File moveToArchive() throws IOException {
        if (file.exists()) {
            File archive = getArchiveFile();
            if (!file.renameTo(archive))
                throw new IOException("Unable to rename log to " + archive.getAbsolutePath() + "; is "
                        + file.getPath() + " open by another process?");
            return archive;
        }
        return null;
    }

    private void closeWriter() {
        if (writer != null) {
            //finish writing XML document.
            try {
                writer.writeEndDocument();
                writer.flush();
            } catch (XMLStreamException xse) {
                log.error("Error flushing log for rotation", xse);
            } finally {

                //finish the compressed stream, if applicable.
                try {
                    if (stream instanceof GZIPOutputStream)
                        ((GZIPOutputStream) stream).finish();
                } catch (IOException ioe) {
                    log.error("Error finishing zip stream", ioe);
                } finally {

                    //close the file
                    try {
                        stream.close();
                    } catch (IOException ioe) {
                        log.error("Error closing log stream", ioe);
                    } finally {
                        writer = null;
                        stream = null;
                    }

                }
            }
        }
    }

    /** get the active log file, modifying the supplied base name to reflect compressed / binary options. */
    public File getLogFile(File file) {

        //generate an extension based on output options.
        String ext = isBinary() ? ".bxml" : ".xml";
        if (isCompress())
            ext += ".gz";

        //strip off current extension if applicable.
        String baseName = file.getName();
        Matcher matcher = FILE_NAME_PATTERN.matcher(baseName);
        if (matcher.matches())
            baseName = matcher.group(1);

        return new File(file.getParentFile(), baseName + ext);
    }

    /** get the archive file to which current log data should be moved on rotation */
    private File getArchiveFile() {
        String baseName = file.getPath() + "." + dateFormat.format(new Date());
        File ret = new File(baseName);
        for (int i = 1; ret.exists() && i < 11; ++i)
            ret = new File(baseName + "_" + i);
        if (ret.exists())
            throw new IllegalStateException("Unable to create a unique file name starting with " + baseName
                    + "; maybe system date is off?");
        return ret;
    }

    private Pattern getArchiveNamePattern() {
        //build a regex that matches files with the configured basename, plus a non-binary or binary xml extension,
        //plus a date suffix.
        String filePattern = file.getName();
        Matcher m = FILE_NAME_PATTERN.matcher(filePattern);
        if (m.matches())
            filePattern = m.group(1);
        filePattern += "\\.b?xml\\..+";
        return Pattern.compile(filePattern);
    }

    protected static class FileOrder implements Comparator<File> {

        public static final FileOrder INST = new FileOrder();

        public int compare(File f0, File f1) {
            long delta = f0.lastModified() - f1.lastModified();
            return delta == 0 ? f0.compareTo(f1) : delta > 0 ? -1 : 0;
        }

    }
}