BackupFileWriter

import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

/**
 * 自動的にファイルのバックアップを行うライタ。<br/>
 * ファイルのサイズが閾値を超えた場合または前回出力時と日付が異なる場合にバックアップを行う。<br/>
 * 指定された世代数を超えるバックアップファイルは自動的に削除されるが、VMの起動前から存在していたファイルはその限りではない。<br/>
 */
public class BackupFileWriter extends Writer {

    private static final String FILE_SEPARATOR = System.getProperty("file.separator");
    private static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat("yyyyMMdd");

    // 日付のチェックを行うためのカレンダー
    // Calendarクラスは生成コストが高いらしいので同じものを使いまわす
    private static final Calendar c1 = Calendar.getInstance();
    private static final Calendar c2 = Calendar.getInstance();

    /**
     * ファイルを作成日時と対で保持するクラス
     */
    public static class BackupFile {
        private long creationTime;
        private File file;
        public void setCreationTime(long creationTime) {
            this.creationTime = creationTime;
        }
        public long getCreationTime() {
            return creationTime;
        }
        public void setFile(File file) {
            this.file = file;
        }
        public File getFile() {
            return file;
        }
    }

    /**
     * 新しくバックアップファイルを作成した時に、古いバックアップファイルの処理を定義するクラス。<br/>
     */
    public abstract static class BackupPolicy {

        protected int capacity;
        protected List<BackupFile> files = new ArrayList<BackupFile>();

        /**
         * @param capacity バックアップ世代数
         */
        public BackupPolicy(int capacity) {
            this.capacity = capacity;
        }

        /**
         * バックアップ済みのファイルを追加
         * @param file
         */
        public void add(BackupFile file) {
            files.add(file);
        }

        /**
         * 保持しているバックアップファイルの数を返す。
         * @return
         */
        public int size() {
            return files.size();
        }

        public boolean isFull() {
            return files.size() == capacity;
        }

        /**
         * 古いバックアップファイルの処理内容を記述する。
         * @param 現在書き込み中のファイルの情報
         * @throws IOException
         */
        public abstract void processOldFile(CyclicFileWriter.FileContext context) throws IOException;

        /**
         * 新しいバックアップファイルの名前を決める
         * @param context バックアップ対象のファイル情報
         * @return
         * @throws IOException
         */
        public abstract String createBackupFileName(CyclicFileWriter.FileContext context) throws IOException;

    }

    /**
     * 最も古いファイルを削除し、それ以降のファイルを順次一つ前のファイルにリネームするクラス。<br/>
     */
    public static class RotateBackupPolicy extends BackupPolicy {

        public RotateBackupPolicy(int capacity) {
            super(capacity);
        }

        @Override
        public void processOldFile(CyclicFileWriter.FileContext context) throws IOException {
            if (!isFull()) {
                return;
            }
            if (!files.get(0).getFile().delete()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            if (files.get(0).getFile().exists()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            for (int i = 1; i < files.size(); i++) {
                if (!files.get(i).getFile().renameTo(files.get(i - 1).getFile())) {
                    throw new IOException("failed rename file '" + files.get(i).getFile().getCanonicalPath() + "' to '" + files.get(i - 1).getFile().getCanonicalPath() + "'");
                }
                if (files.get(i).getFile().exists()) {
                    throw new IOException("failed rename file '" + files.get(i).getFile().getCanonicalPath() + "' to '" + files.get(i - 1).getFile().getCanonicalPath() + "'");
                }
                files.get(i - 1).setCreationTime(files.get(i).getCreationTime());
            }
            files.remove(files.size() - 1);
        }

        /**
         * ファイル名の最後(拡張子の前)にバックアップ数を付加する。<br/>
         * 例:/work/test1.txt => /work/test_1.txt,/work/test_2.txt・・・ <br/>
         * 付加されるバックアップ数は{@link #size()}+1。
         */
        @Override
        public String createBackupFileName(CyclicFileWriter.FileContext context) throws IOException {
            File file = context.getFile();
            String fileName = file.getName();
            String ext = "";
            // ファイル名を拡張子とそれ以外に分解する。
            int pos = fileName.indexOf('.');
            if (pos > 0) {
                ext = fileName.substring(pos + 1);
                fileName = fileName.substring(0, pos);
            }
            int generation = size() + 1;
            if (ext.length() > 0) {
                return String.format("%s%s%s_%s.%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName, generation, ext);
            } else {
                return String.format("%s%s%s_%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName, generation);
            }
        }

    }

    /**
     * 最も古いファイルを削除するだけのクラス。<br/>
     */
    public static class DeleteBackupPolicy extends BackupPolicy {

        public DeleteBackupPolicy(int capacity) {
            super(capacity);
        }

        @Override
        public void processOldFile(CyclicFileWriter.FileContext context) throws IOException {
            if (!isFull()) {
                return;
            }
            if (!files.get(0).getFile().delete()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            if (files.get(0).getFile().exists()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            files.remove(0);
        }

        /**
         * ファイル名の最後(拡張子の前)に作成日を付加する。<br/>
         * 例:/work/test1.txt => /work/test_20100101.txt,/work/test_20100102.txt・・・ <br/>
         */
        public String createBackupFileName(CyclicFileWriter.FileContext context) throws IOException {
            File file = context.getFile();
            String fileName = file.getName();
            String ext = "";
            // ファイル名を拡張子とそれ以外に分解する。
            int pos = fileName.indexOf('.');
            if (pos > 0) {
                ext = fileName.substring(pos + 1);
                fileName = fileName.substring(0, pos);
            }
            // ファイル名に日付を付加する。
            fileName += "_" + dateFormat(DATE_FORMATTER, new Date(context.getCreationTime()));
            if (ext.length() > 0) {
                return String.format("%s%s%s.%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName, ext);
            } else {
                return String.format("%s%s%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName);
            }
        }

    }

    /**
     * 条件によって古いファイルを削除するだけかリネームを行うかを決めるクラス。<br/>
     * rotateCondition()がfalseを返すと削除するだけになり、trueを返すとリネームを行うようになる。
     */
    public abstract static class ConditionalRotateBackupPolicy extends BackupPolicy {

        public ConditionalRotateBackupPolicy(int capacity) {
            super(capacity);
        }

        @Override
        public void processOldFile(CyclicFileWriter.FileContext context) throws IOException {
            if (!isFull()) {
                return;
            }
            boolean isRotate = rotateCondition(context);
            if (!files.get(0).getFile().delete()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            if (files.get(0).getFile().exists()) {
                throw new IOException("failed delete file '" + files.get(0).getFile().getCanonicalPath() + "'");
            }
            if (isRotate) {
                for (int i = 1; i < files.size(); i++) {
                    if (!files.get(i).getFile().renameTo(files.get(i - 1).getFile())) {
                        throw new IOException("failed rename file '" + files.get(i).getFile().getCanonicalPath() + "' to '" + files.get(i - 1).getFile().getCanonicalPath() + "'");
                    }
                    if (files.get(i).getFile().exists()) {
                        throw new IOException("failed rename file '" + files.get(i).getFile().getCanonicalPath() + "' to '" + files.get(i - 1).getFile().getCanonicalPath() + "'");
                    }
                    files.get(i - 1).setCreationTime(files.get(i).getCreationTime());
                }
                files.remove(files.size() - 1);
            } else {
                files.remove(0);
            }
        }

        public abstract boolean rotateCondition(CyclicFileWriter.FileContext context) throws IOException;

    }

    /**
     * 日付ごと、一定サイズごとでバックアップするクラス
     */
    public static class DailyRotateBackupPolicy extends ConditionalRotateBackupPolicy {

        // 同一日付内でのバックアップ数
        protected int dailyBackupCount = 1;

        public DailyRotateBackupPolicy(int capacity) {
            super(capacity);
        }

        @Override
        public boolean rotateCondition(FileContext context) throws IOException {
            // 最も古いバックアップファイルの作成日と今のファイルの作成日が同じか否かで処理内容が変わる。
            return compareDate(files.get(0).getCreationTime(), context.getCreationTime());
        }

        /**
         * ファイル名の最後(拡張子の前)に作成日とバックアップ数を付加する。<br/>
         * 例:/work/test1.txt => /work/test_20100101_1.txt,/work/test_20100101_2.txt, /work/test_20100102_1.txt・・・ <br/>
         */
        @Override
        public String createBackupFileName(FileContext context) throws IOException {
            File file = context.getFile();
            String fileName = file.getName();
            String ext = "";
            // ファイル名を拡張子とそれ以外に分解する。
            int pos = fileName.indexOf('.');
            if (pos > 0) {
                ext = fileName.substring(pos + 1);
                fileName = fileName.substring(0, pos);
            }
            // ファイル名に日付を付加する。
            fileName += "_" + dateFormat(DATE_FORMATTER, new Date(context.getCreationTime()));
            // 世代数は日付ごととする
            int generation = dailyBackupCount++;
            // 一日に何回バックアップを行っても世代数の最大はバックアップ数
            dailyBackupCount = Math.min(dailyBackupCount, capacity);
            if (ext.length() > 0) {
                return String.format("%s%s%s_%s.%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName, generation, ext);
            } else {
                return String.format("%s%s%s_%s", file.getCanonicalFile().getParent(), FILE_SEPARATOR, fileName, generation);
            }
        }
        
        @Override
        public void add(BackupFile file) {
            super.add(file);
            if (!compareDate(file.getCreationTime(), SystemUtils.currentTimeMillis())) {
                // 違う日付のファイルがバックアップされたら日付ごとのバックアップ世代数をリセットする
                dailyBackupCount = 1;
            }
        }

    }

    /**
     * 2つの日付が同じであればtrueを返す。<br/>
     * @return
     */
    private synchronized static boolean compareDate(long d1, long d2) {
        c1.setTimeInMillis(d1);
        c2.setTimeInMillis(d2);
        return c1.get(Calendar.DATE) == c2.get(Calendar.DATE) && c1.get(Calendar.MONTH) == c2.get(Calendar.MONTH) && c1.get(Calendar.YEAR) == c2.get(Calendar.YEAR);
    }

    /**
     * 日付の文字列化。<br/>
     * DateFormatがスレッドセーフではないためsynchronizedで使用。
     * @param df
     * @param date
     * @return
     */
    private synchronized static String dateFormat(DateFormat df, Date date) {
        return df.format(date);
    }

    // 実際に使用するライタ
    private CyclicFileWriter writer;

    // ファイルの最大バイト数
    private long maxLength = -1;

    // 前回出力時と日付が異なる場合にバックアップするか否かのフラグ
    private boolean changeDate;

    // バックアップの世代数
    private int backupCount = 3;

    // バックアップ処理の実装
    protected BackupPolicy backupPolicy;

    /**
     * ファイルの上限サイズを指定してライタを作成する。<br/>
     * ただし、サイズのチェックは改行のタイミングで行っているので、maxLengthを若干超えることは有りうる。<br/>
     * @param file
     * @param maxLength
     * @param backupCount バックアップファイルを保持する世代数
     * @throws IOException
     */
    public BackupFileWriter(File file, long maxLength, int backupCount) throws IOException {
        this(file, maxLength, false, backupCount);
    }

    /**
     * 日付が変わるたびにバックアップを行うライタを作成する。<br/>
     * @param file
     * @param changeDate
     * @param backupCount バックアップファイルを保持する世代数
     * @throws IOException
     */
    public BackupFileWriter(File file, boolean changeDate, int backupCount) throws IOException {
        this(file, -1, changeDate, backupCount);
    }

    /**
     * ファイルサイズが閾値を超えるか、日付が変わった場合にバックアップを行うライタを作成する。<br/>
     * ただし、サイズのチェックは改行のタイミングで行っているので、maxLengthを若干超えることは有りうる。<br/>
     * @param file
     * @param maxLength ファイルサイズの上限。0以下だとサイズによるバックアップは行わない。
     * @param changeDate 日付によるバックアップの指定。falseにすると日付が変わってもバックアップは行わない。
     * @param backupCount バックアップファイルを保持する世代数
     * @throws IOException
     */
    public BackupFileWriter(File file, long maxLength, boolean changeDate, int backupCount) throws IOException {
        if (maxLength <= 0 && !changeDate) {
            throw new IllegalArgumentException("must be maxLength > 0 or changeDate is true");
        }
        if (backupCount <= 0) {
            throw new IllegalArgumentException("must be backupCount > 0");
        }
        this.maxLength = maxLength;
        this.changeDate = changeDate;
        this.backupCount = backupCount;
        // バックアップのタイミングによってポリシーを変更する。
        if (maxLength <= 0) {
            backupPolicy = new DeleteBackupPolicy(backupCount);
        } else {
            if (changeDate) {
                backupPolicy = new DailyRotateBackupPolicy(backupCount);
            } else {
                backupPolicy = new RotateBackupPolicy(backupCount);
            }
        }
        writer = new CyclicFileWriter(new CyclicFileWriter.CyclicPolicy() {

            /**
             * ファイルサイズが閾値を超えた場合、またはファイル作成時と日付が変わった場合にバックアップを行う。
             */
            @Override
            public boolean checkCyclic(CyclicFileWriter.FileContext context) {
                if (getMaxLength() > 0 && context.getLength() > getMaxLength()) {
                    return true;
                }
                if (isChangeDate() && !compareDate(context.getCreationTime(), SystemUtils.currentTimeMillis())) {
                    return true;
                }
                return false;
            }

            /**
             * ファイルのバックアップを行う。<br/>
             */
            @Override
            public void cyclic(CyclicFileWriter.FileContext context) throws IOException {
                String newFileName = "";
                BackupFile newFile = null;
                // バックアップ用のファイル名を決める
                while (newFile == null) {
                    // 指定されたバックアップ数までバックアップ済みであれば、ローテートさせて古いファイルを削除する
                    if (backupPolicy.isFull()) {
                        backupPolicy.processOldFile(context);
                    }
                    // バックアップ用のファイル名を作成する
                    newFileName = backupPolicy.createBackupFileName(context);
                    // そのファイルがすでに存在していればすでにバックアップしていたものとして繰り返し
                    newFile = new BackupFile();
                    newFile.setFile(new File(newFileName));
                    if (newFile.getFile().exists()) {
                        newFile.setCreationTime(newFile.getFile().lastModified() + SystemUtils.getOffsetCurrentTimeMillis());
                        backupPolicy.add(newFile);
                        newFile = null;
                    }
                }
                // ファイルをリネーム
                File file = context.getFile();
                if (!file.renameTo(newFile.getFile())) {
                    throw new IOException("failed rename file '" + file.getCanonicalPath() + "' to '" + newFileName + "'");
                }
                // OSによっては、リネームが失敗してもtrueを返す場合がある
                if (file.exists()) {
                    throw new IOException("failed rename file '" + file.getCanonicalPath() + "' to '" + newFileName + "'");
                }
                newFile.setCreationTime(context.getCreationTime());
                backupPolicy.add(newFile);
            }

        }, file);
    }

    /**
     * ファイルの上限サイズを指定してライタを作成する。<br/>
     * ただし、サイズのチェックは改行のタイミングで行っているので、maxLengthを若干超えることは有りうる。<br/>
     * @param name
     * @param maxLength
     * @param backupCount
     * @throws IOException
     */
    public BackupFileWriter(String name, long maxLength, int backupCount) throws IOException {
        this(new File(name), maxLength, false, backupCount);
    }

    /**
     * 日付が変わるたびにバックアップを行うライタを作成する。<br/>
     * @param name
     * @param changeDate
     * @param backupCount
     * @throws IOException
     */
    public BackupFileWriter(String name, boolean changeDate, int backupCount) throws IOException {
        this(new File(name), -1, changeDate, backupCount);
    }

    /**
     * ファイルサイズが閾値を超えるか、日付が変わった場合にバックアップを行うライタを作成する。<br/>
     * ただし、サイズのチェックは改行のタイミングで行っているので、maxLengthを若干超えることは有りうる。<br/>
     * @param name
     * @param maxLength
     * @param changeDate
     * @param backupCount
     * @throws IOException
     */
    public BackupFileWriter(String name, int maxLength, boolean changeDate, int backupCount) throws IOException {
        this(new File(name), maxLength, changeDate, backupCount);
    }

    /**
     * ファイルの上限サイズを返す。<br/>
     * @return
     */
    public long getMaxLength() {
        return maxLength;
    }

    /**
     * 日付ごとのバックアップを行うか否かを返す。<br/>
     * @return
     */
    public boolean isChangeDate() {
        return changeDate;
    }

    /**
     * バックアップを保持する世代数
     * @return
     */
    public int getBackupCount() {
        return backupCount;
    }

    @Override
    public void close() throws IOException {
        writer.close();
    }

    @Override
    public void flush() throws IOException {
        writer.flush();
    }

    @Override
    public void write(char[] cbuf, int off, int len) throws IOException {
        writer.write(cbuf, off, len);
    }

}