Design Bite - PlugIn Structure
line-counter with run-time loadable output component
"I am always doing that which I can not do, in order that I may learn how to do it."
- Pablo Picasso
1.0 Introduction
This DesignBite sequence was inspired by BuildOn project TextFinder.
As that project is designed and implemented, a number of design decisions are made, consciously or unconsciously.
Each of these pages addresses one answer to questions about fundamental decisions of structure.
To make discussion pragmatic and concrete, we implement a program that evaluates the number of lines
in text files. Processing is quite simple so it allows us to see how each
structure alternative is implemented.
We consider both package structure and logical structure, e.g., functions and structs used to order
design and implementation. In this PlugIn Structure page, code is implemented in a set of packages
Executive, Input, Compute, and Output and their
structs. That provides all of the organization for processing.
2. Application Structure - PlugIn
This structure is modular with a data flow structure. It differs from the previous TypeErase structure
in that:
-
Its ComputeImpl type is not parameterized.
-
That allows the Output type to be determined at run-time, affording application flexability
in the way it displays output, based on user input.
-
It also means that Executive instantiates the Output type and delivers that to ComputeImpl
via a get_app() method of the Input type.
-
The Rust data model requires that to be a trait object created on the heap (wrapped in a Box).
Figure 6. PlugIn Type Structure
PlugIn Structure
The PlugIn structure is very similar to Type Erasure, but with the flexibility to define output processing
at run-time. The Executive creates an OutputImpl type at run-time and configures
ComputeImpl with that type.
That can be any type that implements the Output trait. We get that freedom by not
parameterizing ComputeImpl on OutputImpl, as we did in Type Erasure.
Now, ComputeImpl cannot
create an instance of the OutputImpl type because it only has the Output trait
definition, but no paramaterized type to get an instance using a factory function.
This gives the application freedom to choose the type of output processing based on a command line input.
PlugIn has most of the characteristics of the Type Erasure structure, with the addition of freedom of
choice for output processing.
Implementation gets a bit more complicated because Executive has to create an instance of
the desired type and give it to ComputeImpl. It depends on Input to provide
access via an get_app(&mut self) method.
Pros:
- Same as data flow
-
The Input, Compute, and Output parts are now decoupled, each depending only on
the interfaces it defines and the interfaces of upstream components.
-
Freedom to define Output processing at run-time through a polymorphic run-time
dispatch.
Cons:
-
The building process becomes more complex. Each component needs to use a factory
function to create its downstream component (to maintain type ignorance).
-
Again, more complex because Executive needs access to Compute to configure
it with Output.
PlugIn Code Repository
Executive::main.rs
/////////////////////////////////////////////////////////////
// PluginDataFlowStructure::Executive::main.rs //
// - Executive creates and uses all lower level parts //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
/*
Note:
Executive creates Input and Output instances. Comp
instance is created by Inupt.
*/
use input::*;
use compute::*;
use output::*;
fn main() {
let putln = || println!();
print!("\n -- PluginDataFlowStructure::Executive --\n");
let mut lines = 0;
let mut inp = Input::<ComputeImpl>::new();
let out = OutputImpl::new();
let app = inp.get_app();
app.set_output(Box::new(out)); // use of trait object
let name = "../Executive/src/main.rs";
let srclines = inp.do_input(name);
lines += srclines;
putln();
let name = "../Input/src/lib.rs";
let srclines = inp.do_input(name);
lines += srclines;
let name = "../Input/examples/test1.rs";
let srclines = inp.do_input(name);
lines += srclines;
putln();
let name = "../Compute/src/lib.rs";
let srclines = inp.do_input(name);
lines += srclines;
let name = "../Compute/examples/test1.rs";
let srclines = inp.do_input(name);
lines += srclines;
putln();
let name = "../Output/src/lib.rs";
let srclines = inp.do_input(name);
lines += srclines;
let name = "../Output/examples/test1.rs";
let srclines = inp.do_input(name);
lines += srclines;
putln();
let name = "no-exist";
let srclines = inp.do_input(name);
lines += srclines;
putln();
print!("\n total lines: {}", lines);
print!("\n\n That's all Folks!\n\n");
}
Output
-- PluginDataFlowStructure::Executive --
65 lines in file "../Executive/src/main.rs"
61 lines in file "../Input/src/lib.rs"
30 lines in file "../Input/examples/test1.rs"
74 lines in file "../Compute/src/lib.rs"
46 lines in file "../Compute/examples/test1.rs"
29 lines in file "../Output/src/lib.rs"
16 lines in file "../Output/examples/test1.rs"
can't open file "no-exist"
total lines: 321
That's all Folks!
cargo.toml
[package]
name = "executive"
version = "0.1.0"
authors = ["James W. Fawcett"]
edition = "2018"
# See more keys ...
[dependencies]
input = { path = "../Input" }
compute = { path = "../Compute" }
output = { path = "../Output" }
Input::lib.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Input::lib.rs //
// - Attempts to return line count from file //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
/*
Note:
- Input owns and instantiates Compute.
- It attempts to open file and pass to Compute for
processing.
- Returns line count if successful
*/
use std::fs::*;
pub trait Compute {
fn new() -> Self;
fn do_compute(&mut self, name: &str, file:File);
fn lines(&self) -> usize;
}
mod file_utilities;
use file_utilities::open_file_for_read;
#[derive(Debug)]
pub struct Input<T: Compute> {
name: String,
compute: T
}
impl<T: Compute> Input<T> {
pub fn new() -> Input<T> {
Input {
name: String::new(),
compute: T::new()
}
}
pub fn do_input(&mut self, name: &str) -> usize {
let mut lines: usize = 0;
self.name = name.to_string();
let rslt = open_file_for_read(name);
if let Ok(file) = rslt {
self.compute.do_compute(name, file);
lines = self.compute.lines();
}
else {
print!("\n can't open file {:?}", name);
}
lines
}
pub fn get_app(&mut self) -> &mut T {
&mut self.compute
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
file_utilities module
/////////////////////////////////////////////////////////////
// FactoredStructure::Input::file_utilities.rs //
// - Input attempts to open named file and return File //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
/*
This code may be useful for other programs so it is
factored into a module here.
*/
#![allow(dead_code)]
use std::fs::*;
use std::io::{Read, Error, ErrorKind};
pub fn open_file_for_read(file_name:&str)
->Result<File, std::io::Error> {
let rfile = OpenOptions::new()
.read(true)
.open(file_name);
rfile
}
pub fn read_file_to_string(f:&mut File)
-> Result<String, std::io::Error> {
let mut contents = String::new();
let bytes_rslt = f.read_to_string(&mut contents);
if bytes_rslt.is_ok() {
Ok(contents)
}
else {
Err(Error::new(ErrorKind::Other, "read error"))
}
}
test1.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Input::test1.rs //
// - Attempts to return line count from file //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
use input::*;
use std::fs::*;
struct MockCompute {}
impl Compute for MockCompute {
fn new() -> MockCompute {
MockCompute{}
}
fn do_compute(&mut self, name: &str, _file:File) {
print!("\n {} doing mock computation", name);
}
fn lines(&self) -> usize {
0
}
}
fn main() {
print!("\n -- input::test1 --\n");
let mut inp = Input::<MockCompute>::new();
let name = "./src/lib.rs";
let lines = inp.do_input(name);
print!("\n received {} lines from compute", lines);
print!("\n\n That's all Folks!\n\n");
}
Test Output
-- input::test1 --
./src/lib.rs doing mock computation
received 0 lines from compute
That's all Folks!
cargo.toml
[package]
name = "input"
version = "0.1.0"
authors = ["James W. Fawcett"]
edition = "2018"
# See more keys ...
[dependencies]
Compute::lib.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Compute::lib.rs //
// - Attempts to read opened file to string, count lines //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
/*
Note:
- creates instance of Output
- attempts to read file to string and count its lines
- sends results to Output
*/
use input::{Compute};
use std::fs::*;
/*---------------------------------------------------------
PlugIns require use of Trait Objects
-----------------------------------------------------------
Trait objects cannot:
- have functions that return Self
- have generic functions
*/
pub trait Output {
// fn new() -> Self;
fn do_output(&self, name: &str, lines: usize);
}
mod file_utilities;
use file_utilities::read_file_to_string;
pub struct ComputeImpl {
lines: usize,
out: Option<Box<dyn Output>> // will hold trait object
}
impl Compute for ComputeImpl {
fn new() -> ComputeImpl {
ComputeImpl {
lines: 0,
out: None
}
}
fn do_compute(&mut self, name: &str, mut file:File) {
let rslt = read_file_to_string(&mut file);
if let Ok(contents) = rslt {
self.lines = 1;
for ch in contents.chars() {
if ch == '\n' {
self.lines += 1;
}
}
if let Some(plug) = &self.out {
plug.do_output(name, self.lines);
}
}
else {
print!("\n could not read {:?}", name);
}
}
fn lines(&self) -> usize {
self.lines
}
}
impl ComputeImpl {
pub fn set_output(&mut self, out: Box<dyn Output>) {
&self.out.replace(out);
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
Module file utilities
Module copied from input/src
test1.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Compute::test1.rs //
// - Attempts to read opened file to string, count lines //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
use compute::*;
use input::Compute;
use compute::Output;
use std::fs::*;
use std::io::*;
// use std::option::*;
struct MockOutput {}
impl Output for MockOutput {
fn do_output(&self, name: &str, lines: usize) {
print!("\n sending {} lines to {:?}", lines, name);
}
}
impl MockOutput {
pub fn new() -> MockOutput {
MockOutput{}
}
}
fn open_file_for_read(file_name:&str)
-> Result<File> {
let rfile = OpenOptions::new()
.read(true)
.open(file_name);
rfile
}
fn main() {
print!("\n -- compute::test1 --\n");
let name = "./src/lib.rs";
let rslt = open_file_for_read(name);
if let Ok(file) = rslt {
let mut compute = ComputeImpl::new();
let mo = Box::new(MockOutput::new());
compute.set_output(mo);
let _ = compute.do_compute(name, file);
}
print!("\n\n That's all Folks!\n\n");
}
Output
-- compute::test1 --
sending 74 lines to "./src/lib.rs"
That's all Folks!
cargo.toml
[package]
name = "compute"
version = "0.1.0"
authors = ["James W. Fawcett"]
edition = "2018"
# See more keys ...
[dependencies]
input = { path = "../Input" }
Output::lib.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Output::lib.rs //
// - Sends results to console //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
use compute::Output;
#[derive(Debug)]
pub struct OutputImpl {}
impl Output for OutputImpl {
fn do_output(&self, name: &str, lines: usize) {
print!("\n {} lines in file {:?}", lines, name);
}
}
impl OutputImpl {
pub fn new() -> OutputImpl {
OutputImpl {}
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
test1.rs
/////////////////////////////////////////////////////////////
// PlugInDataFlowStructure::Output::test1.rs //
// - Sends results to console //
// Jim Fawcett, https://JimFawcett.github.io, 04 Mar 2021 //
/////////////////////////////////////////////////////////////
use output::*;
use compute::Output;
fn main() {
print!("\n -- test Output --\n");
let out = OutputImpl::new();
out.do_output("SomeFile.rs", 3);
print!("\n That's all Folks!\n\n");
}
Test Output
-- test Output --
3 lines in file "SomeFile.rs"
That's all Folks!
cargo.toml
[package]
name = "output"
version = "0.1.0"
authors = ["James W. Fawcett"]
edition = "2018"
# See more keys ...
[dependencies]
compute = { path = "../Compute" }
3. Epilogue
The five design alternatives considered here:
- Monolithic Structure
- Factored Structure
- DataFlow Structure
- TypeErase Structure
- PlugIn Structure
are progressively more flexible, eventually resulting in reusable components, but also increasingly
complex. Where you settle in these alternatives is determined by design context. Is this a
one-of-a-kind project that you want to finish quickly or is it
heading for production code that will be maintained by more than one developer?