Introduction
File Flit is a simple utility that watches for changes to existing files and, when a change is detected, copies the file to an existing target folder. I wrote this utility because I didn't really find anything out there in the Internet ether that did just this and was simple to set up. So, when I started writing the code, I had the idea that, let's write the article from the perspective of how this utility was developed in about four hours. In fact, the article has taken at least as long, if not longer, than the code! You won't see elegant code here or snazzy technologies like WPF, WCF, Silverlight, MVVM, and so forth. Those just aren't needed. What you will see is a brain-dead simple piece of code that does, well, what it's intended to do, and some stories about each piece of code.
Requirements Statement
Whether I'm looking for an application that already exists, or some code, or considering the idea that I might roll my own, I like to start with a requirements statement, something that harks back to the days of Grady Booch and other early object-oriented design methodologies. When teaching programming, I also start with the requirements statement--put in words what the program should do. It's amazing how difficult people find this task! So here's my requirements statement:
The program should monitor when one or more files is updated (via its change date) and then copy the file to one or more target locations. The source and target are to be persisted in a lightweight XML format in a configuration file, and a simple UI should allow for the editing of the file list. Each file being monitored can specify a time delay between when the file change is detected and when the copy operation is performed, so that the program can compensate for the file update time. The UI should also show a log of activity and errors that occur when trying to copy the file. The source is always a file (wildcards are not allowed at the moment) and the target is always the destination folder into which the file should exist. The program can assume that the files and folders always exist.
From this requirements statement, I can extract, just by looking at the statement, the following implementation requirements:
- A configuration class that serializes / deserializes the XML
- A class to maintain the property values of each "flitter" record (source, target, delay)
- A list to maintain the collection of records
- A UI for displaying the configuration
- A "service" that monitors the source file changes and copies them when they change
- A way of logging success and failure
- A UI for displaying the log file
I like this approach very much and use it as a cornerstone for everything that I do. The beauty of it is that I can take even highly abstract requirements and recursively refine them into more and more concrete statements, such that at the end, I have a very concise definition of each requirement in terms of things (nouns) and activities (verbs), the interaction between the user and the things, and the interaction between the things themselves.
The above requirements were easily implemented in the desired timeframe, and next I'll discuss each one.
Configuration Classes
The configuration class began simple enough, but quickly grew into four different classes:
- The
Config
class itself, whose methods are accessed through a singleton property - The serializable configuration,
FileFlitConfig
, which just maintains a list of file records - The
FlitterRecord
class, which maintains the property values of a single file record - A class implementing
IEnumerable
, ConfigEnumerator
, to iterate over the record list
The only part that I didn't consider in the implementation requirements was the enumeration of the record list, and frankly was a kludgy "for convenience" implementation, one that I probably would never have created if the configuration class maintained anything more than a collection of file records. These classes take care of the implementation requirements #1-3.
FlitterRecord Class
This class maintains the property values of a single "Flitter" record. Because I like readable XML, I decorated the properties with XmlAttribute
tags, and you'll notice later on that the serialization uses the formatting option Formatting.Indented
. I originally created two constructors, one which doesn't require specifying the delay time to initiate the copy. Later on in some method, I use a default parameter value instead, so I figured, well, I should be consistent, and use a default parameter value for the constructor here. So here we are, faced with the amusing question, are multiple constructors better or are default parameter values better? I got rid of the first constructor, and amusingly, Visual Studio 2010 highlights the code with an error:
but it compiles just fine. So now, I'm stuck with a little red flag on my code, even though there's nothing wrong with it!
And of course, the default constructor is required for deserialization, so the class can be instantiated.
[Serializable]
public class FlitterRecord
{
[XmlAttribute("SourceFile")]
public string SourceFile { get; set; }
[XmlAttribute("DestinationFolder")]
public string DestinationFolder { get; set; }
[XmlAttribute("Delay")]
public int Delay { get; set; }
public FlitterRecord()
{
}
public FlitterRecord(string sourceFile,
string destinationFolder, int msDelay=1000)
{
SourceFile = sourceFile;
DestinationFolder = destinationFolder;
Delay = msDelay;
}
}
FileFlitConfig Class
This class maintains the collection of FileFlitter
records. I had initially put the List
into the Config
class (see below) but then started encountering serialization errors when I added the IEnumerable
interface. From MSDN:
The XmlSerializer
gives special treatment to classes that implement IEnumerable
or ICollection
. A class that implements IEnumerable
must implement a public Add
method that takes a single parameter. The Add
method's parameter must be of the same type as is returned from the Current
property on the value returned from GetEnumerator
, or one of that type's bases.
IEnumerable
makes serialization more complicated! So, not wanting to learn something new, I decided to separate out the class that is being serialized, that maintains the collection of FlitterRecord
instances. I figure, this is probably a better implementation anyways.
[Serializable]
public class FileFlitConfig
{
public List<FlitterRecord> FlitterRecords { get; set; }
public FileFlitConfig()
{
FlitterRecords = new List<FlitterRecord>();
}
}
Config Class
This isn't a very elegant class, it combines enumeration and encapsulation of the record collection class, as well as methods to support clearing the list, adding records to the list, and serializing / deserializing the list. There is a static static property "Records
" that, if configuration is not initialized, will do so in the property getter. I guess this is a quasi Factory and Singleton pattern. It's a Factory pattern because it handles the instantiation of the record collection, but it's a Singleton pattern because it returns an instance of itself and the constructor is protected. Well, whatever. Patterns are for nerdy discussions by geeks who drape their arms over the walls of their fellow cubicle inmates in an attempt at self-deification. And I wonder why I don't interview well!
Oh, and errors that are encountered in deserialization are reported to the user via a MessageBox
displayed in the deserializer method - talk about mixing of concerns! In the event of a deserialization error, the getter returns a Config
instance and the FlitterRecord
collection is empty. We don't really need to throw an exception, do we? We don't really need to inform the caller that there was an error, right? And whatever option could we possibly offer the user other than to grudgingly click on the "OK" button: "Yeah, there's an error, and you can't do anything about it!!!" Simplicity!
[Serializable]
public class Config : IEnumerable<FlitterRecord>
{
protected FileFlitConfig fileFlitConfig = new FileFlitConfig();
protected static Config config;
public static Config Records
{
get
{
if (config == null)
{
Deserialize();
}
return config;
}
protected set
{
config = value;
}
}
public FlitterRecord this[int i] { get { return fileFlitConfig.FlitterRecords[i]; } }
public int Count { get { return fileFlitConfig.FlitterRecords.Count; } }
public void Clear()
{
fileFlitConfig.FlitterRecords.Clear();
}
public void AddRecord(string sourceFile, string targetPath)
{
fileFlitConfig.FlitterRecords.Add(new FlitterRecord(sourceFile, targetPath));
}
public void AddRecord(string sourceFile, string targetPath, int msDelay)
{
fileFlitConfig.FlitterRecords.Add(
new FlitterRecord(sourceFile, targetPath, msDelay));
}
protected Config()
{
}
public void Serialize()
{
XmlTextWriter xtw = new XmlTextWriter("config.xml", Encoding.UTF8);
xtw.Formatting = Formatting.Indented;
XmlSerializer xs = new XmlSerializer(typeof(FileFlitConfig));
xs.Serialize(xtw, fileFlitConfig);
xtw.Close();
}
public static void Deserialize()
{
if (File.Exists("config.xml"))
{
XmlTextReader xtr = null;
try
{
xtr = new XmlTextReader("config.xml");
XmlSerializer xs = new XmlSerializer(typeof(FileFlitConfig));
config = new Config();
config.fileFlitConfig = (FileFlitConfig)xs.Deserialize(xtr);
}
catch (Exception e)
{
MessageBox.Show(e.Message, "Error Loading Configuration",
MessageBoxButtons.OK, MessageBoxIcon.Error);
config = new Config();
}
finally
{
xtr.Close();
}
}
else
{
config = new Config();
}
}
public IEnumerator<FlitterRecord> GetEnumerator()
{
return new ConfigEnumerator(fileFlitConfig.FlitterRecords);
}
IEnumerator IEnumerable.GetEnumerator()
{
return new ConfigEnumerator(fileFlitConfig.FlitterRecords);
}
}
ConfigEnumerator
This class supports the IEnumerable
interface from which the Config
class is derived, iterating through the "flitter" records. Nothing new here. Since I don't usually even write IEnumerator
implementations, I have no idea how to write one, so I basically just copied an example from MSDN. Like I said, I interview really poorly! I think interviews should allow the interviewee to use Google, the way students can now use calculators for tests. After all, doesn't that show something much more valuable, that I actually know how to find answers rather than have my brain cluttered with essentially useless information? But I digress...
public class ConfigEnumerator : IEnumerator, IEnumerator<FlitterRecord>
{
protected List<FlitterRecord> records;
protected int index;
public ConfigEnumerator(List<FlitterRecord> records)
{
this.records = records;
index = -1;
}
public object Current
{
get { return records[index]; }
}
public bool MoveNext()
{
++index;
return index < records.Count;
}
public void Reset()
{
index = -1;
}
FlitterRecord IEnumerator<FlitterRecord>.Current
{
get { return records[index]; }
}
public void Dispose()
{
}
}
The User Interface
The user interface was slapped together initially with the idea of having a grid for the file records and a textbox for the log, plus three buttons to add and delete records, and a save button to save the configuration. I quickly realized that I needed a couple buttons for selecting the source file and the target folder, and it would be nice to have a clear button for the log. In the process of writing the file selector, I also realized that being able to select multiple files was a definite must, something that did not make its way into the requirements statement or the implementation requirements. That's a point worth remembering, that consideration of how to handle the interface to the rest of the world ought to be formally specified--obvious in hindsight.
The Form1 Class
I didn't even bother renaming this class!
Some things that are noteworthy (not because of their elegance, in some cases, quite the opposite!):
- The file record list is converted to and from a
DataTable
, which is then handed off to the DataView
, which becomes the data source for a BindingSource
. A BindingSource
is used so the Position
property can be updated when rows are added, which then allows for updating cell values for the current row of the grid. - When a single source file is selected, it updates the currently selected row of the grid. When multiple source files are selected from the file browser, each source file is added as a new record.
- The static method
Log
provides a mechanism for logging activity to the form's logger TextBox
control without requiring an instance of the form.
The implementation is simple enough that I really didn't feel like it warranted a separate controller. So, what you're seeing here is basically just a View-View-View implementation, with the control concerns built directly into the View. We have a very narcissistic piece of code here, folks. It's all about the form, me, me, me! The form handles the implementation requirements above, #4, #6, and #7.
Oh, and yeah, I think I read somewhere that there's a way to bind a grid to a List
, but I'll be damned if I can remember how to make it work so I actually get three columns (I was only getting the Delay
field when I tried assigning the List
to the DataSource
), and I was too lazy to figure it out. And besides, when I implement the grid with a DevExpress XtraGrid
control (I find it annoying that I have to write articles using dumbed-down .NET controls, don't you??? - and please don't tell me to use WPF!), who knows what fun things will be possible?
public partial class Form1 : Form
{
protected static Form1 form;
protected DataTable dt;
protected DataView dv;
protected BindingSource bs;
public Form1()
{
form = this;
InitializeComponent();
Setup();
Populate();
Assign();
AddInitialRowIfNoData();
}
public static void Log(bool success, FlitterRecord fr,
string message="")
{
StringBuilder sb = new StringBuilder();
DateTime date=DateTime.Now;
sb.Append(date.ToString("MM/dd/yy"));
sb.Append(" ");
sb.Append(date.ToString("HH:mm:ss"));
sb.Append(" ");
if (success)
{
sb.Append("Copied " + fr.SourceFile +
" to " + fr.DestinationFolder);
}
else
{
sb.Append("Error copying " + fr.SourceFile);
sb.Append("\r\n");
sb.Append(message);
}
sb.Append("\r\n");
form.tbLog.Text += sb.ToString();
}
protected void Setup()
{
dt = new DataTable();
dt.Columns.Add("SourceFile", typeof(string));
dt.Columns.Add("TargetFolder", typeof(string));
dt.Columns.Add("Delay", typeof(int));
dv = new DataView(dt);
bs = new BindingSource();
bs.DataSource = dv;
}
protected void Populate()
{
foreach (FlitterRecord fr in Config.Records)
{
DataRow row = dt.NewRow();
row["SourceFile"] = fr.SourceFile;
row["TargetFolder"] = fr.DestinationFolder;
row["Delay"] = fr.Delay;
dt.Rows.Add(row);
}
dt.AcceptChanges();
}
protected void UpdateConfig()
{
Config.Records.Clear();
foreach (DataRow row in dt.Rows)
{
Config.Records.AddRecord(row["SourceFile"].ToString(),
row["TargetFolder"].ToString(),
Convert.ToInt32(row["Delay"]));
}
}
protected void Assign()
{
dgFlitter.DataSource = bs;
}
private void OnSave(object sender, EventArgs e)
{
UpdateConfig();
Config.Records.Serialize();
}
private void OnBrowseSource(object sender, EventArgs e)
{
AddInitialRowIfNoData();
OpenFileDialog fd = new OpenFileDialog();
fd.RestoreDirectory = true;
fd.Multiselect = true;
DialogResult res = fd.ShowDialog();
if (res == DialogResult.OK)
{
foreach (string fn in fd.FileNames)
{
if (fd.FileNames.Length > 1)
{
AddRow();
bs.Position = dt.Rows.Count - 1;
}
dgFlitter.CurrentRow.Cells["SourceFile"].Value = fn;
}
}
}
private void OnBrowseTarget(object sender, EventArgs e)
{
AddInitialRowIfNoData();
FolderBrowserDialog fbd = new FolderBrowserDialog();
DialogResult res = fbd.ShowDialog();
if (res == DialogResult.OK)
{
dgFlitter.CurrentRow.Cells["TargetFolder"].Value = fbd.SelectedPath;
}
}
private void OnRemove(object sender, EventArgs e)
{
if (bs.Position != -1)
{
bs.RemoveCurrent();
dt.AcceptChanges();
}
}
private void OnAdd(object sender, EventArgs e)
{
AddRow();
}
protected void AddRow()
{
DataRow row = dt.NewRow();
row["SourceFile"] = "[source file]";
row["TargetFolder"] = "[target folder]";
row["Delay"] = 1000;
dt.Rows.Add(row);
}
protected void AddInitialRowIfNoData()
{
if (dt.Rows.Count == 0)
{
AddRow();
}
}
private void OnMonitor(object sender, EventArgs e)
{
Update();
Monitor.Go(Config.Records.ToList());
}
private void OnUpdateNow(object sender, EventArgs e)
{
Update();
Monitor.UpdateNow(Config.Records.ToList());
}
private void OnClear(object sender, EventArgs e)
{
tbLog.Text = String.Empty;
}
}
The Monitor Class
The Monitor
class provides two static methods, one for immediately copying source files to target folders, and the other for initiating monitoring of source files. The class instance provides a method for initializing the FileSystemWatcher
instances (actually, a derived implementation, so that the FlitterRecord
can be associated with the file watcher) and the file changed event handler. OK, maybe Form1
isn't as narcissistic as I originally made it out to be, why gosh, here's sort of a controller!
public class Monitor
{
protected List<FileSystemWatcher> watchList;
protected static Monitor monitor;
public static void Go(List<FlitterRecord> fileList)
{
if (monitor == null)
{
monitor = new Monitor();
}
monitor.WatchFiles(fileList);
}
public static void UpdateNow(List<FlitterRecord> fileList)
{
foreach (FlitterRecord fr in fileList)
{
try
{
File.Copy(fr.SourceFile, Path.Combine(fr.DestinationFolder,
Path.GetFileName(fr.SourceFile)), true);
Form1.Log(true, fr);
}
catch (Exception e)
{
Form1.Log(false, fr, e.Message);
}
}
}
public Monitor()
{
watchList = new List<FileSystemWatcher>();
}
protected void WatchFiles(List<FlitterRecord> fileList)
{
foreach (FileSystemWatcher fsw in watchList)
{
fsw.Changed -= OnChanged;
}
List<FileSystemWatcher> newWatchList =
new List<FileSystemWatcher>();
foreach (FlitterRecord fr in fileList)
{
FlitterFileSystemWatcher fsw = new FlitterFileSystemWatcher(
Path.GetDirectoryName(fr.SourceFile),
Path.GetFileName(fr.SourceFile)) { FlitterRecord = fr };
fsw.NotifyFilter = NotifyFilters.LastWrite;
fsw.Changed += new FileSystemEventHandler(OnChanged);
fsw.EnableRaisingEvents = true;
newWatchList.Add(fsw);
}
watchList = newWatchList;
}
protected void OnChanged(object sender, FileSystemEventArgs e)
{
FlitterRecord fr = ((FlitterFileSystemWatcher)sender).FlitterRecord;
bool success=false;
int retries = 3;
while (!success)
{
try
{
Thread.Sleep(fr.Delay);
File.Copy(fr.SourceFile, Path.Combine(fr.DestinationFolder,
Path.GetFileName(fr.SourceFile)), true);
success = true;
}
catch(Exception ex)
{
if (--retries == 0)
{
Form1.Log(false, fr, ex.Message);
break;
}
}
if (success)
{
Form1.Log(true, fr);
}
}
}
}
Conclusion
I hope you've enjoyed this little adventure. And hopefully, if you actually use this utility, you will remember to click on the "Monitor!" button to get the thing going in an automated mode, unlike me, who keeps forgetting and then wondering why nothing got transferred to my distribution folders after compiling the project! So an indicator would be nice, and gee, maybe a way to stop the monitoring of files? Ya' think?