Utilizing Inversion of Control to Build a Swappable Search Service in Episerver

See examples using Find and Vulcan to implement an easily swappable search service in Episerver.

One of the really cool things about my job is that I get to work with some great developers. One of my colleagues recently turned me on to the idea of writing applications that have swappable functionality. The basic idea is that the code you write should not tie into an implementation of some functionality but it's abstraction. That way, you can switch out the implementation with minimal to no changes to your controller and view model. This allows your code to be very extensible and completely decoupled. For this article, I am going to focus on building a search in Episerver, using both Find and Vulcan, to implement our service. The full code source for this article can be found here: EpiComerceSandbox.

To start, let's define three interfaces:

public interface ISearchService
{
     ISearchHitList Search(string term, int pagesize, int pageNumber);

     ISearchHitList SearchVariants(string term, string manufacturer, int pagesize, int pageNumber);
}
public interface ISearchHit
{
     string Name { get; set; }
     string Url { get; set; }
     string Teaser { get; set; }
}
public interface ISearchHitList
{
     int TotalResults { get; set; }
     IEnumerable<ISearchHit> Results { get; set; }
}

As you can see, our ISearchService is pretty basic. We will have two functions, one for the general site search, and one for a product specific search. Before we look at the classes that implement these interfaces, let's take a look at the controller and view model we are going to use for our search results page.

public class SearchResultsController : PageControllerBase<SearchResultsPageData>
    {
        private readonly ISearchService _searchService;

        public SearchResultsController(ISearchService searchService)
        {
            _searchService = searchService;
        }

        // General Site Search
        public ViewResult Index(SearchResultsPageData currentPage, string term, int pageSize, int page = 1)
        {
            SearchContentViewModel model = new SearchContentViewModel(currentPage);
         
            model.PageSize = pageSize;
            model.PageNumber = page;

            if (!string.IsNullOrWhiteSpace(term))
            {
                var hitList = _searchService.Search(term, pageSize, page);
                
                model.NumberOfHits = hitList.TotalResults;
                model.Hits = hitList.Results;
            }

            return View(model);
        }

        // Product Search
        public ViewResult Index(SearchResultsPageData currentPage, string term, string manufacturer, int pageSize, int page = 1)
        {
            SearchContentViewModel model = new SearchContentViewModel(currentPage);

            model.PageSize = pageSize;
            model.PageNumber = page;

            if (!string.IsNullOrWhiteSpace(term) || !string.IsNullOrWhiteSpace(manufacturer))
            {
                var hitList = _searchService.SearchVariants(term, manufacturer, pageSize, page);

                model.NumberOfHits = hitList.TotalResults;
                model.Hits = hitList.Results;
            }

            return View(model);
        }
    }
public class SearchContentViewModel : PageViewModel<SearchResultsPageData>
    {
        public SearchContentViewModel() { }

        public SearchContentViewModel(SearchResultsPageData currentPage)
            : base(currentPage) { }

        public string SearchedQuery { get; set; }

        public int NumberOfHits { get; set; }

        public int PageSize { get; set; }

        public IEnumerable<ISearchHit> Hits { get; set; }
    }

In our controller, we are using dependency injection to create an instance of our ISearchService. It doesn't care if we are using Find, or Vulcan or even the built-in Lucene search. It is programmed to only care about our abstracted ISearchService. So, now we have the ability to easily specify which search to use by implementing our ISearchService and wiring it up within the Episerver IoC-container. The view model is also only interested in our ISearchHit, not the class we are going to see next that implements it.

[EPiServer.ServiceLocation.ServiceConfiguration(typeof(ISearchHit))]
public class Hit : ISearchHit
{
    public string Name { get; set; }
    public string Url { get; set; }
    public string Teaser { get; set; }
}
[EPiServer.ServiceLocation.ServiceConfiguration(typeof(ISearchHitList))]
public class SearchResults : ISearchHitList
{
    public IEnumerable<ISearchHit> Results { get; set; }       
    public int TotalResults { get; set; }
}

The main thing to note here is the atribute we have on the class: 

[EPiServer.ServiceLocation.ServiceConfiguration(typeof(MyInterfaceToWireUp))]

This is how Episerver knows which class to inject when we call our abstracted classes. Now all we have to do is implement our ISearchService and we will be good to go. To make it interesting, let's do a hypothetical situation. One which may help us understand how awesome this all really is. Let's say we are working on a new client site and we know that they want to eventually be using Episerver Find for their search, but, the problem is that the new site needs to launch in June, and the budget for Find won't be included until the first quarter of the following year. So, we need a temporary fix, knowing that we will be swapping it out for Find in 6 months or so. Lucky for us, we built the search functionality with this in mind. Here is what our Vulcan Implementation looks like:

[EPiServer.ServiceLocation.ServiceConfiguration(typeof(ISearchService))]
public class VulcanSearchService : ISearchService
    {
        private readonly ISearchHitList _searchHitList;
        private readonly IContentLoader _contentLoader;
        private readonly IVulcanHandler _vulcanHandler;
        private readonly ReferenceConverter _referenceConverter;

        public VulcanSearchService(ISearchHitList searchHitList, IContentLoader contentLoader, IVulcanHandler vulcanHandler, ReferenceConverter referenceConverter)
        {
            _searchHitList = searchHitList;
            _contentLoader = contentLoader;
            _vulcanHandler = vulcanHandler;
            _referenceConverter = referenceConverter;
        }

        // General Site Search
        public ISearchHitList Search(string term, int pageSize, int pageNumber)
        {
            string requestString, responseString;
            var client = _vulcanHandler.GetClient();
            var searchScope = new ContentReference[] { _referenceConverter.GetRootLink() };
            var excludeTypes = new[]
                    {
                        typeof(ProductCategoryData),
                        typeof(MediaData),
                        typeof(ContainerData)
                    };
            
            VulcanSearchHitList siteHits = client.GetSearchHits(term, pageNumber, pageSize, searchRoots: searchScope, excludeTypes: excludeTypes);

            // For debugging request and response body, set "system.web/compilation" debug to true!
            if (HttpContext.Current.IsDebuggingEnabled && siteHits.ResponseContext.ApiCall.RequestBodyInBytes != null && siteHits.ResponseContext.ApiCall.ResponseBodyInBytes != null)
            {
                requestString = System.Text.Encoding.UTF8.GetString(siteHits.ResponseContext.ApiCall.RequestBodyInBytes);
                responseString = System.Text.Encoding.UTF8.GetString(siteHits.ResponseContext.ApiCall.ResponseBodyInBytes);
            }
            _searchHitList.TotalResults = Convert.ToInt32(siteHits.TotalHits);
            _searchHitList.Results = siteHits.Items.SelectMany(CreateHitModelFromVulcan).ToList();

            return _searchHitList;
        }

        // Product Search
        public ISearchHitList SearchVariants(string term, string manufacturer, int pageNumber, int pageSize)
        {
            string requestString, responseString;
            var client = _vulcanHandler.GetClient();
            var searchScope = new ContentReference[] { _referenceConverter.GetRootLink() };
            var excludeTypes = new[]
                    {
                        typeof(ProductCategoryData),
                    };
            var typesToSearchFor = typeof(ProductItemData).GetSearchTypesFor();

            VulcanSearchHitList siteHits = client.GetSearchHits(term, pageNumber, pageSize, searchRoots: searchScope, includeTypes: typesToSearchFor, excludeTypes: excludeTypes);

            // For debugging request and response body, set "system.web/compilation" debug to true!
            if (HttpContext.Current.IsDebuggingEnabled && siteHits.ResponseContext.ApiCall.RequestBodyInBytes != null && siteHits.ResponseContext.ApiCall.ResponseBodyInBytes != null)
            {
                requestString = System.Text.Encoding.UTF8.GetString(siteHits.ResponseContext.ApiCall.RequestBodyInBytes);
                responseString = System.Text.Encoding.UTF8.GetString(siteHits.ResponseContext.ApiCall.ResponseBodyInBytes);
            }
            _searchHitList.TotalResults = Convert.ToInt32(siteHits.TotalHits);
            _searchHitList.Results = siteHits.Items.SelectMany(CreateProductHitModelFromVulcan).ToList();

            return _searchHitList;
        }

        #region Private Helper Functions

        private IEnumerable<ISearchHit> CreateHitModelFromVulcan(VulcanSearchHit item)
        {          
            yield return new Hit
            {
                Name = item.Title,
                Url = item.Url,
                Teaser = item.Summary
            };
        }

        private IEnumerable<ISearchHit> CreateProductHitModelFromVulcan(VulcanSearchHit item)
        {
            dynamic id = item.Id;
            int commerceId = id.ID;
            var contentReference = new ContentReference(commerceId, "CatalogContent");
            var product = _contentLoader.Get(contentReference);

            yield return new Hit
            {
                Name = product.DisplayName,
                Url = product.SearchHitUrl,
                Teaser = product.Teaser
            };
        }

        #endregion
    }

I am not going to get into specifics about Vulcan. That is beyond the scope of this article. I do want to point out the private helper functions, which are not a part of the ISearchService interface, but important nontheless. These get the data back from the Vulcan client and create the type we need for our controller and view model. Now, let's see how we would do it with Episerver Find.

[EPiServer.ServiceLocation.ServiceConfiguration(typeof(ISearchService))]
    public class FindSearchService : ISearchService
    {
        private IClient _client = SearchClient.Instance;
        private readonly ISearchHitList _searchHitList;
        private readonly IContentLoader _contentLoader;
        private readonly ReferenceConverter _referenceConverter;

        public FindSearchService(ISearchHitList searchHitList, IContentLoader contentLoader, ReferenceConverter referenceConverter)
        {
            _searchHitList = searchHitList;
            _contentLoader = contentLoader;
            _referenceConverter = referenceConverter;
        }

        // General Site Search
        public ISearchHitList Search(string term, int pagesize, int pageNumber)
        {
            ITypeSearch<ISearchContent> query = _client.UnifiedSearchFor(term);

            var TypeFilter = _client.BuildFilter().FilterForVisitor().ExcludeDeleted().CurrentlyPublished();
            query = query.Filter(TypeFilter);

            // Get the results
            var siteHits = query.Skip((pageNumber - 1) * pagesize).Take(pagesize).GetResult();

            _searchHitList.TotalResults = siteHits.TotalMatching;
            _searchHitList.Results = siteHits.SelectMany(CreateHitModelFromFind).ToList();

            return _searchHitList;
        }

        // Product Search
        public ISearchHitList SearchVariants(string term, string manufacturer, int pagesize, int pageNumber)
        {
            ITypeSearch<ProductItemData> query = _client.Search<ProductItemData>();
            // Keyword
            if (!string.IsNullOrEmpty(term))
            {
                query = query.For(term, q =>
                {
                    q.Query = "*" + term + "*";
                }).InField(x => x.DisplayName).AndInField(x => x.Description);
            }
            // Manufacturer
            if (!manufacturer.Equals(string.Empty))
            {
                var manufacturerFilter = _client.BuildFilter<ProductItemData>();
                manufacturerFilter = manufacturerFilter.And(x => x.Manufacturer.Match(manufacturer));
                query = query.Filter(manufacturerFilter);
            }
            // Get the reults
            var siteHits = query.FilterForVisitor().ExcludeDeleted().CurrentlyPublished().Skip((pageNumber - 1) * pagesize).Take(pagesize).GetContentResult();
            
            _searchHitList.TotalResults = siteHits.Items.Count();
            _searchHitList.Results = siteHits.Items.SelectMany(CreateProductHitModelFromFind).ToList();

            return _searchHitList;
        }

        #region Private Helper Functions

        private IEnumerable<ISearchHit> CreateHitModelFromFind(UnifiedSearchHit item)
        {
            yield return new Hit
            {
                Name = item.Title,
                Url = item.Url,
                Teaser = item.Excerpt
            };
        }

        private IEnumerable<ISearchHit> CreateProductHitModelFromFind(ProductItemData item)
        {
            yield return new  Hit
            {
                Name = item.DisplayName,
                Url = item.SearchHitUrl,
                Teaser = item.Teaser
            };
        }

        #endregion
    }

There we have it! Now, we can easily swap out our search service with minimal to no rework. I think that is pretty cool and an efficient way to build scalable applications. Keep in mind that you will only want to wire up one class at a time with your ISearchService in the Ioc-container.

If you are interested in learning more about getting started using Vulcan search with Episerver, check out this great article that my friend and colleague Brad McDavid wrote about it here. To learn about how awesome Find is and how to use it, check out the documentation at Episerver World, here. If you have any questions or comments, please join the conversation below.

3 comment(s) in response to Utilizing Inversion of Control to Build a Swappable Search Service in Episerver

Your Boyeee 15 Feb 17 @ 09:57 AM
Valdis Iljuconoks Says:
Great approach. Just 2 cents: sometimes you need debug request/response even in production site. You won't set debug flag to true as it will compile everything in debug. For that reason, you can use something from FeatureSwitch library to enable or disable particular behavior in runtime :) Cheers!

Your Boyeee 17 Feb 17 @ 03:39 PM
Valdis Iljuconoks Says:
That's fantastic avatar :D

Your Boyeee 17 Feb 17 @ 04:17 PM
John Says:
Ha! Yeah, it randomly pulls an avatar from a folder of images I keep on the server. Eventually, I will have more than just TCM images.

Have a comment?