Even the most trivial of computer programs work with data. In many cases, the user will wish to save data and reuse it between one run of the program and a later session. Since different systems handle data differently, the user may need to save data in multiple formats for compatibility purposes. The program should know how to not only persist data but also read data from a variety of supported formats.
Let’s consider a word processes. When we write a document in a word processor, we may normally save documents in the word processor’s default file format. Later on, we may wish to share the document with another coworker or friend, but they have a different word processor than what we are using. The solution may be to use the word processor’s export feature to convert our document from the word processor’s default format to the file format our friend’s word processor is using.
Converting data isn’t just an issue for desktop programs either. We may have a web application deployed that may store data in an RBDMS but have to export the same data to XML or JSON. Likewise, it’s very likely a web application may need to consume JSON and export data as a CSV format. Clearly, there is plenty of need for an application to be flexible about file formats.
That means we should not tie the persistent mechanism to a data structure. It might seem to go against the OOP principle of encapsulation where each object is responsible for its own data but think about the implications of such a design. If we write the persistence logic into a class, the class is tied to a particular file format. What happens when the class needs to read and write to a different file format? We could add additional persistence mechanisms to the class, but eventually, the class becomes bloated as we add more and more persistence schemes to it.
Another approach might be to define abstract methods for saving and retrieving data, but this is equally as bad of an approach. For one thing, are we really going to create an entire class hierarchy just for reading and writing data? Furthermore, we have no means with which to enforce a certain storage mechanism since all instances of such a class would resolve their storage logic using virtual methods. Finally, we would most likely need to convert instances of one subclass to another subclass in order to switch between storage mechanisms, which could send us down the rabbit hole of deep cloning objects.
If you think about it, storing and retrieving data is a completely separate concern of the application. A data object should never end up being responsible for its own persistence. That needs to be the concern of a completely different class or set of classes. Thus we end up with the data access pattern.
The data access pattern relies on a transfer object to hold the data. The transfer object is passed between the view (which is normally the UI) and the source of the data. Kotlin has a special data class that we can use for this specific purpose. Let’s have a look at an example.
/** * This a model class that is persisted to disk in this program */ data class Cook(val fName : String, val lName : String, val position : String, val age : Int) : Serializable
Koltin data classes get a built-in implementation of hashCode(), equals(), and toString(). We are free to add additional methods to them if needed. We also have this class implementing Serializable for a later demonstration. The important part to note about the Cook class is that it does not handle its own persistence. That is done by another class that is specific for this purpose.
Once we have our transfer object, we can begin by defining our persistence classes. Since we plan on using different formats for reading and writing data, we should use an interface as an abstraction point.
/** * This interface describes the kinds of Data Access Operations available */ interface CookDao { fun save(c : Cook, outStream: OutputStream) fun read(inStream: InputStream) : Cook }
Our CookDao interface defines two methods. The first method, save(), takes a Cook object and an OuputStream. The Cook object is, of course, the transfer object. As for the OutputStream, it’s best to use OutputStream rather than File because it allows this Dao class to work with non-file streams such as network sockets. The DAO class should be concerned with how the object is persisted and retrieved, but it should not be coupled to a particular source. This keeps it flexible and allows us to send and store data on File Systems, Network Sockets, Http Responses, etc.
The same principle holds true for the read() function as well. This method takes an InputStream, the reciprocal class for OutputStream, and returns a Cook object. This allows us to create a Cook from any input stream. Once again, the source of the InputStream isn’t important to the class, only that it contains the data needed to create a Cook object.
Now that we have our interface, let’s look out our implementations. At first, our application wishes to store Cooks as Serialized Java Objects. Let’s create a version of CookDao that accomplishes this task.
/** * Implementation of CookDao that uses JVM serialization */ class SerializedCookDao : CookDao{ override fun save(c: Cook, outStream: OutputStream) { val o = ObjectOutputStream(outStream) outStream.use { o.writeObject(c) } } override fun read(inStream: InputStream) : Cook{ val i = ObjectInputStream(inStream) inStream.use { return i.readObject() as Cook } } }
The SerializedCookDao implements CookDao and stores Cook as a Serialized Java object. When we wish to restore the Cook, we use the readObject() method found on ObjectInputStream to restore the Cook. Either way, the application at large isn’t concerned about how cook is persisted and restored.
Later on, we decide to support command separated values format or CSV. We don’t need to change any client code to accomplish such as task. All we need to do is define another class that implements CookDao.
/** * Implementation of CookDao that converts Cooks to a CSV file */ class CsvCookDao : CookDao { //Private extension function on Cook for creating //CSV files private fun Cook.toCSV() : String { return "${this.fName},${this.lName},${this.age},${this.position}" } //Private function to convert a CSV line to a Cook private fun parseCsv(csv : String) : Cook{ val parts = csv.split(",") return Cook(fName = parts[0], lName = parts[1], age = parts[2].toInt(), position = parts[3]) } override fun save(c: Cook, outStream: OutputStream) { outStream.use { PrintWriter(outStream, true).println(c.toCSV()) } } override fun read(inStream: InputStream): Cook { inStream.use { val sc = Scanner(inStream) return parseCsv(sc.nextLine()) } } }
The CsvCookDao class does the job. There are a couple of things to note about this class that makes it more interesting. The first is the extension function Cook.toCSV(). Kotlin extension functions let us add extra functionality to a class. We could have added a toCSV() method to Cook, but let’s think about the implications of such a design. For one thing, only CsvCookDao is concerned about making a Cook into a line of CSV. If we added toCSV() to Cook, then we are saying that all Cook objects should be able to turn themselves into CSV at any time.
That’s bad because we are violating the separation of concerns principle again. We don’t want Cook to concern itself with persistence. That’s the job of our DAO classes. Thus, our CsvCookDao gets a private method to convert Cooks into CSV. Likewise, we have the parseCsv function which also turns CSV back into Cooks. We could violate the separation of concerns by adding a constructor to the Cook class, but that would be equally as bad as adding a toCSV() method.
Now that we have our DAO classes, we need a way to pass Cooks from the view to the DAO. We can define a service class for this purpose.
/** * CookService uses Kotlin's delegation mechanism. CookService will have * all of the same methods as CookDao but the implementation will depend * on the CookDao that was provided */ class CookService(private val cookDao: CookDao) : CookDao by cookDao
The CookService class is powerful but incredibly compact. Instances of this class are using Kotlin’s delegation mechanism where all of the methods of CookService are wrapped by methods found in CookDao. It’s this mechanism that lets our application switch between serialization and CSV so easily. The view will use CookService. The CookService is created by passing an instance of CookDao to the constructor of CookService. Whichever CookDao is used will determine the data format the application is currently using!
Let’s have a look at the final portion of the application to see this in action.
fun saveAndRestore(bob : Cook, cookService : CookService, inStream : InputStream, outStream : OutputStream){ println("Bob before saving => " + bob) cookService.save(bob, outStream) var restoredBob = cookService.read(inStream) println("Bob after restoring => " + restoredBob) } fun createIfNeeded(name : String) : File{ val f = File(name) if(!f.exists()){ f.createNewFile() } return f } fun main(args : Array<String>){ val bob = Cook("Bob", "Belcher", "Owner", 45) println("Using CSV") saveAndRestore(bob, CookService(CsvCookDao()), //Application is using CSV FileInputStream(createIfNeeded("bob.csv")), FileOutputStream(createIfNeeded("bob.csv"))) println() println("Using serialization") saveAndRestore(bob, CookService(SerializedCookDao()), //Application is using Serialization FileInputStream(createIfNeeded("bob.ser")), FileOutputStream(createIfNeeded("bob.ser"))) }
The saveAndRestore function does the job of saving and restoring a Cook object. However, it has no idea of where or how a Cook is handled. The saveAndRestore function interacts soley with CookService. The CookService objects are created in the main method. When a CsvCookDao is used in the CookService constructor, the Cooks are saved in CSV format. When SerialiazedCookDao is used instead, the application will use JVM serialization to save and restore a Cook.
It’s easy to imagine how easily this program can be extended later on. Suppose we which to support XML. All we need to do is create a new CookDao class that handles the transformation of a Cook object to and from XML. Later on, we would pass this CookDao class to the constructor of CookService and the rest of the application would continue to work. In this fashion, we can easily continue to add additional file formats as needs change.
Putting it Together
Here is the program in its entirety followed by output.
package ch4.dataacesspattern import java.io.* import java.util.* /** * This a model class that is persisted to disk in this program */ data class Cook(val fName : String, val lName : String, val position : String, val age : Int) : Serializable /** * This interface describes the kinds of Data Access Operations available */ interface CookDao { fun save(c : Cook, outStream: OutputStream) fun read(inStream: InputStream) : Cook } /** * Implementation of CookDao that uses JVM serialization */ class SerializedCookDao : CookDao{ override fun save(c: Cook, outStream: OutputStream) { val o = ObjectOutputStream(outStream) outStream.use { o.writeObject(c) } } override fun read(inStream: InputStream) : Cook{ val i = ObjectInputStream(inStream) inStream.use { return i.readObject() as Cook } } } /** * Implementation of CookDao that converts Cooks to a CSV file */ class CsvCookDao : CookDao { //Private extension function on Cook for creating //CSV files private fun Cook.toCSV() : String { return "${this.fName},${this.lName},${this.age},${this.position}" } //Private function to convert a CSV line to a Cook private fun parseCsv(csv : String) : Cook{ val parts = csv.split(",") return Cook(fName = parts[0], lName = parts[1], age = parts[2].toInt(), position = parts[3]) } override fun save(c: Cook, outStream: OutputStream) { outStream.use { PrintWriter(outStream, true).println(c.toCSV()) } } override fun read(inStream: InputStream): Cook { inStream.use { val sc = Scanner(inStream) return parseCsv(sc.nextLine()) } } } /** * CookService uses Kotlin's delegation mechanism. CookService will have * all of the same methods as CookDao but the implementation will depend * on the CookDao that was provided */ class CookService(private val cookDao: CookDao) : CookDao by cookDao fun saveAndRestore(bob : Cook, cookService : CookService, inStream : InputStream, outStream : OutputStream){ println("Bob before saving => " + bob) cookService.save(bob, outStream) var restoredBob = cookService.read(inStream) println("Bob after restoring => " + restoredBob) } fun createIfNeeded(name : String) : File{ val f = File(name) if(!f.exists()){ f.createNewFile() } return f } fun main(args : Array<String>){ val bob = Cook("Bob", "Belcher", "Owner", 45) println("Using CSV") saveAndRestore(bob, CookService(CsvCookDao()), //Application is using CSV FileInputStream(createIfNeeded("bob.csv")), FileOutputStream(createIfNeeded("bob.csv"))) println() println("Using serialization") saveAndRestore(bob, CookService(SerializedCookDao()), //Application is using Serialization FileInputStream(createIfNeeded("bob.ser")), FileOutputStream(createIfNeeded("bob.ser"))) }
Output
Using CSV Bob before saving => Cook(fName=Bob, lName=Belcher, position=Owner, age=45) Bob after restoring => Cook(fName=Bob, lName=Belcher, position=Owner, age=45) Using serialization Bob before saving => Cook(fName=Bob, lName=Belcher, position=Owner, age=45) Bob after restoring => Cook(fName=Bob, lName=Belcher, position=Owner, age=45)