Jumping into MVC Development With Kentico - Part 2
NOTE
This blog is the second part of a two-part series. Be sure to check DevNet for the first part here.
Porting Over the Azure Search Integration
In my previous Azure Search Integration, I used the UriRequest library to interact with the Azure Search REST service. While this worked fine, it did not take advantage of a great SDK tool that Microsoft has for the platform. With the new project being in MVC, I could take advantage of some functionality that the development model offers. With this new SDK, my calls would be much cleaner, and I could standardize a lot of the communication.
To accomplish this, I did the following:
1. In my new code, I brought in the Azure Search SDK NuGet package, which contained all of the functions I would need.
2. In my AzureSearchHelper.cs class, I added methods for creating the index, loading the data, searching the index, and deleting the index. Note that this code is using the Azure Search SDK, as well as the QuoteMVCProvider generated class.
I alos created another Helper Class called Quote to help handle the objects within the MVC site.
Click to view AzureSearchHelper code
#region IndexActions
public string CreateIndex()
{
// Create the Azure Search index based on the included schema
try
{
//Create the index definition
var definition = new Index()
{
Name = _indexName,
Fields = new[]
{
...
}
};
definition.Suggesters = new List<Suggester> {
new Suggester()
{
Name = "quoteauthor",
SearchMode = SuggesterSearchMode.AnalyzingInfixMatching,
SourceFields = new List<string> { "QuoteAuthor" }
}
};
List<ScoringProfile> lstScoringProfiles = new List<ScoringProfile>();
TextWeights twauthor = new TextWeights();
twauthor.Weights.Add("QuoteAuthor", 100);
TextWeights twtext = new TextWeights();
twtext.Weights.Add("QuoteText", 100);
lstScoringProfiles.Add(new ScoringProfile()
{
Name = "QuoteAuthor",
TextWeights = twauthor
});
lstScoringProfiles.Add(new ScoringProfile()
{
Name = "QuoteText",
TextWeights = twtext
});
definition.ScoringProfiles = lstScoringProfiles;
_searchClient.Indexes.Create(definition);
return "Index created!";
}
catch (Exception ex)
{
return "There was an issue creating the index: {0}\r\n" + ex.Message.ToString();
}
}
public string LoadIndex()
{
try
{
//Get the quotes
var quotes = QuoteMVCProvider.GetQuoteMVCs();
//Build up a json post of the quote data
List<Quote> lstQuotes = new List<Quote>();
foreach (QuoteMVC quote in quotes)
{
Quote qt = new Quote();
qt.DocumentID = quote.DocumentID.ToString();
qt.NodeAliasPath = quote.NodeAliasPath;
qt.QuoteAuthor = quote.GetValue("QuoteAuthor").ToString();
qt.QuoteText = quote.GetValue("QuoteText").ToString().Replace("'", "''").Replace("\"", "''");
qt.QuoteDate = quote.GetValue("QuoteDate").ToString();
lstQuotes.Add(qt);
}
_indexClient.Documents.Index(IndexBatch.Create(lstQuotes.Select(qt => IndexAction.Create(qt))));
return "Index loaded!";
}
catch (Exception ex)
{
return "There was an issue loading the index: {0}\r\n" + ex.Message.ToString();
}
}
public string DeleteIndex()
{
try
{
//Delete the index
_searchClient.Indexes.Delete(_indexName);
return "Index deleted!";
}
catch (Exception ex)
{
return "There was an issue deleting the index: {0}\r\n" + ex.Message.ToString();
}
}
#endregion
#region Search Actions
public DocumentSearchResponse Search(string searchText, bool blnHighlights, bool blnFacets, bool blnScore, string strFilter, string strScoringProfile)
{
// Execute search based on query string
try
{
//Build the SearchParameter object
SearchParameters sp = new SearchParameters();
sp.SearchMode = SearchMode.All;
//Check if highlights should be returned
if (blnHighlights)
{
sp.HighlightFields = new List<string> { "QuoteAuthor", "QuoteText" };
sp.HighlightPreTag = "<span class='highlight'>";
sp.HighlightPostTag = "</span>";
}
//Check if facets shoudl be returned
if (blnFacets)
{
sp.Facets = new List<string> { "QuoteAuthor" };
}
//Check if the results should be filtered
if (strFilter != "")
{
sp.Filter = "QuoteAuthor eq '" + strFilter + "'";
}
//Check if there is a scoring profile specified
if(strScoringProfile != "")
{
sp.ScoringProfile = strScoringProfile;
}
return _indexClient.Documents.Search(searchText, sp);
}
catch (Exception ex)
{
Console.WriteLine("Error querying index: {0}\r\n", ex.Message.ToString());
}
return null;
}
#endregion
Because I used the Azure Search SDK, these calls were very simple to code and implement.
3. In my AzureSearchController file, I referenced the AzureSearchHelper functions in the appropriate actions.
Click to view AzureSearchController code
public ActionResult Search(string q = "", bool h = false, bool f = false, bool s = false, string fi = "", string sp = "")
{
//Create an object array to return
object[] searchresults = new object[2];
//Get the JsonResult
JsonResult resultsdata = new JsonResult();
resultsdata.JsonRequestBehavior = JsonRequestBehavior.AllowGet;
resultsdata.Data = _AzureSearch.Search(q + "*", h, f, s, fi, sp);
//Get the Facets section. This value is not accessible via the JsonResult object so we need to create a DocumentSearchResponse to retrieve it.
DocumentSearchResponse dsrfacets = (DocumentSearchResponse)resultsdata.Data;
searchresults[0] = resultsdata.Data;
searchresults[1] = dsrfacets.Facets;
//Return the array
return Json(searchresults);
}
public ActionResult CreateIndex()
{
ViewData["message"] = _AzureSearch.CreateIndex();
return Content(ViewData["message"].ToString());
}
public ActionResult LoadIndex()
{
ViewData["message"] = _AzureSearch.LoadIndex();
return Content(ViewData["message"].ToString());
}
public ActionResult DeleteIndex()
{
ViewData["message"] = _AzureSearch.DeleteIndex();
return Content(ViewData["message"].ToString());
}
The last piece to port over was the layout. Because this implementation would be using MVC, a lot of my backend code got moved to the View. Because my ActionResults consisted of JSON data, I added JavaScript functions to parse the information and display the search results.
Click to view View JS code
function Search(filter) {
// We will post to the MVC controller and parse the full results on the client side
// You may wish to do additional pre-processing on the data before sending it back to the client
var q = $("#q").val();
var h = $("#cbhighlights").is(':checked');
var f = $("#cbfacets").is(':checked');
var s = $("#cbscores").is(':checked');
var fi = filter;
var sp = $("#selscoringprofile").val();
$.post('/DancingGoatMvc9/en-us/AzureSearch/search',
{
q: q, h: h, f: f, s: s, fi: fi, sp: sp
},
function (searchresults) {
var data = searchresults[0];
var facets = searchresults[1];
var resultsHTML = "";
var facetsHTML = "";
//Get the total records
$("#resultstotal").html('Total Records: ' + data.length);
//Determine if the facets should be displayed
if (facets != null) {
//Loop through the facets to build the list
for (var j = 0; j < facets.QuoteAuthor.length; j++) {
facetsHTML += "<a style=\"cursor: pointer;\" onclick=\"FacetClick('" + facets.QuoteAuthor[j].Value + "');\">" + facets.QuoteAuthor[j].Value + " (" + facets.QuoteAuthor[j].Count + ")<br />";
}
facetsHTML += "<a style=\"cursor: pointer;\" onclick=\"FacetClick('All');\">All";
$("#resultsleft").html(facetsHTML);
}
else {
$("#resultsleft").html('');
}
//Loop through the results
for (var i = 0; i < data.length; i++) {
//Build up the result link
resultsHTML += "<div><a href=\"/DancingGoatMvc9/en-us" + data[i].Document["NodeAliasPath"] + "\">";
//Determine if highlights should be displayed
if (data[i].Highlights != null) {
if (data[i].Highlights.QuoteAuthor != null) {
resultsHTML += data[i].Highlights.QuoteAuthor + " - ";
}
else {
resultsHTML += data[i].Document.QuoteAuthor + " - ";
}
if (data[i].Highlights.QuoteText != null) {
resultsHTML += data[i].Highlights.QuoteText.toString().replace("''", "'");
}
else {
resultsHTML += data[i].Document.QuoteText.toString().replace("''", "'");
}
}
else {
resultsHTML += data[i].Document.QuoteAuthor + " - ";
resultsHTML += data[i].Document.QuoteText.toString().replace("''", "'");
}
resultsHTML += "</a><br />";
if (s) {
resultsHTML += "Score:" + data[i].Score + "</br >";
}
resultsHTML += "</br>";
}
if (resultsHTML != '') {
$("#results").html(resultsHTML);
}
else {
$("#results").html('No results found.');
}
});
function parseJsonDate(jsonDateString) {
if (jsonDateString != null)
return new Date(parseInt(jsonDateString.replace('/Date(', '')));
else
return "";
}
};
There was a good bit of testing I had to do to get the JSON to parse correctly. I won’t fill this blog with the details, but you can see in the code that I am using a lot of flags (Highlight, Score, etc.) to determine which data to show where.
Testing
After all of that, I was finally ready to test my site. I fired up my MVC application and browsed to the Azure Search page (which I added as a link in the Views/Shared/_Layout.cshtml file). Once on that page, I could test the functionality and confirm my code was working correctly.
Success! Everything worked, and it looked great in the process. I can say that the MVC implementation was MUCH faster than the web form model, which opened the doors to some other features of the development model (like partial loading, etc.). And because of that, I decided to add Suggestions to the mix using this partial postback feature.
Adding Suggestions
In my previous demo, I had Suggestions working using an AJAX Postback. I know, many of you don’t like that method. While this worked in the original demo, it was also not the cleanest solution. Now that my code was in MVC, I leveraged the separated model to implement it in a better way.
1. I added the Suggest method to the AzureSearchHelper class. This now contained my API call to do the suggestion.
Click to view AzureSearchhelper View code
public DocumentSuggestResponse Suggest(string searchText, bool blnFuzzy)
{
// Execute search based on query string
try
{
//Build the SearchParameter object
SearchParameters sp = new SearchParameters();
sp.SearchMode = SearchMode.All;
SuggestParameters sugp = new SuggestParameters();
sugp.UseFuzzyMatching = blnFuzzy;
return _indexClient.Documents.Suggest(searchText, "quoteauthor", sugp);
}
catch (Exception ex)
{
Console.WriteLine("Error querying index: {0}\r\n", ex.Message.ToString());
}
return null;
}
2. I added the Suggest action to the AzureSearchController. This would allow me to have a method to call when a suggestion was needed.
Click to view AzureSearchController Suggest code
public ActionResult Suggest(string search, bool fuzzy)
{
//Get the JsonResult
JsonResult suggestiondata = new JsonResult();
suggestiondata.JsonRequestBehavior = JsonRequestBehavior.AllowGet;
suggestiondata.Data = _AzureSearch.Suggest(search, fuzzy);
//Return the array
return Json(suggestiondata);
}
3. I added some KeyPress event handlers to the View to capture when a user types a suggestion. When at least three characters are entered, the form would automatically call the Suggest action to get the results.
Click to view Suggest KeyPress code
$(function () {
$("#q").keyup(function () {
if ($("#q").val().length > 2) {
Suggest($("#q").val(), $("#cbfuzzy").is(':checked'));
}
else {
$("#suggestions").html('');
}
});
// Execute search if user clicks enter
$("#q").keyup(function (event) {
if (event.keyCode == 13) {
Search();
}
});
});
4. I added some JavaScript to parse the suggestions from the REST service and display them as links.
Click to view Suggest JavaScript parsing code
function Suggest(search, fuzzy) {
// We will post to the MVC controller and parse the full results on the client side
// You may wish to do additional pre-processing on the data before sending it back to the client
$.post('/DancingGoatMvc9/en-us/AzureSearch/suggest',
{
search: search, fuzzy: fuzzy
},
function (suggestresults) {
var suggestionsHTML = "<strong>Suggested Authors:</strong> ";
//Loop through the facets to build the list
for (var j = 0; j < suggestresults.Data.length; j++) {
if (suggestionsHTML.indexOf(suggestresults.Data[j].Text) == -1) {
suggestionsHTML += "<a href=\"javascript: selectauthor('" + suggestresults.Data[j].Text + "');\">" + suggestresults.Data[j].Text + "</a> ";
}
}
$("#suggestions").html(suggestionsHTML);
});
};
Testing (Again)
With my new Suggest functionality in place, it was back to more testing. I fired up the site again and started typing in the Search box. As I entered my text, I saw the suggestions displaying on my page, just as intended. Not yet satisfied, I added one more piece of functionality to allow the ability to do a “fuzzy” search. Denoted by a flag that gets passed to the REST service, it will return results “similar” to what you entered, a great feature for any Search page.
Wrapping Up
So there you have it! I successfully ported my Azure Search integration to MVC and leveraged the MVC demo project for the foundation. While this blog was a little long, the actual process only took me about a day to complete, mostly due to parsing out the JSON and understanding the objects being returned.
Speaking of which, there are a couple of notes I wanted to share:
- I updated the “Create Index” method to create the index, add the Suggester, and scoring profiles all in one call.
- Most of the previous code that was in the web part’s code-behind file has been moved to JavaScript in the View.
- To get Facets working, I had to return an array of JSON objects from the Search Action in the Controller. This was because the IDE couldn’t seem to understand the “Facets” node of the results. That is why there are two objects being returned.
I forked the Kentico MVC GitHub demo to include the new functionality. You can access the fork here:
Azure Search Demo GitHub
I packaged the files, classes, and quote data into a zip file that you can download below. I've also included an Instructions file to help you set up the environment.
Azure Search MVC Package
This was a great learning experience for me and built my understanding of how to bring custom functionality into a Kentico site with the MVC development model. MVC is certainly a powerful framework that can be leveraged to build scalable applications using the best technology. I hope it helps you understand the process better. If not, let me know below!
Good luck!