RWE: Sample Code Review
I have got a chance to do code review for one of our client's recently and I thought I would share results with you. You should learn a bit more about our API and how it influences overall website performance. I would also like to use this sample code review to demonstrate what we can do as part of Kentico Professional Services.
What was an issue?
The customer reached us because their custom code (below) was performing very poorly. The client actually reported that single document insert action takes anywhere from 6 to 10 seconds. You would probably agree there must be something wrong with the code as 10 seconds for simple insert is just too much. And you are right - it is very suspicious whenever single action takes so long.
The custom code I attached below imports a new content into Kentico CMS using our API. Source data are retrieved through the channel to external webservice. Perhaps the performance loss can be caused by the delay in the response coming from the webservice. That's true; you cannot really rely on external service. There is no guarantee service would be available all the time and there will always be the factor of hand-shake and request-response cost. Anyway, for this particular case let's assume the delay is no longer than 1 to 3 seconds. We are therefore still looking at additional 7-9 seconds of the execution time lost somewhere along the lines of our custom code.
Let's take closer look on the code now.
How does code look like?
In my analysis I am going to refer to the custom code below. Please review it before you move forward. Please note that for obvious reasons I replaced name of variables and certain lines of code that could suggest who the customer is.
Code Snippet
-
namespace CUSTOM.Business
-
{
-
/// <summary>
-
///
-
/// </summary>
-
public static class LocationManager
-
{
-
private static LocationManagerDataSet _lmds;
-
private static BranchManagerDataSet _bmds;
-
private static AgeRangeDataSet _agds;
-
private static WebTimeCrossReferenceManagerDataSet _wtcds;
-
private static WebTimeSFYPManagerDataSet _wtsds;
-
private static WebTimeItemCrossReferenceManagerDataSet _wtixds;
-
private static JobScheduleDataSet _jsds;
-
-
private static DataSet ClassesDataSet;
-
private static string durationstr;
-
private static string currentSite;
-
private static string currentCulture;
-
-
public static string CurrentSite
-
{
-
get
-
{
-
if (string.IsNullOrEmpty(currentSite))
-
{
-
if (CMS.CMSHelper.CMSContext.CurrentSiteName == string.Empty)
-
currentSite = ConfigurationManager.AppSettings["DefaultSite"].ToString();
-
else
-
currentSite = CMS.CMSHelper.CMSContext.CurrentSiteName;
-
}
-
return currentSite;
-
}
-
}
-
-
public static string CurrentCulture
-
{
-
get
-
{
-
if (string.IsNullOrEmpty(currentCulture))
-
{
-
if (CMS.CMSHelper.CMSContext.CurrentSiteName == string.Empty)
-
currentCulture = ConfigurationManager.AppSettings["DefaultCulture"].ToString();
-
else
-
currentCulture = CMS.CMSHelper.CMSContext.CurrentDocumentCulture.CultureCode;
-
}
-
return currentCulture;
-
}
-
}
-
-
public static LocationManagerDataSet GetAllLocations()
-
{
-
_lmds = SqlDataClient.GetAllLocations();
-
return _lmds;
-
}
-
-
public static JobScheduleDataSet GetAllJobBranches()
-
{
-
_jsds = SqlDataClient.GetAllJobBranches();
-
return _jsds;
-
}
-
-
public static void UpdateJobBranch(int locationID)
-
{
-
SqlDataClient.UpdateJobBranch(locationID);
-
}
-
-
-
public static AgeRangeDataSet GetAllAgeRange()
-
{
-
_agds = SqlDataClient.GetAllAgeRange();
-
return _agds;
-
}
-
-
public static LocationManagerDataSet GetLocation(int LocationID)
-
{
-
_lmds = SqlDataClient.GetLocation(LocationID);
-
return _lmds;
-
}
-
-
public static BranchManagerDataSet GetBranchByBranchName(string branchName)
-
{
-
_bmds = SqlDataClient.GetBranchByBranchName(branchName);
-
return _bmds;
-
}
-
-
public static LocationManagerDataSet GetLocationDataSet(int LocationID)
-
{
-
_lmds = SqlDataClient.GetLocation(LocationID);
-
return _lmds;
-
}
-
-
public static BranchManagerDataSet GetValidWebTimeBranches()
-
{
-
_bmds = SqlDataClient.GetValidBranches();
-
return _bmds;
-
}
-
-
public static void GetWebItemItemCrossReferenceByLocationID(int locationID)
-
{
-
_wtixds = SqlDataClient.GetWebItemItemCrossReferenceByLocationID(locationID);
-
}
-
-
public static void GetWebTimeCrossReferenceByLocationID(int locationID)
-
{
-
_wtcds = SqlDataClient.GetWebTimeCrossReferenceByLocationID(locationID);
-
}
-
-
public static void GetWebTimeSFYPDataByLocationID(int locationID)
-
{
-
_wtsds = SqlDataClient.GetWebTimeSFYPDataByLocationID(locationID);
-
}
-
-
public static string RefreshDataFromWebService(int locationID)
-
{
-
DateTime startTime = DateTime.Now;
-
GetLocation(locationID);
-
-
Guid batchNumber = Guid.NewGuid();
-
-
foreach (LocationManagerDataSet.LocationDataTableRow drow in _lmds.LocationDataTable.Rows)
-
{
-
// Create the web request
-
HttpWebRequest request = WebRequest.Create(string.Format("http://www.somedomain.com", drow.WebserviceLocationID)) as HttpWebRequest;
-
-
if (request != null)
-
{
-
// Get response
-
using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
-
{
-
if (response != null)
-
{
-
string contents = string.Empty;
-
// Load data into a dataset
-
DataSet dsw = new DataSet();
-
using (StreamReader reader = new StreamReader(response.GetResponseStream()))
-
{
-
contents = reader.ReadToEnd().Replace("& UP", "+");
-
}
-
-
System.IO.StringReader strcontents = new System.IO.StringReader(contents);
-
WebTimeDataManagerDataSet dsWeather = new WebTimeDataManagerDataSet();
-
dsw.ReadXml(strcontents);
-
-
if (dsw.Tables["Transaction"].Rows[0]["TRAN_STATUS"].ToString() != "FAIL")
-
{
-
// save the data
-
SqlDataClient.SaveWebTimeData(dsw, drow.WebtimeLocationID, batchNumber);
-
}
-
}
-
}
-
}
-
}
-
-
DateTime stopTime = DateTime.Now;
-
TimeSpan duration = stopTime - startTime;
-
durationstr = duration.ToString();
-
return ("RefreshTimes: " + durationstr);
-
}
-
-
public static string PopulateNodes(int locationID)
-
{
-
durationstr = string.Empty;
-
-
GetLocation(locationID);
-
GetValidWebTimeBranches();
-
-
foreach (LocationManagerDataSet.LocationDataTableRow drow in _lmds.LocationDataTable.Rows)
-
{
-
DeleteSchedules(drow.WebtimeLocationID, _bmds);
-
AddNewSchedules(drow.WebtimeLocationID, _bmds);
-
}
-
-
return (durationstr);
-
}
-
-
private static void DeleteSchedules(int locationID, BranchManagerDataSet bm)
-
{
-
DateTime startTime = DateTime.Now;
-
try
-
{
-
// look for the Programs Nodes
-
DataRow[] drow = bm.something_Branch.Select("WebtimeLocationID=" + locationID.ToString());
-
string programpath = string.Empty;
-
string unprocessedpath = string.Empty;
-
-
if (drow.Count() > 0)
-
{
-
programpath = ((BranchManagerDataSet.something_BranchRow)drow[0]).NodeAliasPath.ToString() + "/Programs/%";
-
}
-
-
// if there is a real program path, go to the path using the Kentico API
-
if (programpath != string.Empty)
-
{
-
// create a TreeProvider instance
-
UserInfo ui = UserInfoProvider.GetUserInfo("administrator");
-
CMS.TreeEngine.TreeProvider tree = new CMS.TreeEngine.TreeProvider(ui);
-
-
DataSet treeDS = tree.SelectNodes(CurrentSite, programpath, CurrentCulture, true, "something.ClassTime");
-
if (treeDS != null && treeDS.Tables.Count > 0)
-
{
-
CMS.CMSHelper.CMSContext.Init();
-
DataTable dtable = treeDS.Tables[0];
-
foreach (DataRow dr in dtable.Rows)
-
{
-
// Get document with specified site, aliaspath and culture
-
-
CMS.TreeEngine.TreeNode node = tree.SelectSingleNode(CurrentSite, dr["NodeAliaspath"].ToString(), CurrentCulture, true, "something.ClassTime");
-
if (node != null)
-
{
-
// Delete the document
-
DocumentHelper.DeleteDocument(node, tree, true, true, true);
-
node.Delete();
-
}
-
}
-
}
-
-
// delete also from the Unprocessed Bucket
-
unprocessedpath = "/Unprocessed" + ((BranchManagerDataSet.something_BranchRow)drow[0]).NodeAliasPath + "/%";
-
DataSet unprocessedDS = new DataSet();
-
-
unprocessedDS = tree.SelectNodes(CurrentSite, unprocessedpath, CurrentCulture, true, "something.ClassTime");
-
if (unprocessedDS != null && unprocessedDS.Tables.Count > 0)
-
{
-
// this is needed for the windows service routine because it is not in context of the website
-
CMS.CMSHelper.CMSContext.Init();
-
DataTable unprocesseddtable = unprocessedDS.Tables[0];
-
foreach (DataRow dr in unprocesseddtable.Rows)
-
{
-
// Get document with specified site, aliaspath and culture
-
-
CMS.TreeEngine.TreeNode node = tree.SelectSingleNode(CurrentSite, dr["NodeAliaspath"].ToString(), CurrentCulture, true, "something.ClassTime");
-
if (node != null)
-
{
-
// Delete the document
-
DocumentHelper.DeleteDocument(node, tree, true, true, true);
-
node.Delete();
-
}
-
}
-
}
-
}
-
-
DateTime stopTime = DateTime.Now;
-
TimeSpan duration = stopTime - startTime;
-
durationstr = "Delete Times: " + duration.ToString();
-
}
-
catch (Exception ex)
-
{
-
throw new Exception(ex.Message);
-
}
-
}
-
-
public static CMSTreeNodeOfferingCategoryManagerDataSet GetOfferingCategoryNodesByBranchPath(string branchnamepath)
-
{
-
CMSTreeNodeOfferingCategoryManagerDataSet currentProgramsDS = new CMSTreeNodeOfferingCategoryManagerDataSet();
-
-
UserInfo ui = UserInfoProvider.GetUserInfo("administrator");
-
CMS.TreeEngine.TreeProvider tree = new CMS.TreeEngine.TreeProvider(ui);
-
-
currentProgramsDS.CMSTreeNodeOfferingCategory.TableName = "something.OfferingCategoryPage";
-
-
string programpath = branchnamepath + "/Programs/%";
-
-
// get all the program offerings for this program path
-
currentProgramsDS.Merge(tree.SelectNodes(CurrentSite, programpath, CurrentCulture, true, "something.OfferingCategoryPage"));
-
-
return currentProgramsDS;
-
}
-
-
public static CMSTreeNodeOfferingCategoryManagerDataSet GetOfferingCategoryNodes(string branchpath)
-
{
-
CMSTreeNodeOfferingCategoryManagerDataSet currentProgramsDS = new CMSTreeNodeOfferingCategoryManagerDataSet();
-
-
UserInfo ui = UserInfoProvider.GetUserInfo("administrator");
-
CMS.TreeEngine.TreeProvider tree = new CMS.TreeEngine.TreeProvider(ui);
-
-
currentProgramsDS.CMSTreeNodeOfferingCategory.TableName = "something.OfferingCategoryPage";
-
-
string programpath = branchpath + "/Programs/%";
-
-
// get all the program offerings for this program path
-
currentProgramsDS.Merge(tree.SelectNodes(CurrentSite, programpath, CurrentCulture, true, "something.OfferingCategoryPage"));
-
-
return currentProgramsDS;
-
}
-
-
private static void AddNewSchedules(int locationID, BranchManagerDataSet bm)
-
{
-
// look for the Programs Nodes
-
DataRow[] drow = bm.something_Branch.Select("WebtimeLocationID=" + locationID.ToString());
-
string programpath = string.Empty;
-
-
DateTime startTime = DateTime.Now;
-
-
try
-
{
-
if (drow.Count() > 0)
-
{
-
programpath = ((BranchManagerDataSet.something_BranchRow)drow[0]).NodeAliasPath + "/Programs/%";
-
-
// Get the Import Data
-
GetWebTimeSFYPDataByLocationID(locationID);
-
GetWebItemItemCrossReferenceByLocationID(locationID);
-
-
UserInfo ui = UserInfoProvider.GetUserInfo("administrator");
-
CMS.TreeEngine.TreeProvider tree = new CMS.TreeEngine.TreeProvider(ui);
-
-
CMSTreeNodeClassTypeDataSet ClassesDS = new CMSTreeNodeClassTypeDataSet();
-
ClassesDS.CMSTreeNodeClassType.TableName = "something.Class";
-
-
// get all the classes for this program path
-
-
DataSet ds = tree.SelectNodes(CurrentSite, programpath, CurrentCulture, true, "something.Class");
-
ClassesDS.Merge(ds);
-
-
//Read the Import Data First
-
foreach (WebTimeSFYPManagerDataSet.SOMETHING_WebTimeSFYPDataTableRow dsrow in _wtsds.SOMETHING_WebTimeSFYPDataTable.Rows)
-
{
-
// locate for the pgm code in the crossreference file
-
string pgmcodetosearch = string.Empty;
-
-
// check if the last 2 are numbers
-
if (dsrow.THRUSTCODE != string.Empty)
-
{
-
// create exception
-
// look for the Programs Nodes
-
string selectstring = "WebTime_ThrustCode='" + dsrow.THRUSTCODE.ToString() + "'";
-
DataRow[] xrows = ClassesDS.Tables[0].Select(selectstring);
-
-
if (xrows.Count() > 0)
-
{
-
// get parent node for new document
-
CreateNode(((CMSTreeNodeClassTypeDataSet.CMSTreeNodeClassTypeRow)xrows[0]).NodeAliasPath, tree, dsrow, ((BranchManagerDataSet.something_BranchRow)drow[0]).WebserviceLocationID.ToString());
-
}
-
else
-
{
-
// create exception
-
// look for the Programs Nodes
-
string parentpath = string.Empty;
-
-
if (drow.Count() > 0)
-
{
-
parentpath = "/Unprocessed" + ((BranchManagerDataSet.something_BranchRow)drow[0]).NodeAliasPath;
-
}
-
-
CreateNode(parentpath, tree, dsrow, ((BranchManagerDataSet.something_BranchRow)drow[0]).WebserviceLocationID.ToString());
-
}
-
}
-
else
-
{
-
// create exception
-
// look for the Programs Nodes
-
string parentpath = string.Empty;
-
-
if (drow.Count() > 0)
-
{
-
parentpath = "/Unprocessed" + ((BranchManagerDataSet.something_BranchRow)drow[0]).NodeAliasPath;
-
}
-
-
CreateNode(parentpath, tree, dsrow, ((BranchManagerDataSet.something_BranchRow)drow[0]).WebserviceLocationID.ToString());
-
}
-
}
-
}
-
-
DateTime stopTime = DateTime.Now;
-
TimeSpan duration = stopTime - startTime;
-
-
durationstr = durationstr + "<br/>" + "Add Times: " + duration.ToString() + "<br>";
-
}
-
catch (Exception ex)
-
{
-
throw new Exception(ex.Message);
-
}
-
}
-
-
private static void CreateNode(string parentpath, CMS.TreeEngine.TreeProvider tree, WebTimeSFYPManagerDataSet.SOMETHING_WebTimeSFYPDataTableRow dsrow, string webservicelocationID)
-
{
-
// get parent node for new document
-
-
// this is needed for the windows service because it is not in the context of the website
-
CMS.CMSHelper.CMSContext.Init();
-
CMS.TreeEngine.TreeNode parentClassNode = tree.SelectSingleNode(CurrentSite, parentpath, CurrentCulture);
-
-
// create a new tree node
-
CMS.TreeEngine.TreeNode node = new CMS.TreeEngine.TreeNode("something.ClassTime", tree);
-
-
// check off the properties
-
if (parentClassNode != null)
-
{
-
// set document properties
-
string nodename = GenerateNodeName(dsrow);
-
if (nodename == string.Empty)
-
nodename = dsrow.KEY;
-
-
node.NodeName = nodename;
-
node.NodeAlias = nodename;
-
-
node.SetValue("DocumentCulture", CurrentCulture);
-
node.SetValue("DayOfWeek", nodename);
-
node.SetValue("DocumentName", nodename);
-
-
string startdate = string.Empty;
-
string enddate = string.Empty;
-
-
if (dsrow.BEGIN_DATE != string.Empty)
-
{
-
startdate = DateTime.Now.Year.ToString().Substring(0, 2) + dsrow.BEGIN_DATE;
-
node.SetValue("StartDate", Convert.ToDateTime(startdate.Substring(0, 4) + "/" + startdate.Substring(4, 2) + "/" + startdate.Substring(6, 2)));
-
}
-
-
if (dsrow.END_DATE != string.Empty)
-
{
-
enddate = DateTime.Now.Year.ToString().Substring(0, 2) + dsrow.END_DATE;
-
node.SetValue("EndDate", Convert.ToDateTime(enddate.Substring(0, 4) + "/" + enddate.Substring(4, 2) + "/" + enddate.Substring(6, 2)));
-
}
-
-
node.SetValue("StartTime", dsrow.START_TIME);
-
node.SetValue("EndTime", dsrow.END_TIME);
-
node.Insert(parentClassNode.NodeID);
-
-
WorkflowManager wm = new WorkflowManager(tree);
-
-
//check if the node supports workflow
-
WorkflowInfo wi = wm.GetNodeWorkflow(node);
-
if (wi != null)
-
{
-
// Approve until the step is publish
-
WorkflowStepInfo currentStep = wm.GetStepInfo(node);
-
if (wm != null)
-
{
-
while ((currentStep != null) && (currentStep.StepName.ToLower() != "published"))
-
{
-
currentStep = wm.MoveToNextStep(node, "");
-
}
-
}
-
}
-
}
-
}
-
}
-
}
How to improve existing code?
Problem 1: Performing Unnecessary Actions
Let's focus on the PopulateNodes(int locationID) method first. As you can see for every location (basically row record) in the _lmds.LocationDataTable.Rows collection the custom code calls DeleteSchedules(int locationID, BranchManagerDataSet bm) method (line no. 187). Inside this method our code retrieves the collection of 'Programs' nodes existing in the content tree by using the TreeProvide.SelectNodes() method (line no. 216).
As the next step code loops through the retrieved program nodes and for every node it:
a) Gets the
NodeAliasPath available in the node data row (line no.
225),
b) Query the database to retrieve the
TreeNode object using this
NodeAliasPath for the
TreeProvider.SelectSingleNode() method (line no.
225),
FIX 1: There is actually no reason to call the
SelectSingleNode() method to get
TreeNode object. All the information about the node is already present in the
DataRow dr (line no.
221). Thereby, instead of calling the
SelectSingleNode() we can create new instance of the
TreeNode object using the
new TreeNode(dr, "<document type name>", treeProvider) constructor where we simply pass the
DataRow as parameter.
The small change suggested above saves 1 query per every program node per every location. If there would be 100 locations with let's say only 10 program nodes per location we would have just saved 1000 quite expensive queries.
Problem 2: Duplicate Actions
There is another issue with the
DeleteSchedules(int locationID, BranchManagerDataSet bm) method. You can see code calls the
DocumentHelper.DeleteDocument() method first and then
the node.Delete() method right after that (lines no.
229 and
230). The
DocumentHelper.DeleteDocument() should be used for documents under the workflow. Hence it is absolutely correct to call
DeleteDocument() as long as we are dealing with versioned documents. The
node.Delete() method on the other hand should be used only for documents without workflow because unlike
DocumentHelper.DeleteDocument() the
node.Delete() does not care about multiple versions of the document and therefore is not suitable in our scenario.
FIX 2: There is no reason to call both the
DocumentHelper.DeleteDocument() and
node.Delete() method at the same place. Actually, it is highly inefficient because
the node.Delete() executes additional query for every program node for every location after the document is already wiped off by
DeleteDocument() called earlier.
We can again save hundreds or even thousands of queries just by removing the
node.Delete() call.
NOTE: The same fix needs to be applied to the code few lines later when working through bucket of unprocessed documents (lines no.
235 - 258).
Problem 3: No Data Caching & Inefficient Insert Operation
Inside the
AddNewSchedules(int locationID, BranchManagerDataSet bm) method custom code calls the
GetWebTimeSFYPDataByLocationID(int locationID) and
GetWebItemItemCrossReferenceByLocationID(int locationID) methods (lines no.
319 and
320) that retrieve data from database executing 2 queries in total for every location. Further in the same method code also retrieves all the location's program nodes (line no.
330). The code then loops through the import data (source data for new document sent from external service) and calls the
CreateNode(string parentpath, CMS.TreeEngine.TreeProvider tree, WebTimeSFYPManagerDataSet.SOMETHING_WebTimeSFYPDataTableRow dsrow, string webservicelocationID) method to insert new document in the content tree (lines no.
350,
363 and
377).
In the
CreateNode() method custom code uses
TreeProvider.SelectSingleNode() (line no.
399) to get the parent node required whenever we are inserting a new document. The
CreateNode() method accepts parent
NodeAliasPath as first parameter. This parent
NodeAliasPath comes from the
DataRow that contains full information on the parent node - like
NodeID for example.
At the bottom of the
CreateNode() method code uses the
node.Insert(parentNodeID) (line no.
436) to finally insert a new document in the content tree.
FIX 3: As you can see most of the custom methods (lines no.
68 - 129) pulling out additional information related to the document created (e.g. location details) execute single query for every location being processed. The results of those queries are not cached at all even though caching is perfect fit in scenarios like this. We should therefore implement caching mechanism for the location data obtained from database to streamline data retrieval and processing. The new API used to simplify caching implementations is explained
here.
Ideally we should go even step further and instead of querying DB for every location separately we could retrieve details for all locations within the single request, cache the whole result set and later filter out records from the
DataSet stored in a memory rather than accessing DB over and over again.
FIX 4: : Instead of selecting the parent node object using the
TreeProvider.SelectSingleNode() (line no.
399) using the parent
NodeAliasPath we want to pass the parent
NodeID as input parameter for the
CreateNode() method. The parent
NodeID is available in the same
DataRow as
NodeAliasPath in the original code. This would save us another call to the database curently fired by
TreeProvider.SelectSingleNode() (line no.
399). If you realize that every call to the
SelectSingleNode() executes additional query to get parent information already available we can eliminate another query for every document created.
FIX 5: To further optimize the code inserting new documents we want to call the
node.Insert(parentNodeObj) instead of
node.Insert(parentNodeID) (line no.
436). If you call the
node.Insert() method with the parent
NodeID as parameter the
Insert() method will still internally call the
SelectSingleNode() to get the parent node object. Let's call the
Insert() with the parent node object directly and we get rid of another expensive query. As I have already mentioned to create new instance of parent
TreeNode object we just need to use the
new TreeNode() constructor and pass the parent
DataRow as one of its parameters.
FIX 6: In the CreateNode() method we should use the DocumentHelper.InsertDocument() instead of the node.Insert(). The DocumentHelper.InsertDocument() recognizes whether the node uses workflow or not and performs not only insert of the document itself but also takes care about any additional actions required to properly handle versioning and other workflow related functionality.
Summary
As you could see when it comes to the performance issues with your custom code is more-or-less about efficiency of your code. And by most part the efficiency goes hand in hand with costs of data access and transfer. That being said your attention should be aimed on how much data you retrieve from data base, what is reusability level of such data and whether or not the same data are not already available at different source.
Before you go...
I would love to hear some feedback from you on whether you find posts like this useful, what I should change or add to better serve their purpose and so on. I can imagine we could do some series from articles on code review/best practices topics, however, I need to know if it is something you may be interested in. Please use comments below to pass the message.
Thank you!
K.J.