One of the most important principles in building complex software systems, is detaching the business logic from the screens that allow users to view and edit information.
You’ll notice I said “most important” and not “most popular.” Sad but true – all modern IDEs make it so easy for a programmer to simply whack together a bunch of user interface controls, that there are probably far more forms with embedded business logic than without.
The fact is that for simple systems, this can often be convenient and harmless. But for more complex ones, it nearly always spells disaster (or at least chronic pain). The gold standard for these kinds of systems is to have a set of business objects and a set of screens that merely displays and edits them.
Building a proper business object framework is a complex topic all of its own, but Delphi (and all the other modern languages) give us more than enough riches in the object model to implement what we need in this regard.
What environments like Delphi don’t do very well, is give developers the tools to build a form that can capably display and edit business objects without knowing too much about them. And very generic entry forms only work well for very generic data. Invariably, specialised data require specialised forms that give the user appropriate functionality and a sensible layout.
So you end up with lots and lots of code like this:
procedure TCustomerForm.LoadForm(Customer: TCustomer); begin NameEdit.Text := Customer.Name; EmailEdit.Text := Customer.Email; end; procedure TCustomerForm.PostForm(Customer: TCustomer); begin Customer.Name := NameEdit.Text; Customer.Email := EmailEdit.Text; end;And this kind of boiler-plate code is yet another reason while lots of programmers throw structure to the hounds and just integrate the business logic into the form.
Well, no need.
Delphi’s RTTI framework has always allowed one to access and manipulate all the published properties and fields on an object and I have been using a framework based on that for over ten years now. Simply publish all the properties you want to represent on the form and you can match object properties to screen controls by name and type.
Delphi 2010 has of course overhauled the RTTI framework completely and I quickly adapted my code to use that instead. Truth be told, the new RTTI is a little finicky and brittle, but it is also far richer in functionality and I found it well worth the effort to overhaul my classes.
What I am about to present here is not based in any way on the framework in our system, but illustrates very nicely how that works. Having said that, what I will show here is also simplified to the point that you will definitely want to extend it a little before you go gold with it.
Exploring The Cliff Faces
There is no real limit to the data types that you can have on your business object, but VCL controls almost all use simpler types to represent their values. So the first thing you’ll need is some sort of mapping of how you will want to represent information.
Here is a quick table of reasonable mappings:
Business Object Property | Possbile VCL Controls |
string, TObject | TEdit, TComboBox, TLabel |
Boolean | TCheckBox |
Integer | TEdit, TUpDown, TTrackBar |
Double | TEdit |
TDateTime | TDateTimePicker |
Enumerated type | TComboBox, TListBox |
Set type | TCheckListBox |
I’m sure you can think of a few more and probably disagree with me on a few, but you get to pick your own.
The plan is to have a completely autonomous business object and a very simple screen that can display and modify the business object without knowing much about it.
Here is the business object I am going to use:
TPerson = class private FName: string; FAge: Integer; FOccupation: string; public property Name: string read FName write FName; property Age: Integer read FAge write FAge; property Occupation: string read FOccupation write FOccupation; end;And my form is as simple as they get too:
The load button predictably loads the content of a TPerson instance into the form, the Save button does the reverse and Clear Form… well OK.
Here is the code for the Load and Save buttons:
procedure TCustomerForm.LoadButtonClick(Sender: TObject); begin Binding.Load; end; procedure TCustomerForm.SaveButtonClick(Sender: TObject); begin Binding.Save; end;That is about as simple as it gets, right? An obvious enhancement is to make the form validate the values before assigning them to the form, but let’s not get ahead of ourselves.
The Binding variable above is of the TObjectBinding class which links together any TWinControl and any object. Before I show that – here are a few things you probably know, but let’s cover them anyway:
- All controls placed on a form at design time get their own published fields. This lets the streaming system know what should go into the form file and what shouldn’t.
- Not all public properties on your business object necessarily need to be displayed on the form. And none of those properties that you want to display will be published unless you made them so.
- Properties have names. So do screen controls. They may even match, but lots of programmers prefer to have their controls named something like AgeEdit or EdtAge instead of just Age.
- Not all properties are writeable. In fact, you could (probably shouldn’t) even create properties that can be written to but not read.
Clearly then, we can find the relevant controls by using Delphi’s new RTTI framework to iterate the published fields that are controls. Finding the relevant business object fields can be as simple as grabbing all the public and published properties or it can involve checking attributes. Or only properties with an even-length name. My class has a method to pick out valid fields and another to pick out valid properties. Modify these to your heart’s content.
And in production code you may want to pick up some of the form’s properties as well. Works well when you need to override some of the basic functionality.
Building The Narrow Bridge
Here is the class declaration – I’ll discuss the methods as we go along:
TObjectBinding = class private // Quick way to get from the object // field to its screen control. PropFieldMapping: TDictionary<TRttiProperty, TRttiField>; // Needed for RTTI. Context: TRttiContext; ControlType: TRttiType; ObjType: TRttiType; // The control (normally form) and // the object it represents. Control: TWinControl; Obj: TObject; // Finds the object properties that have corresponding // fields and stores them in the dictionary. procedure CreateMappings; function FindField(Prop: TRttiProperty; out Field: TRttiField): Boolean; function FieldClass(Field: TRttiField): TClass; // Modify these to change the rules about what // should be matched. function IsValidField(Field: TRttiField): Boolean; function IsValidProp(Prop: TRttiProperty): Boolean; // Modify these to change the mappings of property type // to VCL control class. procedure AssignField(Prop: TRttiProperty; Field: TRttiField); procedure AssignProp(Prop: TRttiProperty; Field: TRttiField); // Used from AssignField/AssignProp. Extend these to // support a wider range of properties. function GetPropText(Prop: TRttiProperty): string; procedure SetPropText(Prop: TRttiProperty; const Text: string); public procedure Load; procedure Save; constructor Create(Control: TWinControl; Obj: TObject); destructor Destroy; override; end;Load and Save can easily be supplemented here with Validate, which will use attributes specified on the business object properties to decide whether the current screen values are valid. If not, we don’t call Save at all.
The constructor and destructor do what you expect. They create and destroy the objects held in those private fields. The constructor also calls CreateMappings, which is where a lot of the fun stuff happens.
constructor TObjectBinding.Create(Control: TWinControl; Obj: TObject); begin inherited Create; Self.Control := Control; Self.Obj := Obj; Context := TRttiContext.Create; ControlType := Context.GetType(Control.ClassInfo); ObjType := Context.GetType(Obj.ClassInfo); PropFieldMapping := TDictionary<TRttiProperty, TRttiField>.Create; CreateMappings; end; destructor TObjectBinding.Destroy; begin PropFieldMapping.Free; ObjType.Free; ControlType.Free; Context.Free; inherited; end; procedure TObjectBinding.CreateMappings; var Props: TArray<TRttiProperty>; Prop: TRttiProperty; Field: TRttiField; begin Props := ObjType.GetProperties; for Prop in Props do if IsValidProp(Prop) and FindField(Prop, Field) then PropFieldMapping.Add(Prop, Field); end;CreateMappings scans through all the properties on the business object and attempts to find fields on the form to match them to. If a match is found, they are stored in the PropFieldMapping dictionary for future reference.
Notice the IsValidProp function. That is where you should impose any additional rules about whether or not a property should be taken into account. The default implementation simply grabs all the public and published properties.
function TObjectBinding.IsValidProp(Prop: TRttiProperty): Boolean; begin Result := Prop.Visibility >= mvPublic; end;Also, there is a FindField function. This little guy does a bit more than just grab all the published fields. Again, there is an IsValidField function for you to toy with. But it first attempts to find a control with a name that matches exactly with that of an approved property. If not, it also checks for a few common variants of the name (AgeEdit and EdtAge as mentioned before). This list will obviously need to grow if you support many more controls.
function TObjectBinding.FieldClass(Field: TRttiField): TClass; begin Result := GetTypeData(Field.FieldType.Handle).ClassType; end; function TObjectBinding.IsValidField(Field: TRttiField): Boolean; begin Result := (Field <> nil) and (Field.Visibility = mvPublished) and (Field.FieldType.TypeKind = tkClass) and (FieldClass(Field).InheritsFrom(TControl)); end; function TObjectBinding.FindField(Prop: TRttiProperty; out Field: TRttiField): Boolean; const Embelishments: array [0..5] of string = ('Edt', 'Edit', 'Combo', 'ComboBox', 'Lookup', 'Lkp'); var Emb: string; begin Field := ControlType.GetField(Prop.Name); if IsValidField(Field) then Exit(True) else for Emb in Embelishments do begin Field := ControlType.GetField(Prop.Name + Emb); if not IsValidField(Field) then Field := ControlType.GetField(Emb + Prop.Name); if IsValidField(Field) then Exit(True); end; Result := False; end;Now that we have a dictionary of business object properties and the screen fields that we want to link them to, we can take a look at the very similar-looking Load and Save methods.
procedure TObjectBinding.Load; var Prop: TRttiProperty; begin for Prop in PropFieldMapping.Keys do AssignField(Prop, PropFieldMapping[Prop]); end; procedure TObjectBinding.Save; var Prop: TRttiProperty; begin for Prop in PropFieldMapping.Keys do AssignProp(Prop, PropFieldMapping[Prop]); end;AssignProp and AssignField are probably the two most difficult functions to write in this entire class. The problem is that Delphi’s TValue structure used in the new RTTI framework makes no attempt whatsoever to convert data. Instead, you need to do any conversions yourself and assign to the exact type you need. That isn’t too much of a problem, but it means lots of individually-crafted conversion blocks.
To keep my example manageable, I opted to convert everything to strings and back using GetPropText and SetPropText. And I only support string, Integer, Double and TDateTime. I didn’t use Double and TDateTime on my TPerson business object, but they do require a special trick that I wanted to show.
procedure TObjectBinding.AssignField(Prop: TRttiProperty; Field: TRttiField); var NestedControl: TControl; PropText: string; begin NestedControl := Field.GetValue(Control).AsObject as TControl; PropText := GetPropText(Prop); if NestedControl is TCustomEdit then TCustomEdit(NestedControl).Text := PropText else if NestedControl is TCustomComboBox then TComboBox(NestedControl).Text := PropText; end; procedure TObjectBinding.AssignProp(Prop: TRttiProperty; Field: TRttiField); var NestedControl: TControl; FieldText: string; begin NestedControl := Field.GetValue(Control).AsObject as TControl; if NestedControl is TCustomEdit then FieldText := TCustomEdit(NestedControl).Text else if NestedControl is TCustomComboBox then FieldText := TComboBox(NestedControl).Text else FieldText := ''; SetPropText(Prop, FieldText); end; procedure TObjectBinding.SetPropText(Prop: TRttiProperty; const Text: string); var V: TValue; begin case Prop.PropertyType.TypeKind of tkInteger: V := StrToIntDef(Text, 0); tkFloat: if Prop.PropertyType.Handle = TypeInfo(TDateTime) then V := StrToDateDef(Text, 0) else V := StrToFloatDef(Text, 0); tkUString: V := Text; // And other types handled in similar ways else Exit; // Or some reasonable default action end; Prop.SetValue(Obj, V); end; function TObjectBinding.GetPropText(Prop: TRttiProperty): string; var V: TValue; begin V := Prop.GetValue(Obj); case Prop.PropertyType.TypeKind of tkInteger: Result := IntToStr(V.AsInteger); tkFloat: if Prop.PropertyType.Handle = TypeInfo(TDateTime) then Result := DateToStr(V.AsType<TDateTime>) else Result := FloatToStr(V.AsType<Double>); tkUString: Result := V.AsString; // And other types handled in similar ways else Result := ''; // Or some reasonable default end; end;Firstly, the AssignProp and AssignField functions each has a check for every supported VCL control base class. So to support TCheckBox or TCheckListBox this is where you would add the additional support code.
Then take a look at GetPropText and SetPropText. They mirror one another of course, so we can learn what we need by inspecting GetPropText.
First thing to notice is that string properties use the tkUString type and not tkString as in pre-Unicode versions of Delphi. Also, we can simply read TValue.AsInteger when reading an integer value.
The trouble arises when we look at Double and TDateTime. TDateTime is actually a type alias for Double and they both show up as tkFloat. This is why I compare the type info from my property with the type info for TDateTime. The alternative would be to check the type name, but that thought makes me queasy. The same kind of thing will be needed to tell Boolean from other enumerated types.
Also, both Double and TDateTime are incompatible with TValue.AsExtended, which is of course a floating point type of a different size. So taking care of all these type conversions become really cumbersome, but of course you only need to write the code once.
And that’s it
Hooking all of this together, you can now have business objects that are completely detached from their editing screens. You’ll find that the screens become really trivial to develop – most cases involve little more than slapping on the controls, naming them and calling Load and Save. And that last bit can be done is a base class.
You also get to take advantage of all the other advantages that come with a proper split. You will find it easier to modify your business rules, easily use the same business logic in other contexts (say a bulk import versus the capture screen) and also be able to validate your business rules using automated tests. Good idea!
I can now show you my super-complex test app in action. Note that the first screen shot shows the names of both edit boxes and the combo box. Despite the different naming conventions, they all link up automatically to the correct properties.
1. Starting up.
2. Click Load Form.
3. Mess around with the values and click Save to Object.
4. Click Clear Form.
5. And finally, load again.