Delphi Clinic | C++Builder Gate | Training & Consultancy | Delphi Notes Weblog | Dr.Bob's Webshop |
|
Multi-tier/Distributed Database Applications in .NET
In this paper, a techniques is demonstrated to build multi-tier database applications with ASP.NET Web Services using Delphi for .NET as the development environment.
The server-side application will be returning DataSets to clients, and receiving DiffGrams from clients to update the remote database.
Note that ASP.NET Web Services require a web server (like IIS).
The result is a server that connects to several thin-client applications (i.e. clients that do not know or care which database they are connected to).
ASP.NET Web Services
We'll use an ASP.NET Web Service to export DataSets to client applications, who can work with these DataSets and send updates back to the Web Service (in order to update the underlying database).
First, create an ASP.NET Web Service as normal, and call it D8DataBaseServer.
A new virtual directory called D8DataBaseServer is created, with the new ASP.NET Web Service project inside.
By default, the new project contains a file WebService1.pas and .asmx, that you may want to rename as DataBaseWebService.
The file DataBaseWebService.pas contains the definition for TWebService1, which you may want to rename to a more descriptive name as well, like TEmployeeData.
Apart from the name of the web service, you should also add a unique namespace to it, using the WebService attribute, as follows:
[WebService(Namespace='http://eBob42.org', Description='InterBase Employee DB')]The web methods that we are about to write need to connect to the InterBase database to be able to produce their result. This means that we need a BdpConnection component to share this connection. You can either add it to the TEmployeeData web service definition, or - more conveniently - move to the design view of the Web Service and place the BdpConnection component on the Designer page (or drop it from the Data Explorer).
TEmployeeData Interface
Remove the sample HelloWorld method from the TEmployeeData definition, and add a method called GetDataSet instead, taking the tablename as argument, as well as the starting record and maximum number of records to return.
[WebService(Namespace='http://eBob42.org', Description='InterBase Employee DB')] TEmployeeData = class(System.Web.Services.WebService) ... public constructor Create; [WebMethod] function GetDataSet(const TableName: String; start, max: Integer): DataSet; end;GetDataSet will perform a SELECT command on the InterBase database, using TableName to specify which table to select records from. The start and max arguments are available as a result of the Fill method, which we'll see in a moment when implementing the GetDataSet method.
TEmployeeData Implementation
The GetDataSet method will create a BdpDataAdapter component, connected to the BdpConnection component, passing the SELECT * FROM substring, with the TableName parameter to complete this query.
Note that this is potentially dangerous, since apart from the TableName, people could pass an SQL separator character as well as another SQL statement (like DROP TABLE, GRAND access or something else unwanted).
However, we will use it for demonstrative purposes to see that it works, and then shield the interface from the outside world again.
The Fill method of the BdpDataAdapter component contains the ability to specify a starting position and maximum number of records that are returned, and that's where we can use the start and max arguments, as follows:
function TEmployeeData.GetDataSet(const TableName: &String; start, max: Integer): DataSet; var DataAdapter: BdpDataAdapter; begin Result := DataSet.Create; DataAdapter := BdpDataAdapter.Create('select * from ' + TableName, BdpConnection1); try DataAdapter.Fill(Result, start, max, TableName) finally DataAdapter.Free end end;Note that the BdpConnection will be opened when needed (i.e. when we call the Fill method of the BdpDataAdapter).
[WebMethod(Description='Return the COUNTRY table from Employee.gdb')] function GetCOUNTRY(start, max: Integer): DataSet; [WebMethod(Description='Return the CUSTOMER table from Employee.gdb')] function GetCUSTOMER(start, max: Integer): DataSet; [WebMethod(Description='Return the DEPARTMENT table from Employee.gdb')] function GetDEPARTMENT(start, max: Integer): DataSet; [WebMethod(Description='Return the EMPLOYEE table from Employee.gdb')] function GetEMPLOYEE(start, max: Integer): DataSet; [WebMethod(Description='Return the EMPLOYEE_PROJECT table from Employee.gdb')] function GetEMPLOYEE_PROJECT(start, max: Integer): DataSet; [WebMethod(Description='Return the ITEMS table from Employee.gdb')] function GetITEMS(start, max: Integer): DataSet; [WebMethod(Description='Return the JOB table from Employee.gdb')] function GetJOB(start, max: Integer): DataSet; [WebMethod(Description='Return the PROJECT table from Employee.gdb')] function GetPROJECT(start, max: Integer): DataSet; [WebMethod(Description='Return the PROJ_DEPT_BUDGET table from Employee.gdb')] function GetPROJ_DEPT_BUDGET(start, max: Integer): DataSet; [WebMethod(Description='Return the SALES table from Employee.gdb')] function GetSALES(start, max: Integer): DataSet; [WebMethod(Description='Return the SALES_HISTORY table from Employee.gdb')] function GetSALES_HISTORY(start, max: Integer): DataSet;The implementation of the first 11 methods is easy - only the implementation of the GetEMPLOYEE is shown in the following code, since the other methods are all calling the (now internal) method GetDataSet, each passing a different tablename:
function TEmployeeData.GetEMPLOYEE(start, max: Integer): DataSet; begin Result := GetDataSet('EMPLOYEE', start, max) end;Now it's time to save your work, compile the application, and test it from the Delphi for .NET IDE. This should produce your default web browser with the URL of the web service, showing the 11 methods that we have now to remotely access the database (the GetDataSet method should no longer be visible).
DataBase Web Service (thin) Clients
For the Web Service Client, you need to start a new application with File | New Windows Forms Application.
Save it in DataBaseWSClient, and right-click on the project node in the Project Manager to choose the "Add Web Reference" menu option to import the TEmployeeData web service.
This results in a dialog where we can specify the location of the WSDL for the TEmployeeData web service.
If you've deployed it on your local machine, then it's http://localhost/D8DataBaseServer/DataBaseWebService.asmx?WSDL.
When you click on the Add Reference button, the WSDL is saved, parsed and the import unit is generated and added to our Delphi for .NET project.
Once the web reference is added you can use it.
Add localhost.DataBaseWebService to the uses clause of the WinForms application.
There are different ways to create and keep an instance of the web service engines: either create a new instance in each method that uses it, or create a private field in the WinForm and create the instance in the OnLoad event (or on demand).
Since we probably call a number of methods, it seems logical to add the private field called EmployeeData of type TEmployeeData to the TWinForm1 class, and create the instance in the OnLoad event handler as follows:
procedure TWinForm.TWinForm_Load(sender: System.Object; e: System.EventArgs); begin EmployeeData := TEmployeeData.Create end;We can now use the EmployeeData and call methods like GetEMPLOYEE when we want. In fact, that's exactly what we'll do, showing the resulting DataTables and Relation.
Retrieving Remote DataSets
As visual control to display the data, we can use a DataGrid control.
Resize it so it covers the top part of the form, and the entire width of the form, and set the Anchor property to Top, Left, Right to make sure it resizes with the form.
The DataGrid control is usually connected to a DataSet and/or DataTable component at design-time, but since we don't have one - yet - we need to configure it at run-time.
We can do this using a Button, or also in the OnLoad event handler of the Form.
Since we've already created the EmployeeData web service instance in the OnLoad event handler, we can call the GetEMPLOYEE method at that place as well (specifying that we want to start with the first record - at index 0 - and get 8 records in total), connecting the resulting DataSet to the DataGrid, specifying EMPLOYEE as DataMember as follows:
procedure TWinForm.TWinForm_Load(sender: System.Object; e: System.EventArgs);
begin
EmployeeData := TEmployeeData.Create;
try
RecNo := 0; // private field to hold current record number
DataGrid1.DataSource := EmployeeData.GetEMPLOYEE(0,8);
DataGrid1.DataMember := 'EMPLOYEE'
except
on Ex: Exception do
MessageBox.Show(Ex.StackTrace, Ex.Message)
end
end;
Note that a private field RecNo needs to be added to the class definition of the WinForm.
This field is used to maintain the current record (or rather the record number of the first EMPLOYEE record in the DataGrid).
In order to navigate through the DataGrid, we need two more buttons, called btnNext, and btnPrev.
Each of them will call the GetEMPLOYEE method again, passing the modified value of RecNo and the number of records that we want to retrieve at that time.
The Click event of btnNext and btnPrevious are implemented follows:
const PageSize = 8; procedure TWinForm.btnNext_Click(sender: System.Object; e: System.EventArgs); begin try Inc(RecNo, PageSize); DataGrid1.DataSource := EmployeeData.GetEMPLOYEE(RecNo,PageSize); DataGrid1.DataMember := 'EMPLOYEE' except on Ex: Exception do MessageBox.Show(Ex.StackTrace, Ex.Message) end end; procedure TWinForm.btnPrevious_Click(sender: System.Object; e: System.EventArgs); begin try Dec(RecNo, PageSize); if RecNo < 0 then RecNo := 0; DataGrid1.DataSource := EmployeeData.GetEMPLOYEE(RecNo,PageSize); DataGrid1.DataMember := 'EMPLOYEE' except on Ex: Exception do MessageBox.Show(Ex.StackTrace, Ex.Message) end end;Note that the MaxRecords constant be made an option to specify on the WinForm itself, so the user can specify how many records to move forward or backward in the EMPLOYEE table. That's left as exercise for the reader, however.
Clicking on the Next 8 and Previous 8 buttons will give you 8 other records each. Note that the DataSource property of the DataGrid is assigned every time, ignoring the previous value of the DataSource, so you will only see a maximum of 8 records at a give time; never more than 8 (you could see less if the last page contains less than 8 records). There's one thing missing: you can make changes to the data in the DataGrid, but there is no way the data can be sent back to the Web Service to be used to update the database. This may not be a problem if the Web Service should only be used to make read-only data available, but it's only a small step to make it capable of handling updates as well. In order to handle DataSet updates, the Web Service would need to be extended with Set- methods, one for each Get- method that currently return the DataSets. The Set methods take a DataSet as argument, including the changes from the client side. However, while this approach would work, it could also lead to a significant bandwidth usage if a DataSet contains only a single change. It would be more efficient to only send the so-called diffgram, returned by calling the GetChanges method of the DataSet.
Applying Updates
Assuming the server will offer a web method called SetEMPLOYEE - to be implemented in a moment - we, can drop a new button, call it btnUpdate, and implement the Click event handler as follows:
procedure TWinForm.btnUpdate_Click(sender: System.Object; e: System.EventArgs);
var
Changes: DataSet;
begin
try
Changes := (DataGrid1.DataSource as DataSet).GetChanges;
// optionally: measure Changes vs. complete DataSet
if EmployeeData.SetEMPLOYEE(Changes) then
begin
(DataGrid1.DataSource as DataSet).Merge(Changes);
(DataGrid1.DataSource as DataSet).AcceptChanges
end
except
on Ex: Exception do
MessageBox.Show(Ex.StackTrace, Ex.Message)
end
end;
Passing only the Changes instead of the full DataSet will make a significant difference, especially if you display more than a few records at the same time (so the DataSet grows) or send only a few changes at the same time (so the Changes are small).
In order to show the difference, we can insert at the place of the // optionally: comments, the following new code to save the DataSet and the Changes as XML files:
(DataGrid1.DataSource as DataSet).WriteXml('DataSetSchema.xml', XmlWriteMode.WriteSchema); (DataGrid1.DataSource as DataSet).WriteXml('DataSet.xml', XmlWriteMode.DiffGram); Changes.WriteXml('ChangesSchema.xml', XmlWriteMode.WriteSchema); Changes.WriteXml('Changes.xml', XmlWriteMode.DiffGram);Note that this will save the DataSet including schema in DataSetSchema.xml, the DataSet plus DiffGram in DataSetxml, and the Changes including schema in ChangesSchema.xml and the Changes DiffGram in Changes.xml. The difference is usually a factor 4, but depends on a number of factors. On thing is sure: the Changes will never be bigger than the complete DataSet, so it's always more efficient to sent the Changes instead of the DataSet.
[WebMethod(Description='Update the COUNTRY table.')] function SetCOUNTRY(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the CUSTOMER table.')] function SetCUSTOMER(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the DEPARTMENT table.')] function SetDEPARTMENT(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the EMPLOYEE table.')] function SetEMPLOYEE(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the EMPLOYEE_PROJECT table.')] function SetEMPLOYEE_PROJECT(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the ITEMS table.')] function SetITEMS(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the JOB table.')] function SetJOB(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the PROJECT table.')] function SetPROJECT(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the PROJ_DEPT_BUDGET table.')] function SetPROJ_DEPT_BUDGET(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the SALES table.')] function SetSALES(ClientDS: DataSet): Boolean; [WebMethod(Description='Update the SALES_HISTORY table.')] function SetSALES_HISTORY(ClientDS: DataSet): Boolean;Like the Set- methods, there is one SetDataSet method - not published as web method - that will implement the changes. This one will be responsible for accepting the DiffGrams and updating the corresponding table in the database. In order to update the table, the SetDataSet needs to know the original TableName, so it can create a BdpDataAdapter component and use the AutoUdate capabilities to create the UPDATE, INSERT and DELETE commands automatically.
//[WebMethod]
procedure SetDataSet(TableName: String; ClientDS: DataSet);
Implementation
For the implementation of SetDataSet, we first need to add the Borland.Data.Common unit to the uses clause, to enable us to create and use instances of BdpUpdateMode types.
The implementation of SetDataSet creates an instance of the BdpDataAdapter the same way we did before, using the information in the SELECT command only to construct the UPDATE, INSERT, and DELETE commands.
The implementation of SetDataSet is as follows:
function TEmployeeData.SetDataSet(TableName: &String; ClientDS: DataSet): Boolean;
var
DataAdapter: BdpDataAdapter;
begin
Result := False;
DataAdapter := BdpDataAdapter.Create('SELECT * FROM ' + TableName, BdpConnection1);
try
DataAdapter.AutoUpdate(ClientDS, TableName, BdpUpdateMode.All)
finally
DataAdapter.Free;
Result := True // success!
end
end;
The AutoUpdate method of the BdpDataAdapter will use a BdpCommandBuilder to automatically generate the correct UPDATE, INSERT and DELETE SQL statements that correspond to the SELECT statement - hence the reason why we had to create the BdpDataAdapter with the "select * from TableName" as argument to the constructor.
With this generic SetDataSet method available, we can now implement the InterBase Employee specific methods.
The following code only shows the implementation of the SetEMPLOYEE method, since the other methods are all calling the internal method SetDataSet, each passing a different TableName and the DataSet:
function TEmployeeData.SetEMPLOYEE(ClientDS: DataSet): Boolean; begin Result := SetDataSet('EMPLOYEE', ClientDS) end;This completes the changes that are required for the D8DataBaseServer web service, so save the project and recompile it. You can test it from the Delphi for .NET IDE again, and this time you should see 22 methods in total: 11 GetXXX table methods and 11 SetXXX table methods (we did not implement the SetEMPLOYEE_and_PROJECT or SetEMPLOYEE_SALARY_HISTORY methods).
We have seen how to use Delphi for .NET to build an ASP.NET Web Service application that publishes the data tables from the database to the outside world, and can also receive updates in the datasets in order to modify the datatables as well.
We have also seen how to build a Windows Forms client application that uses this ASP.NET Web Service to access the database tables remotely, display the data in a DataGrid, navigate through the grid by requesting next and previous pages of records, and edit the data inside the DataGrid.
Finally, we've implemented a way to update the Data and send the changes back to the ASP.NET Web Service where they are resolved in the original database table.
The combination of .NET DataSets and ASP.NET Web Services built with Delphi for .NET allow us to build multi-tier and distributed applications resulting in thin-clients that do not connect to the actual database tables, but to a middle-ware ASP.NET web service.
Note that we did not cover security of the Web Service - you should realise that anyone who knows where to find this Web Service can call the methods, and read (or even write) the datasets.
You may want to extend each method with a number of parameters (like username, password, pincode), or use a secure connection to pass the data from the server to the clients.
For more information, see Dr.Bob Examines #62 on TADONETConnector: connect ADO.NET DataSets to VCL for .NET Clients, #54 on .NET Remoting and DataSets, #60 on Delphi 2005 and Passing Parameters through .NET Remoting, or my BorCon 2004 paper on Multi-tier/Distributed Database Applications in .NET.