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):
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:
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:
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).
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):
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:
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
- We are going to create a folder on D: drive called devdemos and in this folder, a folder called dotnet.
- Then, we start Visual Studio 2019 and from dialog window, we select “Create a new project”.
- We scroll down and select Windows Forms App (.NET Framework) and click Next.
- 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
. - 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.
- 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.
- 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
- 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.
- 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 Add → New 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:
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.
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;
}
}
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:
- We add a reference to Business library by right-click References and then from Project tab, we check Business.
- We need to install Newtonsoft.Json nuget for this project too.
- Then we select the form file, Main.cs, so that our active window is Main.cs [Design].
- 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. - 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 - 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.
- 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. - We do the actions described in the previous step, the only difference this time being naming binding source
bindingSourceHistory
. We save. - 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.
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
.
We confirm with OK. We save.
- We do the actions described in the previous step, with the following differences:
- we bind to
bindingSourceHistory
- (Name) is
dvgRight
- Location is
509, 118
- 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
- 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
- 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. -
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):
- 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.
- We add an event handler for the
frmMain
FormClosing
event and for the SizeChanged
- 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:
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:
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
.
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)
{
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);
bindingSourceCurrent.DataSource = currentWords.Values.Where
(x => x.Show.Equals(true)).OrderByDescending(x => x.Count).Take(100).ToList();
ColorListViews();
}
private void UpdateData()
{
wp.UpdateShowForHistory((IList<Word>)bindingSourceHistory.List);
foreach (Word word in bindingSourceCurrent.List)
{
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:
bindingSourceCurrent.DataSource = null;
bindingSourceHistory.DataSource = null;
break;
case 2:
bindingSourceCurrent.DataSource = null;
break;
case 1:
bindingSourceHistory.DataSource = null;
break;
case 0:
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.
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:
- 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.
- 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.
- In order to get an updated client version, we need to provide users with recompiled application.
- 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. - Our custom resizing method need change every time we add or remove control from the panel.
- 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.
- 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):
-
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:
- 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 enum
s.
and here is WordViewModel
, placed in View Models folder.
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:
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;
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)
{
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*.
Resources and interactivity triggers are collapsed so that the first screen-shot fits on one screen. You can find them expanded below:
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.
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.
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
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
.
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:
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:
And now, POST to http://localhost:3010/history. This call is not expected to run response data, only successful status of 200.
Let's make another one. This time, the response time on server should be less than a millisecond.
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:
And those are the times at server side. Compare the times for a first time call and subsequent call.
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. :)
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.
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.
We are going to add an enum
called HttpVerbs
. Here is it:
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.
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:
- 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:
- 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.
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:
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:
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.
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:
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.
<!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">
<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">
<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. div
s 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.
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){
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
})
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.
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.
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