Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / Node.js

Story about Desktop Client Who Wanted to be More

3 Jun 2019CPOL51 min read 16K  
A simple solution that somehow mimics the real world application, changing it several times, using different UI technlogies and keeping the specific logic the same

Motivation

I decided to make a simple solution, that somehow mimics a real world application, and to change it several times, using different UI technologies while keeping the specific logic the same. I created a simple dotnet solution consisting of WinForms client and .NET core library. Then I created WPF client. Then I created Http.Sys server project which uses the library. From this point, both desktop clients were making HTTP calls to Http.Sys server instead of calling the library directly (as before). Finally, I created express.js node.js hosted solution which hosts a web application client, making calls to the Http.Sys server. Because node.js server and Http.Sys server are running on different domains (ports in demo), cross-origin restrictions are present. Most browsers restrict cross-origin HTTP requests. So I decided not to implement CORS (Cross-Origin Resource Sharing) on Http.Sys server, which is a pretty fine solution, but to implement http proxy in node.js for the Http.Sys server. So finally, none of the clients were seeing the Http.Sys and they were working only with node.js server. Every change which I made solved an existing challenge and it demonstrated usage of a new technology. And of course, it introduces new challenges to solve...

What You Will Be Able to Make if You Follow the Entire Article

The entire solution is created from scratch. It will require some typing and some work. If there is a place, where you give up reading it and move to another reading, then this one is the best. This article will not explain the code line by line, you need to read the code itself and figure it out. Of course, if there are questions, I will try to answer them to the best of my knowledge.

The idea of the article is to show how an application can be changed from single user desktop solution to multiple user locally hosted solution, accessible all over the web*, supporting both web and desktop clients and even providing a kind of web API. I wanted the demo to be useful and for that reason, I created an app, which can do statistical classification and I am using a really simple way of doing it. So the main idea is change - functionality stays the same - same textboxes and same buttons do the same thing, only UI technology is changing and when needed, the related libraries. Logic stays the same - text processed in part one gives the same result as same text processed in part four.

Technical View

The entire demo solution consists of .NET solution project, mixing .NET Core and .NET Frameworks projects, and JavaScript node.js solution. .NET solution have Http.Sys server, library for “business” logic, two desktop clients, one WinForms and one WPF, and one small proxy project. Node.js solution is hosting one web application or you might call it Website if you prefer. Client JavaScript library is Vue.js, library for making http requests is axios.js. Calls from web application are proxied to Http.Sys server via node.js middleware.

Functional View

In machine learning and statistics, classification is the problem of identifying to which of a set of categories a new observation belongs, on the basis of a training set of data, containing observations which category membership is known. Our solution can be used exactly for that. However, this is a general reading programming article, so I will not use statistical and machine learning terms in it.

So, our functional view is like that: Users provide some text. Then this text is processed – some characters are removed, and remaining text is split into words. Those words are then placed into dictionary and ranked based on their occurrence in text. Word with most occurrences is ranked first, those with second most occurrences ranked second, etc. Then users can decide whether to save the data or not. With time, when more and more text is processed and saved, the data grows. So, when users provide new text, the new text is processed and decomposed into words and then compared with existing data from previous inputs. The words that have similar ranking in both sets are colored, so that users can easily identify them. If ranks are very close, they are colored in one color, if they are not so close, they are colored in different color, etc.

Live Demo View

So, our solution is working and it is now in the initial state. The HttpSys server is running on localhost:3010 and express node.js server is running on localhost:3011. Screen-shots to follow... I am going to enter some text, to process it and then to save it, so that I create an existing set. I need text which serves a similar purpose, but it is created by different persons, with different ways of expressing and different way of constructing the text. An announce is a perfect candidate for that. It can be announce for car or real estate sale, announce for job, something else. Because we, as developers, sometimes needs to deal with job posts or at least know what a developer job post should contain, I am going to use job post for making my set. So, I used duck duck go/qwant to find out who is the top US job posting site, navigate to it and search for the term “javascript”. I took the first 100 job posts, no matter whether they are real or not, and extracted the text (the file size of those saved as txt file happened to be 310 KB). Then, I processed it and saved it. This is how it looks on WPF client (word is shown in the first column, number of occurrences (or in more general terms the score) in the second and whether to be used or not is indicated by a state of the checkbox in third column):

Image 1

Then I removed words like and, to, the, of... (generally known as determiners), then I removed most of the verbs or words which are not IT terms. Result is something like that, courtesy of WinForms client:

Image 2

Ok, so far so good. Now it is time to test whether a newly entered text is similar to our existing set. So I go and search for another job post with term JavaScript. Then I paste it in text box and press Preview. This is how it looks on the web application client:

Image 3

Words which are very close are colored in green – word experience rank 0 in existing set and rank 2 in test set, those who are close are colored in yellow and those who are not so close but still in the game – in light gray. As we can see, the test text is quite similar to our existing set.

Now, let’s go and try with another job post, but this time with different search term, like sales or HR (sales in screenshot).

Image 4

As you can see, we don’t have anymore green matches, but still we nail few yellow and light gray. So, it is not a job post for JavaScript something, but still it is a job post.

Now, let’s try with something completely different – for example, current Top 100 number one song lyrics or car sale post or news report or something else (top 100 # 1 lyrics in screenshot):

Image 5

No colors, no match. It appear that test text and our existing set does not have much in common.

Prerequisites to Follow this Article and Make the Described Solution

This solution was created in spring of 2019, so the current versions at this time of all products and corresponding NuGet or NPM packages were used. For the .NET solution, the following IDE was used:

  • Microsoft Visual Studio Community 2019, available as free download from MSDN

and the following NuGet packages has been installed, together with their dependencies:

  • Newtonsoft.Json - for Business and both Windows clients
  • Microsoft.AspNetCore.Server.HttpSys - for dotnet server project
  • Microsoft.Extensions.Logging.Console - for dotnet server project
  • Microsoft.AspNetCore.ResponseCompression - for dotnet server project
  • System.Windows.Interactivity.WPF - only for WPF client

For node.js solution, the following IDE was used:

  • Visual Studio Code, available as free download from MSDN

together with:

  • node.js, available as free download from nodejs.org

The following npm packages:

  • express
  • http-proxy-middleware

The following client side JavaScript frameworks:

  • vue.js (vue.min.js)
  • axios.js (axios.min.js)

and the following CSS style framework:

  • w3.css

For all clients, “Josefin Sans” font was used as default font. For Windows clients, you need to install the font in order to use it, for web client, you can reference it from Google provided url. The font is available for download from Google fonts site. For testing the Http services, it might be useful to install application like Postman, Fiddler or similar. Postman was used during this demo. Demo solution can be completed by using other IDEs, so this is kind of a personal choice. Now it is time to start.

First Steps – Making the Business Logic Library and Winforms Client

The first version of the .NET Framework was released on 13th February, 2002. More than 17 years from the days of writing of this article. Windows Forms or as they are also commonly called WinForms were included in .NET 1.0 release. So it is proper to start our demo with this UI technology. Our project will target however .NET Framework 4.7.2. Even that .NET Core 3 Preview 5 is available at the time of writing, we don’t have an official .NET Core 3.0

Creating Solution and Windows Forms Project

  1. We are going to create a folder on D: drive called devdemos and in this folder, a folder called dotnet.
  2. Then, we start Visual Studio 2019 and from dialog window, we select “Create a new project”.
  3. We scroll down and select Windows Forms App (.NET Framework) and click Next.
  4. We name the project DesktopClientWhoWantedToBeMore and specify as Location the folder we just created, D:\devdemos\dotnet and then we check the “Place solution and project in the same directory”. Then we confirm with Create.
  5. The solution and the Windows Forms projects have the same name. We are going to right-click the project, select Rename and call it WinForms.
  6. Then, we are going to select the project again, right-click it and select Properties from drop-down. We are going to change the Assembly name and Default Namespace to WinForms. We save the properties.
  7. Then we are going to open the file Program.cs and rename the default namespace to WinForms by right clicking the existing name and selecting Rename and then typing WinForms
  8. We rename the file Form1.cs to Main.cs. A dialog appears and we confirm with Yes. The designer file should be renamed as well.
  9. Finally, we build and run the solution

Creating the Business Logic Project (as .NET Core 2.2 Project)

We right-click the solution and select AddNew Project. From the menu, add a new project, we select Class Library (.NET Standard) and we choose Next. We name our project Business and we confirm with Create. We delete Class1.cs and we create two new classes, Word and TextProcessor. We want that our application process text and turn it into words, who have a score and grace to this score they can be ranked in certain way. We are going to create a class, which fulfill those real world requirements. The class will be called Word and it will look like that:

Image 6

We will save all of the words that are designated to be saved by users. Still, for some sampling, not all words will be necessary. So, the Show property will be used in this case. We will filter word returned to client by this property. Now, the most important class, the TextProcessor. This is the specific logic of the sample, and if we look one step further and imagine this is a real application, the class(es) will consist the specific business rules which every (small) company is using. Those rules are probably going to change in some way in future. In ideal case, we would like that this change not affect the clients they continue to work with same method definitions as before. So we will make our business methods little bit more general – we are going to return interfaces instead of classes or void.

  • We are going to need a method who process and preserve the text – Process
  • We will have a method which will test an unknown text based on existing data so that the users can decide whether or not it belongs to a particular set. Or in case that users just want to gather some information about the unknown text. This method will be the Preprocess.
  • We need a method which will return data from the existing set in order to show it to users and do the ranking on their side. We are going to return sorted words by score and we are going to limit the number of returned words. For doing so, clients needs to call GetHistory method.
  • We will also need a method which will tell the Business which words are going to be used or shown, and which ones are not going to be user or not shown. This will be the UpdateShowForHistory method.
  • And finally, we are going to need a method to preserve the created history set for further use – Save method. In the initial stages of our application, this method will be called often and when we make the solution distributed, this method will not be needed, as long as the server is operating properly.

This is how we are going to implement the TextProcessor class. For this implementation, we decided to use Newtonsoft.Json for serialization and deserialization. For better performance, I would suggest using protobuf-net. I choose json in this case becasue it's easier to read saved data and later to examine services output in postman or in browser.

C#
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace Business
{
    public class WordProcessor
    {
        private ConcurrentDictionary<string, Word> words;

        public WordProcessor()
        {
            string file = Path.Combine(Directory.GetCurrentDirectory(), "words.json");
            if (File.Exists(file))
            {
                string savedContent = File.ReadAllText(file);
                words = JsonConvert.DeserializeObject
                        <ConcurrentDictionary<string, Word>>(savedContent);
            }
            else
            {
                words = new ConcurrentDictionary<string, Word>();
            }
        }

        public IDictionary<string, Word> Preprocess(string sentence)
        {
            Dictionary<string, Word> currentWords = new Dictionary<string, Word>();
            string[] parts = CleanSentence(sentence);
            foreach (string p in parts)
            {
                if (currentWords.ContainsKey(p))
                {
                    currentWords[p].Count++;
                }
                else
                {
                    currentWords.Add(p, new Word { Name = p, Count = 1, Show = true });
                }

                if (words.ContainsKey(p))
                {
                    currentWords[p].Show = words[p].Show; // Do not show words
                                                          // already marked as hidden
                }
            }

            return currentWords;
        }

        public void Process(string sentence)
        {
            string[] parts = CleanSentence(sentence);
            foreach (string p in parts)
            {
                words.AddOrUpdate(p, new Word
                { Name = p, Count = 1, Show = true }, (key, value) => new Word
                { Name = value.Name, Count = value.Count + 1, Show = value.Show });
            }
        }

        public void Save()
        {
            string save = JsonConvert.SerializeObject(words, Formatting.None);
            using (StreamWriter sw = new StreamWriter
            (Path.Combine(Directory.GetCurrentDirectory(), "words.json")))
            {
                sw.Write(save);
            }
        }

        public void UpdateShowForHistory(IList<Word> list)
        {
            UpdateWords(list, words);
        }

        public IList<Word> GetHistory(int top, bool useShow = true)
        {
            return Update(words, new List<Word>(), top, useShow);
        }

        private static string[] CleanSentence(string sentence)
        {
            string cleanSentence =
            Regex.Replace(sentence, @"\t|\n|\r|\)|\(|;|<|>|{|}", " ");
            string[] parts = cleanSentence.ToLowerInvariant().Split(new[]
            { ',', '.', ' ', '/' }, StringSplitOptions.RemoveEmptyEntries);
            return parts;
        }

        private static IList<Word> Update(IDictionary<string, Word> dict,
                       IList<Word> list, int top, bool useShow = true)
        {
            if (useShow)
            {
                UpdateWords(list, dict);
                list = (from w in dict.Values
                        where w.Show == true
                        orderby w.Count descending
                        select w).Take(top).ToList();
            }
            else
            {
                list = (from w in dict.Values
                        orderby w.Count descending
                        select w).Take(top).ToList();
            }

            return list;
        }

        private static void UpdateWords(IList<Word> list, IDictionary<string, Word> dict)
        {
            if (list != null && list.Count > 0)
            {
                foreach (Word w in list)
                {
                    dict[w.Name].Show = w.Show;
                }
            }
        }
    }
}

We are now ready to build the library and to continue with WinForms client.

Creating the Windows Forms UI

Working with WinForms means in most of the cases working with the designer. Usually controls are drag and dropped from a toolbox into a panel, which is placed into the form. Working with raw designer cs/vb files is also a common practice, especially when the form is big and contains lots of controls, but in our article, we are not going to employ it. So:

  1. We add a reference to Business library by right-click References and then from Project tab, we check Business.
  2. We need to install Newtonsoft.Json nuget for this project too.
  3. Then we select the form file, Main.cs, so that our active window is Main.cs [Design].
  4. We click on the form to select it and then select Properties tab. Here, we will make some changes. We will specify values for Size, Width will be 1024 and Height will be 640. The Text is going to be winforms. And the (Name) will be frmMain. We save our changes.
  5. From the toolbox, we select <it>Panel control and drag and drop it onto the form. There will appear a panel menu, which will show an option “Dock in Parent Container” when clicked. We select it. We select again the Properties tab and specify for the property (Name) value pnl. We save
  6. Now, we can select a TextBox or RichTextBox as our next control. TextBox will suit us well, but it has a limitation that it does not allow large text with more than 1024 new lines in it. So we go for RichTextBox, because we want to process very large texts. It has some drawbacks, for example, it misses Padding. Anyway, drag and drop the control onto panel.
    • We specify as Location, X to 12 and Y to 12
    • For Size, we specify 984 and 100
    • For (Name), we set rtb
    • And for Font, we specify Josefin Sans, Regular, 16pt which will be shown as Josefin Sans 15.75 pt.

    We save. We might build the solution to see everything is fine.

  7. Now, we select BindingSource from Toolbox and place it into panel. It is automatically moved below the form. We select it and we set for (Name) - bindingSourceCurrent, and we set AllowNew to false. Then, for DataSource, we select the arrow pointing down so that a drop-down menu appear and we click on Add Project Data Source. Data Source Configuration Wizard appears. On the first screen, we select Object and confirm with Next. Then we select Word class from Business library Business namespace and we confirm with Finish. Image 7
  8. We do the actions described in the previous step, the only difference this time being naming binding source bindingSourceHistory. We save.
  9. From Toolbox, we select DataGridView and place in into panel. From the Data grid view menu, we set the data source for the grid view to bindingSourceCurrent. We remove checks from Enable Adding and Enable Deleting. We select the data grid view and we go to its Properties.
    • For (Name), we specify dgvLeft
    • For BackgroundColor and GridColor, we set Window
    • For Location, we specify 12, 118
    • For Size, we specify 487, 421
    • For ColumnHeadersVisible, we set false
    • For RowHeadersVisible, we set false

    Then, we go back to data grid menu and show it. Then we select Edit Columns… We mark Name and Count columns as read-only. We hide Rank (Visible set to false) column for now, it might be useful to show it when you want to verity the ranking and coloring further in the demo. We specify widths of columns which suits us well. We confirm with OK.

    Image 8

    It remains only to specify the data grid font. We call the DefaultCellStyle CellStyle Builder dialog by clicking the .. button. We change the Font to Josefin Sans Regular 10 pt, which will be shown as Josefin Sans, 9.75pt.

    Image 9

    We confirm with OK. We save.

  10. We do the actions described in the previous step, with the following differences:
    • we bind to bindingSourceHistory
    • (Name) is dvgRight
    • Location is 509, 118
  11. From Toolbox, we select a Button and we place it on panel. We specify the following:
    • For (Name), we set btnPreview
    • For Text, we set Preview
    • For Location, we specify 836, 545
    • For Size, we specify 160, 44
    • For Font, we specify Josefin Sans, SemiBold, 16pt
  12. We do the actions described in previous step, with the following differences:
    • For (Name), we set btnSave
    • For Text, we set Save
    • For Location, we specify 670, 545
  13. Again, we follow the actions described in previous step, with the following differences:
    • For (Name) we set btnUpdate
    • For Text we set Update
    • For Location we specify 504, 545
    We save.
  14. It remains to specify the TabIndex for every control. rtb should have TabIndex = 0 and for the other controls, we specify the following sequence: dvgLeft, dvgRight, btnPreview, btnSave, btnUpdate. Our form should look something like that (or the way you want to be, if you decide to change the design a litle bit):

    Image 10
  15. We have to create event handlers for button clicks. We can do that by double-clicking the button in design mode or by selecting the button, then selecting Properties main tab and from it, selecting Events sub-tab and there, we specify the Click event method handler. We will remain the autogenerated method names later.
  16. We add an event handler for the frmMain FormClosing event and for the SizeChanged
  17. Finally, we are going to add some event handlers for rbt. We need event handlers for following events: Enter, KeyUp, Leave and TextChanged. This is because we are going to implement a custom placeholder for rich text box and we want to handle some keyboard events. More, we want when the key combination Shift + Enter is executed in rbt same things to happen as we have clicked the Preview button.

Creating Code-Behind Logic

Now it is time to create code-behind logic, which will determine how our application works, at this moment of the development of the entire solution. We want the application to behave in the following manner:

  • Users can type or copy-paste text in (rich) text box. Enter is accepted. On certain conditions, Preview and Save buttons should be disabled - when there is no user text provided*
  • When executing Shift + Enter in text box or by clicking Preview, the Preprocess method from Business should be called. This will result in text decomposition into words and returning each word with its score. For example, if we enter: One Two Three One Two One, this will return one with Count/score 3 and Rank 0, two with score 2 and Rank 1 and three with score 1 and Rank 2. Result will be shown in left data grid view.
  • When users provide some text and press Save, they will call the Process business method. Text will be processed the same way as in Preview, but it will be preserver and added to existing data in Business. The updated ranking will be shown in right data grid view. This is achieved by calling another business method, GetHistory, after the first one, Process.
  • We can mark a word in both grid views, left and right, as checked on unchecked. When we uncheck a word, (by default all should be checked), this means that this word won't be used for making the ranking and won't be shown. By clicking on Update button, we will update this info to Business. The method used here is the UpdateShowForHistory.
  • If we want to change which words are shown/hidden, we should press Shift and click Update. This will show first N (500 in demo) words, no matter of Show property value.
  • If we have some data on Business and we add a new one, we can do the classification or put simply, to see which words from two sets have similar ranking. So, when Preprocess method is called, after that, we will call additional method to color words which have close ranks.

We want few additional things:

  • We want that our (rich) text box show short instruction when empty, so the first time user know that to do. For such cases in HTML world, we have input tag placeholder attribute. The Windows term for this is Cue Banner. There are special windows OS messages for working with them. So in order to implement it this way, we have to call native Windows methods. We use another approach, we manually set the cue banner of focus, leave and text changed events. And we will make the cue banner light blue, something that default cue banner does not support (if we use the default windows theme).
  • WinForms do not support by changing of controls location and size during form resizing. Form can be dynamically resized, but controls will be not. So we will have something like that:

    Image 11

    We can solve this in various ways - in production application, we can forbid our forms to be resized or we can use some third-party control library, which provides this functionality. Those custom controls will be able to automatically fix the layout on resizing. Or we can create our own implementation of custom panel, which automatically arrange its child controls. Or we can create custom method, which arrange layout for this form in particular. We are going to use the last approach. Using it can lead to errors and will reduce maintainability in production application. But in our case, it will show one advantage of a later technology, WPF, which we doing to demonstrate in greater detail in part 2. Anyway, this is how our custom method fixes resizing:

    Image 12

Even though in today's software industry it is innovation that is respected, but not tradition, we will honor previous WinForms developers and we will name our event handlers and arguments in a specific way, even only for a small demo app. The designer names the event handlers in the following way - NameOfTheControl_EventName, and all event arguments are called e. We will use the naming On + NameOfTheControl + EventName and we will name EventArgs - ea, KeyEventArgs - kea, etc. According to some guidelines, worlds like Please should be omitted, if from users it is required to do something, that application would normally accept, like "Enter Password" or even "Password" instead of "Please, enter your password". This cue banner message starts with "Please" because in my native language well written instructions start with please, even it is sometimes the only choice or is imperative. And finally, this is the code-behind for frmMain.

C#
using Business;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
using System.Linq;

namespace WinForms
{
    public partial class frmMain : Form
    {
        private string cueMessage = "Please, enter text to process";
        private WordProcessor wp;
        private Dictionary<string, Word> currentWords;

        public frmMain()
        {
            InitializeComponent();

            wp = new WordProcessor();
            currentWords = new Dictionary<string, Word>();

            bindingSourceHistory.DataSource = wp.GetHistory(100, true);
        }

        private void OnBtnPreviewClick(object sender, EventArgs ea)
        {
            PreprocessText();
        }

        private void OnBtnSaveClick(object sender, EventArgs ea)
        {
            wp.Process(rtb.Text);
            bindingSourceHistory.DataSource = wp.GetHistory(100, true);
            rtb.Text = string.Empty;

            currentWords.Clear();
            bindingSourceCurrent.Clear();
            UpdateData();
        }

        private void OnBtnUpdateClick(object sender, EventArgs ea)
        {
            UpdateData();
        }

        private void OnRtbEnter(object sender, EventArgs ea)
        {
            // we can cast the sender to RichTextBox or we can call the control by name
            if (rtb.Text.Equals(cueMessage))
            {
                RemoveCueMessage();
            }
        }

        private void OnRtbKeyUp(object sender, KeyEventArgs kea)
        {
            if (kea.KeyCode.Equals(Keys.Enter) && kea.Shift)
            {
                PreprocessText();
            }
        }

        private void OnRtbLeave(object sender, EventArgs ea)
        {
            if (rtb.Text.Equals(string.Empty))
            {
                MakeCueMessage();
            }
        }

        private void OnRtbTextChanged(object sender, EventArgs ea)
        {
            if (string.IsNullOrEmpty(rtb.Text) || rtb.Text.Equals(cueMessage))
            {
                SetButtonState(false);
            }
            else
            {
                SetButtonState(true);
            }
        }

        private void OnFrmMainFormClosing(object sender, FormClosingEventArgs fcea)
        {
            wp.Save();
        }

        private void OnFrmMainSizeChanged(object sender, EventArgs ea)
        {
            ResizeFormControlsAccordingly(Size);
        }

        private void MakeCueMessage()
        {
            rtb.Text = cueMessage;
            rtb.ForeColor = Color.LightBlue;
            SetButtonState(false);
        }

        private void RemoveCueMessage()
        {
            rtb.Text = string.Empty;
            rtb.ForeColor = SystemColors.WindowText;
            SetButtonState(true);
        }

        private void PreprocessText()
        {
            currentWords = (Dictionary<string, Word>)
                wp.Preprocess(rtb.Text); // could be 10 000 words for example
            bindingSourceCurrent.DataSource = currentWords.Values.Where
            (x => x.Show.Equals(true)).OrderByDescending(x => x.Count).Take(100).ToList();
            ColorListViews();
        }

        private void UpdateData()
        {
            // First, we update what user sets as show or not show.
            // We call the business to update history data and
            // we manually update values in dictionary
            wp.UpdateShowForHistory((IList<Word>)bindingSourceHistory.List);
            foreach (Word word in bindingSourceCurrent.List) // this list is about 0-100
            // or 0-500 or small. Current dictionary can contain much more words.
            {
                currentWords[word.Name].Show = word.Show;
            }

            if (ModifierKeys.HasFlag(Keys.Shift))
            {
                bindingSourceHistory.DataSource = wp.GetHistory(500, false);
                bindingSourceCurrent.DataSource = currentWords.Values.OrderBy
                                                  (x => x.Count).Take(500).ToList();
            }
            else
            {
                bindingSourceHistory.DataSource = wp.GetHistory(100, true);
                bindingSourceCurrent.DataSource = currentWords.Values.Where
                (x => x.Show.Equals(true)).OrderByDescending
                (x => x.Count).Take(100).ToList();
            }

            ColorListViews();
        }

        private void ColorListViews()
        {
            byte a = Convert.ToByte(bindingSourceCurrent.Count == 0);
            byte b = Convert.ToByte(bindingSourceHistory.Count == 0);

            int combinedResult = (a << 1 | b);

            switch (combinedResult)
            {
                case 3: // a = 1 b = 1 true true
                    bindingSourceCurrent.DataSource = null;
                    bindingSourceHistory.DataSource = null;
                    break;
                case 2: // a = 1 b = 0 true false
                    bindingSourceCurrent.DataSource = null;
                    break;
                case 1: // a = 0 b = 1 false true
                    bindingSourceHistory.DataSource = null;
                    break;
                case 0: // a = 0 b = 0 false false
                    ColorIntersectingWords();
                    break;
                default:
                    break;
            }
        }

        private void ResizeFormControlsAccordingly(Size formSize)
        {
            rtb.Location = new Point(12, 12);
            rtb.Width = formSize.Width - 16 - 2 * 12;
            rtb.Height = Math.Min(formSize.Height - 39, 100);

            btnPreview.Width = Math.Min(rtb.Width / 3, 160);
            btnPreview.Height = Math.Min(rtb.Height, 44);
            btnPreview.Location = new Point(formSize.Width - 16 - 12 -
            btnPreview.Width, formSize.Height - 39 - 12 - btnPreview.Height);

            dgvLeft.Location = new Point(rtb.Location.X, rtb.Location.Y + rtb.Height + 6);
            dgvLeft.Width = rtb.Width / 2 - 3;
            dgvLeft.Height = formSize.Height - 39 - 2 * 12 - rtb.Height -
                             btnPreview.Height - 2 * 6;

            dgvRight.Location = new Point(dgvLeft.Location.X +
                                dgvLeft.Width + 6, dgvLeft.Location.Y);
            dgvRight.Width = rtb.Width / 2 - 3;
            dgvRight.Height = dgvLeft.Height;

            btnSave.Width = btnPreview.Width;
            btnSave.Height = btnPreview.Height;
            btnSave.Location = new Point(btnPreview.Location.X - 6 -
                               btnPreview.Width, btnPreview.Location.Y);

            btnUpdate.Width = btnPreview.Width;
            btnUpdate.Height = btnPreview.Height;
            btnUpdate.Location = new Point(btnSave.Location.X - 6 -
                                 btnSave.Width, btnSave.Location.Y);
        }

        private void ColorIntersectingWords()
        {
            ClearBackColor(dgvRight);
            ClearBackColor(dgvLeft);
            List<Word> current = (List<Word>)bindingSourceCurrent.List;
            List<Word> history = (List<Word>)bindingSourceHistory.List;

            SetLocalRanking(current);
            SetLocalRanking(history);

            List<string> historyNames = history.Select(w => w.Name).ToList();
            List<string> currentNames = current.Select(w => w.Name).ToList();
            IEnumerable<string> query = historyNames.Intersect(currentNames);

            Word historyWord, currentWord;
            int delta = 0;
            foreach (string match in query)
            {
                historyWord = history.FirstOrDefault(x => x.Name.Equals(match));
                currentWord = current.FirstOrDefault(x => x.Name.Equals(match));
                delta = Math.Abs(historyWord.Rank - currentWord.Rank);

                if (delta <= 3)
                {
                    SpecifyColor(historyWord, currentWord, Color.LightGreen);
                }
                else if (delta <= 15)
                {
                    SpecifyColor(historyWord, currentWord, Color.LightYellow);
                }
                else if (delta <= 30)
                {
                    SpecifyColor(historyWord, currentWord, Color.LightGray);
                }
            }
        }

        private void SpecifyColor(Word history, Word current, Color color)
        {
            dgvLeft.Rows[current.Rank].DefaultCellStyle.BackColor = color;
            dgvRight.Rows[history.Rank].DefaultCellStyle.BackColor = color;
        }

        private void ClearBackColor(DataGridView dgv)
        {
            foreach (DataGridViewRow row in dgv.Rows)
            {
                row.DefaultCellStyle.BackColor = SystemColors.Window;
            }
        }

        private void SetLocalRanking(IEnumerable<Word> list)
        {
            int rank = 0;
            foreach (Word word in list)
            {
                word.Rank = rank++;
            }
        }

        private void SetButtonState(bool enabled)
        {
            btnPreview.Enabled = enabled;
            btnSave.Enabled = enabled;
        }
    }
}

We build and run the solution. Application should be working fine, if we have created everything properly. Now let's to do Part One live demo and close the first chapter.

Live Demo of WinForms Client and Recap of the Demo Solution So Far

You can click on the image and you will be able to see a gif presenting how the application is working at the end of part one.

Image 13

Good job! We have completed part one of this article! We have created a WinForms client and .NET Core library and we have processed some text. Let's recap the good things:

  • It works. It does the job. We have a working application.
  • The text is processed properly, the way we want.
  • The UI is robust and responsive and we can process very big texts for very short time.

And now let's recap the things which can be improved:

  1. The UI allows only one history set to be saved and worked with - this is not so difficult to implement and we leave it to the reader.
  2. Only one user can work with one set, it is not possible for two or more users to simultaneously provide set data and save it/process it, all are working with their history set.
  3. In order to get an updated client version, we need to provide users with recompiled application.
  4. When coloring the matching word rows, we access directly the row index, in SpecifyColor method. If in future, we implement more complicated ranking system, it might happen that by mistake we pass an index which is outside the row's array or we can provide negative index. So even for simple methods we have to put extra care.
  5. Our custom resizing method need change every time we add or remove control from the panel.
  6. For every users actions - button clicks, keyboard shortcuts, we have a separate event handler. Some of those event handlers call same private helper methods. For this form, we have nine event handlers, which just call some helper methods. In order to track event handler methods related to every UI element, we need to constantly look at designer or designer cs file.
  7. Custom Cue Banner implementation - this adds complexity and it will be nice if we have something out of the box

It's time for Part Two - Making the WPF client.

Part Two: Adding WPF Client to Our Existing Demo Project

Windows Presentation Foundation, WPF, was included in .NET Framework 3.0 release, on 21st November 2006. Four and half years after WinForms and more than 13 years from the days of writing this article. As WinForms, WPF is for making windows GUI applications. There are differences between the two and one of those differences is that WPF uses a mark-up language, called XAML, for defining the UI, as opposed to forms resources and code-behind designer generated files in WinForms. WPF development can be done like WinForms, with lots of code-behind code, or using a pattern, called MVVM, Model-View-ViewModel, or using a mixture between two. We are going to use the MVVM approach in our demo. When using MVVM model view basically does not care much about view model and view model does not care much about the view, they are loosely coupled. We can specify a command name and parameter in the view, but view model does not care whether the command was called by a key stroke, by pressing a button, by clicking a menu or by toggling a toggle. Similar, view model provides a collection or collection view source view, but does not care whether this is used as Item Source (for example) of a combo box, listbox, listview, datagrid, etc. So, we can almost independently working on views and on view models and later just bind them. Or a designer can work on the View to make it really cool and developer can work on view model. Or..., ok you get the idea.

Adding the Project, Creating Simple Project Folder Structure and Creating Classes for Base View Model and Command Implementation

  • We select our solution, DesktopClientWhoWantedToBeMore, right-click and choose Add -> New Project...
  • We select WPF app (.NET Framework) and click Next.
  • Project name will be, uhmm..., let's call it uhmmm..., let's call it WPF. We confirm with Create.
  • We select our new project, set it as a startup project, and we add two new folders (by Add -> New folder...) We name those folders, ViewModels and Infrastructure.

The focus of our demo solution is not WPF, so we are not going to use additional UI libraries and we are not going to implement content-based navigation or some little bit more advanced stuffs. You can browse the net for such examples. Simple content navigation is described in one of my other articles, Easy prototyping

  • We continue with our demo project and add class called ViewModelBase to ViewModels folder. We are going to add some code there and this is now it looks after we are ready (we changed the namespace name to the main one):

    Image 14
  • OK, cool. Now it is time to add a ICommand implementation. We add the class SimpleCommand to our Infrastructure folder. Thank you, Josh Smith. Here is it:

    Image 15
  • We add a project reference to Business.
  • We build and run the solution just to see that everything is fine.
  • We have to add two NuGet packages. One is Newtonsoft.Json
  • and the second is System.Windows.Interactivity.WPF.

    It is needed only because we have a custom Cue Banner, which relies on textbox enter and leave events, and they are passed to commands more easily with this package. Latest published date is March 10th, 2013, nearly six years ago.

We are ready with the basics for this part, let's move forward.

Creating the View Models and Implementing the Same User Interactions Like in WinForms

As we already have a really good working WinForms apps, we just need to "translate" code from WinForms to our project. Some of the code will be unchanged, but some of it will change. We have to decide on this stuff:

  • We don't have a binding source here. We can use ObservableCollection, even Collection or we can use CollectionViewSource. Or something else. Let's try with ObservableCollection.
  • XAML is good for working with ItemsControl and templates. We are going to create an ItemTemplate for our words. However, in WinForms, we set the color of matching row directly, by getting the row from the rows collection and setting it's color. Here we don't work directly with UI elements. So, we have to find a way to tell every word - you will be green, you will be yellow, etc. However, the Word class is not ours to tell, it belongs to Business. One way to solve this is to create a small view model for word, in which we will add few (one) additional properties, needed only for this project.
  • OK, so we are going to specify some property and according its value, the item template implementation should decide how to change its appearance. We are going to use a data trigger, which will set some UI properties to certain values based on property values of the data, binded to items control, which uses item template. So, for every element this property might have few values and we have to change the appearance accordingly.
  • Keyboard shortcuts and button clicks - we are going to bind keyboard shortcuts and button clicks to commands, so we are not going to use events in code-behind. For textbox events, GotFocus and LostFocus, we will do the same, using the interactivity NuGet package, which we install. Going code-behind in WPF is perfectly fine to me, but in this demo solution, we will go without it.
  • In WinForms solutions, we measure the distance between ranks of two words in two sets and based on the absolute value, we color the rows for those words. Here, we go one step further to make this more robust - based on our algorithm, which is absolutely the same, we specify an enum value - very close, not so close, not at all, something like that. And we leave the coloring to the view, as we explained few lines above.
  • For Cue Banner, we are going to use reference some visual libraries in our view model and for it, we are going to set the color directly, very similar to WinForms, but not exactly the same. It is not the best practice however.

Time for the code. Here is the enum WordDistanceEnum. We place the file in infrastructure folder, because we don't have designated folder for enums.

Image 16

and here is WordViewModel, placed in View Models folder.

Image 17

and here is the view model for the main window. We called it MainWindowViewModel. It is more than one screen long, so it is easier to paste it directly:

C#
using Business;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using System.Windows.Media;

namespace WPF
{
    public class MainWindowViewModel : ViewModelBase
    {
        private string textToProcess;
        private Brush foregroundBrush;
        private string cueMessage = "Please, enter text to process";
        private WordProcessor wp;
        private Dictionary<string, Word> currentWords;
        private ObservableCollection<WordViewModel> current,
                history; // because we don't add or remove single items,
                         // Collection<T> will work here just fine :)

        public MainWindowViewModel()
        {
            wp = new WordProcessor();

            ForegroundBrush = Brushes.LightBlue;
            TextToProcess = cueMessage;

            UpdateCommand = new SimpleCommand(Update);
            ProcessTextCommand = new SimpleCommand(ProcessText, CanProcessText);
            DummyCueBreadCommand = new SimpleCommand(DummyCueBread);
            SaveToFileCommand = new SimpleCommand(SaveToFile);

            History = MakeObservableCollection(wp.GetHistory(100, true));
        }

        public ICommand UpdateCommand { get; private set; }
        public ICommand ProcessTextCommand { get; private set; }
        public ICommand DummyCueBreadCommand { get; private set; }
        public ICommand SaveToFileCommand { get; private set; }

        public string TextToProcess
        {
            get { return textToProcess; }
            set
            {
                textToProcess = value;
                OnPropertyChanged("TextToProcess");
            }
        }

        public Brush ForegroundBrush
        {
            get { return foregroundBrush; }
            set
            {
                foregroundBrush = value;
                OnPropertyChanged("ForegroundBrush");
            }
        }

        public ObservableCollection<WordViewModel> Current
        {
            get { return current; }
            set
            {
                current = value;
                OnPropertyChanged("Current");
            }
        }

        public ObservableCollection<WordViewModel> History
        {
            get { return history; }
            set
            {
                history = value;
                OnPropertyChanged("History");
            }
        }

        private void Update(object parameter = null)
        {
            // First, we update what user sets as show or not show.
            // We call the business to update history data and
            // we manually update values in dictionary
            wp.UpdateShowForHistory(History.Select(x => x.Word).ToList());
            foreach (WordViewModel wvm in Current)
            {
                currentWords[wvm.Word.Name].Show = wvm.Word.Show;
            }

            if (Keyboard.Modifiers.Equals(ModifierKeys.Shift))
            {
                History = MakeObservableCollection(wp.GetHistory(500, false));
                Current = MakeObservableCollection
                          (currentWords.Values.Take(500).ToList());
            }
            else
            {
                History = MakeObservableCollection(wp.GetHistory(100, true));
                Current = MakeObservableCollection
                          (currentWords.Values.Where(x =>
                          x.Show.Equals(true)).OrderByDescending
                          (x => x.Count).Take(100).ToList());
            }

            CalculateRankDifference();
        }

        private bool CanProcessText(object parameter = null)
        {
            if (string.IsNullOrEmpty(TextToProcess) ||
                                     TextToProcess.Equals(cueMessage))
            {
                return false;
            }
            else
            {
                return true;
            }
        }

        private void ProcessText(object parameter = null)
        {
            switch (parameter.ToString())
            {
                case "ProcessInput":
                    currentWords = (Dictionary<string, Word>)
                                    wp.Preprocess(TextToProcess);
                    Current = MakeObservableCollection
                    (currentWords.Values.Where(x => x.Show.Equals(true)).
                    OrderByDescending(x => x.Count).Take(100).ToList());
                    CalculateRankDifference();
                    break;
                case "Save":
                    wp.Process(TextToProcess);
                    History = MakeObservableCollection(wp.GetHistory(100, true));
                    TextToProcess = string.Empty;
                    currentWords?.Clear();
                    Current?.Clear();
                    DistanceMassUpdate(History, WordDistanceEnum.No);
                    DummyCueBread("LostFocus");
                    break;
                default:
                    break;
            }
        }

        private void DummyCueBread(object parameter = null)
        {
            switch (parameter.ToString())
            {
                case "GotFocus":
                    if (TextToProcess.Equals(cueMessage))
                    {
                        TextToProcess = string.Empty;
                        ForegroundBrush = Brushes.Black;
                    }
                    break;
                case "LostFocus":
                    if (TextToProcess.Equals(cueMessage) ||
                        string.IsNullOrEmpty(TextToProcess))
                    {
                        TextToProcess = cueMessage;
                        ForegroundBrush = Brushes.LightBlue;
                    }
                    break;
                default:
                    break;
            }
        }

        private void CalculateRankDifference()
        {
            DistanceMassUpdate(Current, WordDistanceEnum.No);
            DistanceMassUpdate(History, WordDistanceEnum.No);

            SetLocalRanking(Current);
            SetLocalRanking(History);

            List<string> historyNames = History.Select(vm => vm.Word.Name).ToList();
            List<string> currentNames = Current.Select(vm => vm.Word.Name).ToList();
            IEnumerable<string> query = historyNames.Intersect(currentNames);

            WordViewModel history, current;
            int delta;
            foreach (string match in query)
            {
                history = History.FirstOrDefault(vm => vm.Word.Name.Equals(match));
                current = Current.FirstOrDefault(vm => vm.Word.Name.Equals(match));
                delta = Math.Abs(history.Word.Rank - current.Word.Rank);

                if (delta <= 3)
                {
                    history.Delta = current.Delta = WordDistanceEnum.Exact;
                }
                else if (delta <= 15)
                {
                    history.Delta = current.Delta = WordDistanceEnum.Match;
                }
                else if (delta <= 30)
                {
                    history.Delta = current.Delta = WordDistanceEnum.Close;
                }
            }
        }

        private void DistanceMassUpdate(ObservableCollection<WordViewModel> oc,
                                        WordDistanceEnum distance)
        {
            foreach (WordViewModel wvm in oc)
            {
                wvm.Delta = distance;
            }
        }

        private void SetLocalRanking(ICollection<WordViewModel> list)
        {
            int rank = 0;
            foreach (WordViewModel wvm in list)
            {
                wvm.Word.Rank = rank++;
            }
        }

        private void SaveToFile(object parameter = null)
        {
            wp.Save();
        }

        private ObservableCollection<WordViewModel>
                MakeObservableCollection(IList<Word> list)
        {
            ObservableCollection<WordViewModel> observable =
            new ObservableCollection<WordViewModel>
            (Enumerable.Range(0, list.Count).Select(x => new WordViewModel(list[x])));
            return observable;
        }
    }
}

Time for the mark-up. Before we go to this, there is one thing remaining - we open the code-behind files of App.xaml, App.xaml.cs and MainWindow.xaml, MainWindow.xaml.cs and we remove all non-used references and clean all default comments made by wizard. We save and build the project.

Creating the XAML Mark-up for Main View and Binding It to View Model

Let's make a summary about the main view - we have items controls for current and history word sets - we are going to use list views for them, we have simple item template and triggers for it and we have triggers for GotFocus and LostFocus (and forms Closing event). We have the view model as a data context set directly in the markup. Below, you can see the full XAML*.

Image 18

Resources and interactivity triggers are collapsed so that the first screen-shot fits on one screen. You can find them expanded below:

Image 19

This is how the finished project looks like, together with WinForms and Business projects. Both WPF and WinForms projects reference the Business library project. Time for small live demo for WPF client and then we close this part.

Image 20

WPF - Live Demo

You can click on the image and you will be able to see a gif presenting how the application is working at end of part two.

Image 21

Good job! We have completed part two of the article! We have not only WinForms, but also a WPF client, working fine with our business library.

The good things for client said at the end of part one hold for this WPF client as well - It works and it does the job properly. And we have a robust UI with proven technology. We have resolved some of the things, which we wanted to improve in part one - we don't need to invent any methods for layouts rearrangement when window is resized, WPF do this for us, because we set relative column sized. We use commands and we don't create "manually" events for every user action. And we don't deal with row indexes when we do the coloring of the matching words. Our solution is more robust. Maybe it has become more complex. When we talk about commercial applications using commercial third-party controls and components, WPF or WinForms is actually a personal choice for the developer, both can produce excellent applications.

Still, one of the main problems remains - only one user can create a word set at the time and multiple users cannot use the same set simultaneously. It is still single user application. So this will be our goal for part three. We can easily solve this by using a database and use it to handle multiple users. This is perfectly fine solution and the common case in company owned network. It will be risky however to allow users to connect directly to a database over the Internet. So we are going to create a web server instead, which will provide our business logic to various clients via http connections.

Part Three: Going Web, Going Async

From WinForms and WPF, we move to ASP.NET Core. Main reason for using ASP.NET Core is because our business logic is written in C#. C#/VB.NET are great for making business logic. However, if we want to use other technology for the web, something not .NET related, we have to think about a way to access the existing logic from the other server. This will complicate the solution. One other option will be to re-write the existing logic using other language. Even for our simple logic, this will not be a trivial task, for real-world stuff, it might happen to be very difficult. The main reason we used C# (for VB.NET is the same) back in part one and two was the ease of creating a native applications for Windows. Still, the C# for the web and the C# for the desktop are different, event that the language looks the same (meaning C# is standardized and its specification is the same for everybody), it feels different. The frameworks and tools are written by a different people, the way of writing feels different. Maybe they are not approaching each other with My kung fu C# is stronger than yours, but their code feels different. There are C# developers which concatenate strings with + sign, others uses string.Format with so-called composite format strings, and others use interpolated strings. Reasons for that vary and one of them is because they come from other languages/technology background and are accustomed to one way or another. This 'discussion' goes outside the scope of the article and we stop it here. But we promise to thing about using other technology (node.js - I am talking to you) in our solution and we will think again in part four. Now, let's go back to this part and ASP.NET Core.

ASP.NET core ships with three options for web servers - the Kestrel server, which is the default, cross-platform HTTP server, IIS HTTP Server, in-process server for IIS, and Http.Sys server, Windows-only HTTP server. I wanted application, which self-host the web server and it was easier (for me) to achieve this using the third option, the Http.Sys. In this part of the article, we are going to create server for handling http calls, GET and POST request mostly, and one proxy project, which will use HttpClient for making calls to server and implementing the same methods, which WinForms and WPF clients expect, as if they were working with WordProcessor class from Business library directly.

Creating the Server Project

  • We select our solution, right-click on it and choose Add -> New Project...
  • We select Console App (.NET Core). Yes, even that we are going to create Http.Sys .NET Core server, we select this. We confirm with Next.
  • We name the project HttpSysServer and confirm with Create.
  • This will be our startup project from now on.
  • We rename the Program.cs to Startup.cs. We do the same for the class name inside, from Program to Startup.
  • We add a reference to Business project.

We are going to install four NuGet packages:

  • Newtonsoft.Json
  • Microsoft.AspNetCore.Server.HttpSys
  • Microsoft.Extensions.Logging.Console
  • Microsoft.AspNetCore.ResponseCompression

Image 22

We are not going to use transport security in this demo, neither authentication or authorization. We are going to have three endpoints, current, history and save, which will respond to few HTTP verbs - GET, POST and PUT. For the others, we will return default messages. Response from server will be provided as "application/json" (RFC4627). Our server will use an object with state, this will be the instance of WordProcessor. It will remain in memory. For our demo performance of this will be just fine - we will be avoiding usage of save method directly, because this method serializes the content of the dictionary to the disk and it can be the only slow thing, especially if called multiple times. More of this in part four. Finally, if you want to use object with state in your server, think carefully before doing it. Below, you can find the code for Startup.

C#
using Business;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.HttpSys;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;

namespace HttpSysServer
{
    public class Startup
    {
        private WordProcessor wp;

        public Startup()
        {
            wp = new WordProcessor();
        }

        public static void Main()
        {
            IWebHost webHost = new WebHostBuilder()
                .ConfigureLogging(x => x.AddConsole())
                .UseStartup<Startup>()
                .UseHttpSys(options =>
                {
                    options.UrlPrefixes.Add("http://localhost:3010");
                    options.Authentication.Schemes = AuthenticationSchemes.None;
                    options.Authentication.AllowAnonymous = true;
                })
                .Build();

            webHost.Run();
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddResponseCompression();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseResponseCompression();

            app.Use(async (context, next) =>
            {
                context.Response.ContentType = "application/json";
                context.Response.StatusCode = 200;

                await next();
            });

            app.Map("/current", Preprocess);
            app.Map("/history", Process);
            app.Map("/save", Save);

            app.Run(async context =>
            {
                await context.Response.WriteAsync
                (JsonConvert.SerializeObject("All crews reporting."));
                return;
            });
        }

        private void Preprocess(IApplicationBuilder app)
        {
            app.Use(async (context, next) =>
            {
                if (context.Request.Method.Equals("POST"))
                {
                    string bodyContent = await new StreamReader
                                         (context.Request.Body).ReadToEndAsync();
                    var definition = new { userinput = string.Empty };
                    var obj = JsonConvert.DeserializeAnonymousType
                              (bodyContent, definition);

                    await context.Response.WriteAsync
                    (JsonConvert.SerializeObject(wp.Preprocess(obj.userinput)));
                    return;
                }

                await next();
            });

            app.Run(async context =>
            {
                await context.Response.WriteAsync
                      (JsonConvert.SerializeObject("Transmit orders."));
                return;
            });
        }

        private void Process(IApplicationBuilder app)
        {
            app.Use(async (context, next) =>
            {
                if (context.Request.Method.Equals("POST"))
                {
                    string bodyContent = await new StreamReader
                           (context.Request.Body).ReadToEndAsync();

                    var definition = new { userinput = string.Empty };
                    var obj = JsonConvert.DeserializeAnonymousType
                              (bodyContent, definition);

                    if (obj != null)
                    {
                        wp.Process(obj.userinput);
                        await context.Response.WriteAsync(string.Empty);
                        return;
                    }
                }
                else if (context.Request.Method.Equals("GET"))
                {
                    IList<Word> list = null;
                    IQueryCollection iqc = context.Request.Query;
                    bool useShow = true;
                    if (iqc.ContainsKey("useShow"))
                    {
                        bool.TryParse(iqc["useShow"], out useShow);
                    }

                    if (iqc.ContainsKey("top"))
                    {
                        int topNRecords;
                        if (int.TryParse(iqc["top"], out topNRecords))
                        {
                            topNRecords = Math.Min(1000, Math.Max(0, topNRecords));
                            list = wp.GetHistory(topNRecords, useShow);
                        }
                    }
                    if (list != null)
                    {
                        await context.Response.WriteAsync
                              (JsonConvert.SerializeObject(list));
                        return;
                    }

                }
                else if (context.Request.Method.Equals("PUT"))
                {
                    string bodyContent = await new StreamReader
                                         (context.Request.Body).ReadToEndAsync();

                    IList<Word> list = JsonConvert.DeserializeObject<IList<Word>>
                                       (bodyContent);
                    wp.UpdateShowForHistory(list);
                    await context.Response.WriteAsync
                          (JsonConvert.SerializeObject("Confirmed."));
                    return;
                }

                await next();
            });

            app.Run(async context =>
            {
                await context.Response.WriteAsync
                      (JsonConvert.SerializeObject("Transmit orders."));
                return;
            });
        }

        private void Save(IApplicationBuilder app)
        {
            app.Run(async context =>
            {
                if (context.Request.Method.Equals("POST"))
                {
                    wp.Save();
                    await context.Response.WriteAsync("Affirmative.");
                    return;
                }

                context.Response.StatusCode = 400;
                await context.Response.WriteAsync
                              (JsonConvert.SerializeObject
                              ("State the nature of your medical emergency."));
            });
        }
    }
}

OK, now let's build and run the solution. If all goes well, you should see something like that:

Image 23

We are going to make few basic request to the server to make sure it works properly. We start Postman and make a GET request to http://localhost:3010. We have a response. As you can see shortly just few lines below, the first request to Http.Sys took some time, but after that, every other request is processed very fast.

We make another request. POST to http://localhost:3010/current with some demo data:

Image 24

And now, POST to http://localhost:3010/history. This call is not expected to run response data, only successful status of 200.

Image 25

Let's make another one. This time, the response time on server should be less than a millisecond.

Image 26

And now, let's get the processed word data from server - we make GET call to http://localhost:3010/history?top=100&useShow=true. The first call to a particular endpoint is fast and compared to other server products is OK. The subsequent calls to the endpoint however are as we said above, very fast. We make the exact same call:

Image 27

And those are the times at server side. Compare the times for a first time call and subsequent call.

Image 28

And now, let's focus on what's happens after the subsequent get call, when I initiated few more of those. We are again below millisecond. :)

Image 29

I would advise that you spend some time and properly play with the web api, making multiple calls and testing all exposed endpoints, taking advantage of the excellent debugging support, which Visual Studio offers. When you are ready and feel OK with our solution so far, we move to the client side.

Creating a Proxy Project in Order to Ease Server Interaction for Desktop Clients

At this point of development of our solution, the two desktop clients, which we have created, still reference the Business project. They use the Word and WordProcessor classes, defined in this library. We have created a web server and this web server have a reference to Business library. So, it is now the web server who provides all the logic from Business, this time over the http and using application/json. Those desktop clients no longer need a reference to Business project and they have to consume what web server provide. For that purpose, we will create a project specifically for working with HttpClient and in this project, we will also create a Word class, which is going to be used only by clients. Then, we can start modifying both Word classes according to the needs of server project and proxy project. For example, our ranking is done always on client side, so the Word class on server will no longer need the Rank property. Here are the steps we are going to follow now:

  • We select the solution, right-click and Add -> New project
  • We select Class Library (.NET Standard) and confirm with Next
  • We name the project Proxy and we confirm with Create
  • We have to add the Newtonsoft.Json nuget for this project too
  • We select WinForms project, we expand References tree, we select Business and we remove it.
  • We select WPF project, we expand References tree, we select Business and we remove it.
  • We save the solution and try to build it. It should not build. We are going to fix this soon.

We select Word class from Business project and we remove Rank property. This is how the class should look after. We then compile only the business library.

Image 30

We rename the default Class1.cs to Word and we rename the class name to Word as well. Then we create few properties. This is the Word from Proxy project. If you want to change the names of the properties, this is just fine, but then you have to add few custom lines to make the proper deserialization of the server response. And to change all those names in both clients too. And to make sure it works as before. And..., well, you get the idea.

Image 31

We are going to add an enum called HttpVerbs. Here is it:

Image 32

and then we create class called BusinessProxy, which will hold an instance of HttpClient and will communicate with server. Both clients will have a reference to the Proxy project and they will use the BusinessProxy class instead of WordProcessor. We will make the method names of BusinessProxy the same as those of WordProcessor just to make our refactoring of existing client code little bit easier and save some renaming.

C#
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Web;

namespace Proxy
{
    public class BusinessProxy
    {
        private HttpClient httpClient;
        private string baseAddress = "http://localhost:3010";

        public BusinessProxy()
        {
            httpClient = new HttpClient();
        }

        public IDictionary<string, Word> Preprocess(string userInput)
        {
            string json = JsonConvert.SerializeObject(new { userinput = userInput });
            string result = Task.Run(() =>
            MakeRequest(HttpVerbs.POST,
            string.Format("{0}/current", baseAddress), json)).Result;
            return JsonConvert.DeserializeObject<IDictionary<string, Word>>(result);
        }

        public IList<Word> Process(string userInput)
        {
            string json = JsonConvert.SerializeObject(new { userinput = userInput });
            string result = Task.Run(() => MakeRequest
            (HttpVerbs.POST, string.Format("{0}/history", baseAddress), json)).Result;
            return JsonConvert.DeserializeObject<IList<Word>>(result);
        }
        public void UpdateShowForHistory(IList<Word> list)
        {
            string json = JsonConvert.SerializeObject(list);
            Task.Run(() => MakeRequest(HttpVerbs.PUT,
                           string.Format("{0}/history", baseAddress), json)).Wait();
        }

        public IList<Word> GetHistory(int top, bool useShow = true)
        {
            string requestUri = string.Format("{0}/history?top={1}&useShow={2}",
                                baseAddress, HttpUtility.UrlEncode(top.ToString()),
                                HttpUtility.UrlEncode(useShow.ToString()));
            string result = Task.Run(() => MakeRequest(HttpVerbs.GET,
                            requestUri, string.Empty)).Result;
            return JsonConvert.DeserializeObject<IList<Word>>(result);
        }

        public void Save()
        {
            Task.Run(() => MakeRequest(HttpVerbs.POST,
                           string.Format("{0}/save", baseAddress), string.Empty));
        }

        private async Task<string> MakeRequest(HttpVerbs verb,
                      string requestUri, string json)
        {
            try
            {
                HttpResponseMessage response;
                switch (verb)
                {
                    case HttpVerbs.GET:
                        response = await httpClient.GetAsync(requestUri);
                        break;
                    case HttpVerbs.POST:
                        response = await httpClient.PostAsync
                        (requestUri, new StringContent(json, Encoding.UTF8,
                         "application/json"));
                        break;
                    case HttpVerbs.PUT:
                        response = await httpClient.PutAsync
                        (requestUri, new StringContent(json,
                         Encoding.UTF8, "application/json"));
                        break;
                    case HttpVerbs.DELETE:
                    default:
                        return string.Empty;
                }

                if (response.StatusCode.Equals(HttpStatusCode.OK))
                {
                    string dummy = await response.Content.ReadAsStringAsync();
                    return dummy;
                }
            }
            catch (HttpRequestException hre)
            {
                Debug.WriteLine(hre.Message);
            }
            catch (Exception)
            {
                throw;
            }

            return string.Empty;
        }
    }
}

Threads Are No Joke

You might be wondering - ok, MakeRequest method returns a Task<string>, why don't we use the Result directly on this, but we have use Task.Run(() => something).Result. Short answer - give it a try - it will hang out your client and you might wonder why this is hanging and there is no exception thrown. For more, you might want to consult an MSDN Magazine Article (from March 2013), where such situations are explained in greater details - here

We build the Proxy project. Now it remains to fix the both desktop clients projects.

Fixing WinForms and WPF Projects

  • We select WinForms project, then we right-click References and add a reference only to Proxy project.
  • Then, we open the code-behind file of Main, Main.cs. It should complain about missing Business reference, WordProcessor and Word classes. We specify that we want to use Proxy instead of Business - see below:

    Image 33
  • Instead of WordProcessor wp; we type BusinessProxy bp;
  • In constructor, instead of wp = new WordProcessor, we put bp = new BusinessProxy()
  • and then, we replace every occurrence of wp with bp. There should be total of 7 replacements and there should be 9 references to bp in total after all of our changes. Here is how the first changes look like:

    Image 34
  • We right click on code page and select Remove and Sort Usings. Then we build only the WinForms project. It should build without issues.
  • We open the designer file, Main.Designer.cs and manually change from Business.Word to Proxy.Word. There should be 2 occurrences in total.

How it is time to fix WPF project.

  • We select WPF project, then we right-click References and add a reference only to Proxy project.
  • We open the WordViewModel and fix the Word reference to Proxy instead of Business.
  • We go to MainWindowViewModel class and make similar changes to those we did for Main.cs in WinForms project. Again, total of 9 bp occurrences and total of 7 replacements, as for Winforms project.

    Image 35

At this point, we are ready to build the solution and run it. All should go without troubles and HttpSys server console should appear, because this is still our starting project. This is how the final dotnet solution look like:

Image 36

During debug session, we will select Solution Explorer tab, and from there, right-click on WinForms or WPF, and Debug -> Start new Instance. Or we can Publish the .NET core server project to our local drive, start it from there and run the clients the old way.

Live Demo - Http.Sys Server and Desktop Clients

In case we want to produce an executable from a .NET Core project, we have to Publish the project. We are going to publish the project to a local folder. Those are profile settings, which we are going to use:

Image 37

For this demo, we have published the http.sys server as executable and we start it. As we mentioned before, first calls to every endpoint will took more time, but they still will be relatively fast, and the subsequent calls will execute very fast. So we enter some dummy text to make a history set and we put it on server. Then, every new instance of every client has this history set and start with it. We can add more words to it or update show property for every word.

You can click on the image and you will be able to see a gif presenting how the application is working at the end of part three.

Image 38

If you made it so far, excellent job! We have completed part three of our article! We have created Http.Sys server for providing our existing logic over the http and we have tuned our existing desktop clients projects to use the server and to make calls to it. We are using http protocol for our communication instead some custom and so common alternative and this makes our solution even more robust. We have solved one more challenge from those set at the end of chapter one - multiple users can access and use the same data, our solution is now distributed. We do not rely on database, neither on third-party service to achieve this. Using databases in production application is a must, but it introduces additional complexity and it is risky to connect to databases over the net. Or at least not so easy as accessing urls with browsers or with fat clients. Using third-party services is common in production and they basically do everything for you, but you pay additionally for this service. And sometimes this service can be rejected by owner and your solution will no longer work in such case.

Finally, we have a web server and we have desktop clients. It remains to find a solution to last challenge from part one - easy update of client logic, for example ranking logic. One of the best candidates for that is a web client, which will provide same user functionality as existing clients, but because it is provided over the http as well, it can be very easily updated. Time for the last part, part four.

Part Four and final: node.js Hosted Web Client and node.js Proxy

Our article has reached its final part. We are going to create a web client which will use the same endpoints, we might say the same web api as the existing desktop clients and it will provide the same functionality to the users. User experience from native application and from web application might not be the same, but in our case the interactions are simple and we can agree that in this particular case web clients can be used perfectly fine. We can continue with Http.Sys server and build more stuff there and add more middleware, not only our custom stuffs and the middleware for response compression. Hosting of static data can be done without any additional Nuget and it will be just fine. However, we are on the web now. We are not bound to any particular technology, we can use one or another approach. So, ASP.NET Core is good and will do the job, but let's try something else. Three part for dotnet up to now, let's make one part for node.js stuffs

Initial release of node.js was on 27 May 2009, 10 years ago from the days of writing of this article. The package manager for node.js, npm, was introduced in January 2010. Initial release of Vue.js was made in February 2014, or about 5 years ago. And initial release of axios.js was on 29 August 2014, let's say 4 and half years ago. We get an idea about the main stuffs for this chapter. It is time to move on.

Setting Up the node.js Solution

  • From part one, we have an existing folder on our D drive called devdemos. In this folder resides the existing dotnet folder with all the stuff we did so far. We create a folder called nodejs in devdemos.
  • We start the node.js command prompt and we navigate to D:\devdemos\nodejs
  • In node.js prompt, we type npm install express and confirm. Then we type npm install http-proxy-middleware and confirm. Or instead of those two steps, we type npm i express http-proxy-middleware
  • Then we start Visual Studio code and we open the D:\devdemos\nodejs folder
  • We are going to create a file called server.js (which implies that maybe somewhere should app.js also be present. For this particular demo, there is no app.js) We will run the server.
  • and we create three folders - UserStyles, UserScripts and UserViews
  • We go to https://www.w3schools.com/w3css/4/w3.css and download the css file. We place the file in UserStyles folder.
  • We have to make small changes and change default w3.css font to Josefin Sans. You can use any other css framework and font. In case of other css framework, you have to figure out yourself some of the UI elements classes or attributes later in the article.
  • We go and download recent vue.min.js and axios.min.js. We can get them from their sites for example or have them from CDN. We save both js files in UserScripts folder. For development, we are going to load them from our folder. In production, such third-party stuff should be obtained via Content Delivery Network (CDN), because they will probably deliver them faster.
  • We create file called main.js in UserScripts folder
  • and we create file Main.html in UserViews folder.
  • I have changed the style responsible for the placeholder and I have made it similar color to those, used for desktop clients. Does not work on all browsers however :(

Our solution should look like this:

Image 39

Creating the Markup

This is the markup for Main.html. The markup of the page should be simple. We will have a textarea, three buttons and two unordered list. List template will have to display the name, count and the boolean property Show. We can control the color of the element by setting its class. The CSS framework which we are using colors the element if we add specific word to its class. We are going to use this. For filling the data, we are going to use Vue.js provided binding. The idea is very, very similar to the situation that we have with the WPF client. This similarity will be further emphasized when we look at the main.js script few lines later.

HTML
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Story about desktop client who wanted to be more -
               web client version</title>
        <link rel="stylesheet"
         href="https://fonts.googleapis.com/css?family=Josefin+Sans">
        <link rel="stylesheet" href="w3.css">
    </head>
    <body>
        <div class="w3-container" id="mainViewModel">
            <div class="w3-row w3-margin">
                <textarea class="w3-border" v-model="userInput"
                 @keyup.enter="previewUserInput"
                 placeholder="Please, enter text to process"
                 style="width:90%;height:100px;"></textarea>
            </div>
            <div class="w3-row">
                <div class="w3-half"></div>
                <div class="w3-half w3-margin-bottom">
                    <button class="w3-button w3-margin-right w3-light-grey"
                     @click="onUpdate" id="btnUpdate">Update</button>
                    <button class="w3-button w3-margin-right w3-light-grey"
                     @click="onProcess" id="btnSave">Save</button>
                    <button class="w3-button w3-margin-right w3-light-grey"
                     @click="previewUserInput" id="btnPreview">Preview</button>
                </div>
            </div>
            <div class="w3-row">
                <div class="w3-half" id="leftPanel"><ul class="w3-ul" v-for="(word, index) in current">
                        <li>
                            <div class="w3-col l7">
                                <div v-bind:class="word.ClassString">
                                {{ word.Name }} </div>
                            </div>
                            <div class="w3-col l3">
                                <div v-bind:class="word.ClassString">
                                {{ word.Count }} </div>
                            </div>
                            <!-- <div class="w3-col l2">
                                <div v-bind:class="word.ClassString">
                                {{ word.Rank }} </div>
                            </div> -->
                            <div class="w3-col l2">
                                <div v-bind:class="word.ClassString">
                                <input type="checkbox" v-model="word.Show" /> </div>
                            </div>
                        </li>
                    </ul>
                </div>
                <div class="w3-half" id="rightPanel"><ul class="w3-ul" v-for="(word, index) in history">
                        <li>
                            <div class="w3-col l7">
                                <div v-bind:class="word.ClassString">
                                {{ word.Name }} </div>
                            </div>
                            <div class="w3-col l3">
                                <div v-bind:class="word.ClassString">
                                {{ word.Count }} </div>
                            </div>
                            <!-- <div class="w3-col l2">
                                <div v-bind:class="word.ClassString">
                                {{ word.Rank }} </div>
                            </div> -->
                            <div class="w3-col l2">
                                <div v-bind:class="word.ClassString">
                                <input type="checkbox" v-model="word.Show" /> </div>
                            </div>
                        </li>
                    </ul>
                </div>
            </div>
        </div>
        <footer class="w3-container">
            <h4>Story about desktop client who wanted to be more -
                web client version</h4>
            <script src="vue.min.js"></script>
            <script src="axios.min.js"></script>
            <script src="main.js"></script>
        </footer>
    </body>
</html>

Main.js - Heavily using Vue.js and axios.js

The main.js will contain the code for the web client. Axios will be used for making async calls to the server and vue.js will do the binding, event handling, property change tracking, etc. We have called the main Vue variable mainViewModel to again emphasis similarities between MVVM approach we used in WPF client and approach here. Those framework models are called MV* for a reason. However, let's give more details on the code:

  • el - div element, having id equal to the el value, is the element to which entire model will be bound. divs inside the main one will be accessed by property names. The syntax will be curly braces, like {{ something }} or {{ something.Name }}
  • data - this is where we define variables, which will be used by vue.js
  • watch - here, we watch for changes in some property values and if so, we call the corresponding function
  • mounted - this is executed when the page is loaded, similar to OnLoad event in desktops
  • methods - here we define vue.js methods. We can bind some of them to the markup events or we can call them directly. See bullets below for detailed description of methods.
  • getHistory - this method uses Axios to call the node.js hosted api url, which in turn is proxied to the Http.Sys server. Original url is /history. Axios is a promise based Http client, so you can think of it as HttpClient for the browser and node.js. Look for JavaScript promises, if you need more information about how exactly this works.
  • preProcess - axios call for preprocess. After we get a result, we set few variables and call one method
  • process - axios call for process. After we get a result, meaning only status 200, we update few things, set few variables and from here we call getHistory, so that user UI is updated properly. Otherwise, users have to refresh the browser, not good.
  • updateShowForHistory - simply to notify the server that our opinion on which word should be visible and which has not changed
  • Web client will not call Save method. We will save only from desktop clients. Calling Save is actually not so important now, because both servers are supposed to be running properly and constantly, so data is there. Only when it needs to be persisted, then the Save method should be called. Not the Save button clicked, but the API Save method called. We call it on closing of a desktop client.
  • Most of other methods are just our existing C# methods from WinForms/WPF translated to JavaScript and tuned accordingly. We don't have a property for distance, we set the color as class name directly for example. Because of the nature of JavaScript, we don't need additional view model for client word, we can just type word.Distance = 123 and we are good to go. Every language has its own strengths.

And finally, here is the main.js file. If you have some troubles here, use a browser debugging IDE and put console.log or breakpoints. Not so cool as native debugging in Visual Studio, but it is not so bad either. Getting better with time.

JavaScript
var mainViewModel = new Vue({
    el: '#mainViewModel',
    data: {
        userInput: '',
        currentDictionary: [],
        current: [],
        history: []
    },
    watch: {
        userInput: function(){
            if(this.userInput){
                this.disableEnableButtons(false)
            }else{
                this.disableEnableButtons(true)
            }
        }
    },
    mounted(){
        this.disableEnableButtons(true)
        this.getHistory(100, true)
    },
    methods: {
        getHistory(top, useShow){
            axios
                .get("http://localhost:3011/api/history?top=" +
                      top + "&useShow=" + useShow)
                .then(response =>{
                    this.history = response.data
                })
                .catch(error => console.log(error))
        },
        preProcess(someUserInput){
            axios({
                method: 'post',
                url: "http://localhost:3011/api/current",
                data:{
                    userinput: someUserInput
                }
            })
            .then(response =>{
                this.currentDictionary = response.data
                this.current = this.dictToArray(this.currentDictionary, 100)

                this.calculateRankDifference()
            })
            .catch(error => console.log(error))
        },
        process( someUserInput){
            axios({
                method: 'post',
                url: "http://localhost:3011/api/history",
                data:{
                    userinput: someUserInput
                }
            })
            .then(response =>{
                this.userInput = ''
                this.currentDictionary = []
                this.current = []
                this.distanceMassUpdate(this.history)
                this.getHistory(100, true)
            })
            .catch(error => console.log(error))
        },
        updateShowForHistory(){
            axios({
                method: 'put',
                url: "http://localhost:3011/api/history",
                data: this.history
            })
            .catch(error => console.log(error))
        },
        previewUserInput: function(event){
            // we call this method both from button and
            // by pressing Shift+Enter inside textbox
            if (event.type == 'keyup'){
                if(event.shiftKey){
                    console.log('Shift key pressed.')
                    this.preProcess(this.userInput)
                }
            }else if(event.type == 'click'){
                this.preProcess(this.userInput)
            }
        },
        onProcess(){
            this.process(this.userInput)
        },
        onUpdate: function(event){
            this.updateShowForHistory()
            this.current.forEach(word =>{
                this.currentDictionary[word.Name].Show = word.Show
            })

            if(event.shiftKey){
                this.getHistory(500, false)
                this.current = this.dictToArray(this.currentDictionary, 500)
            }else{
                this.getHistory(100, true)
                this.current = this.dictToArray(this.currentDictionary, 100)
            }
        },
        disableEnableButtons: function(enable){
            document.getElementById("btnSave").disabled = enable
            document.getElementById("btnPreview").disabled = enable
        },
        dictToArray(dict, count){
            var valuesArray = Object.values(dict)
            valuesArray.sort(function(first,second){
                return second.Count - first.Count // descending
            })

            var firstNArray = valuesArray.slice(0, count)
            return firstNArray
        },
        calculateRankDifference(){
            this.distanceMassUpdate(this.current)
            this.distanceMassUpdate(this.history)

            this.setLocalRanking(this.current)
            this.setLocalRanking(this.history)

            var historyNames = this.history.map(w => w.Name)
            var currentNames = this.current.map(w => w.Name)

            console.log('History names:' + historyNames +
                        ' Current names: ' + currentNames)

            var intersect = historyNames.filter(name => currentNames.includes(name))
            intersect.forEach(match => {
                var historyWord = this.history.find(w => w.Name == match)
                var currentWord = this.current.find(w => w.Name == match)

                var delta = historyWord.Rank - currentWord.Rank

                if(delta <= 3){
                    historyWord.ClassString =
                                currentWord.ClassString = "w3-light-green"
                }else if(delta <= 15){
                    historyWord.ClassString = currentWord.ClassString = "w3-yellow"
                }else if(delta < 30){
                    historyWord.ClassString =
                                currentWord.ClassString = "w3-light-gray"
                }
            })
        },
        distanceMassUpdate(array){
            array.forEach( word =>{
                word.ClassString = ''
            })
        },
        setLocalRanking(array){
            var rank = 0
            array.forEach( word =>{
                word.Rank = rank++
            })
            return array
        }
    }
})

Server File, Proxy and Buffer Rewriting if Needed

Now it is time for the final part of node.js solution - the server file. Node.js will serve two functions - will host the main.html (we can open the file main.html manually and it will still work, if node.js is running and API part is accessible) and provide it with CSS and JS files, and will proxy request from /api to Http.Sys server. For making the proxy calls, we will use the http-proxy-middleware. Peculiarity of this middleware is that if node.js uses npm package body-parser or in newer versions (as ours :)) even express.json(), this means that node.js will read the buffer of a POST or PUT or other request*. And buffer usually can be read only once. Why - outside the scope of this article. So, in such case, we have to re-write the buffer and move on. This is why there is commented code on the screen-shot.

Image 40

And that's it for the web part. We are ready with web client stuffs. Time for the final live demo!

Web Client and Http Proxy Live Demo

You can click on the image and you will be able to see a gif presenting how the application is working at end. Only web browser are featured in the gif :), but desktop client is perfectly fine to be used. They call the server and get updated data when they are started.

Image 41

Cool! We have achieved the remaining goal from part one - easy deployment of a client. It's so easy like typing the url and confirming. Now we have both desktop and web clients and we have native experience and if needed, very easy deployment. The idea is that all clients should be used together. We did a good job!

Still, now we have to care for two web servers using two different languages, clients developed using one framework and language, and other clients developer using another language and other frameworks. Surely, there will be new challenges as well.

I hope you enjoyed the article.

Maybe it is overly simplistic, but I hope it emphasis at all points on which I intended. I have tried to make it like a retrospective of how the development was years ago, what changed then, and what changed after that. Of course, there are some things which were missed, some things were not considered in great details, but it is what it is. I have checked typos (code or functional) and I hope there are none which are considerable. I hope someone finds this article interesting.

History

  • 20th May, 2019: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)