Sunday, March 21, 2021

Get DataGridView Current Cell Value Using BindingSource

A couple of months ago, I ran into a post on the Forums about how to get the value of the currently selected cell of a DataGridView, from the grid's BindingSource. The person asking the question didn't specify what kind of object was specified as the BindingSource's DataSource.

Since I work a lot with DataSets/DataTables, I assumed he was binding his DataGridView to a DataTable, and provided him with this answer to his question:

// To get the PropertyName of the data in the current cell of the DataGridView
string colName = this.dataGridView1.CurrentCell.OwningColumn.DataPropertyName;

// Use an object for the value of the DataRow column
object colValue = null;

// Now, let's find the value that's in that cell, where this.bs is the BindingSource:
if (this.bs.DataSource is DataTable)
colValue = ((DataTable)this.bs.DataSource).Rows[this.bs.Position][colName];
else if (this.bs.DataSource is DataSet)
{
DataTable dt = ((DataSet)this.bs.DataSource).Tables[this.bs.DataMember];
colValue = dt.Rows[this.bs.Position][colName];
}

You can find the Type of the bound column by using this line of code if you need to know that as well:

Type colType = this.dataGridView1.CurrentCell.OwningColumn.ValueType;

The nice thing about using DataTables for databinding grids with a BindingSource, is that it is automatically two-way databinding. If you make a change in the grid's cell, it populates the column in the row of the DataTable. Likewise, if the underlying data in the DataTable is changed programmatically, it will immediately show in the grid's cell.

OK, so what about other kinds of DataSources? Let's take a look:

BindingList

If you have a List of a particular class, say a Person class, you'll want to use a BindingList for your DataGridView's DataSource. In order to be sure that your Person class will support two-way databinding, your class will need to implement the INotifyPropertyChanged interface. Here is a sample Person class with the interface implemented (be sure to add a "using System.Runtime.CompilerServices" directive ... it is needed for the INotifyPropertyChanged implementation) :

private class Person : IPerson, INotifyPropertyChanged
{
// Fields and Properties
private string m_FirstName = "";
private string m_LastName = "";

public string FirstName
{ get { return this.m_FirstName; }
set
{
if (value != this.m_FirstName)
{
this.m_FirstName = value;
this.NotifyPropertyChanged();
}
}
}
public string LastName
{ get { return this.m_LastName; }
set
{
if (value != this.m_LastName)
{
this.m_LastName = value;
this.NotifyPropertyChanged();
}
}
}

// Constructors
public Person()
{
FirstName = "";
LastName = "";
}
public Person(string first, string last)
{
FirstName = first;
LastName = last;
}


// PropertyChanged stuff
public event PropertyChangedEventHandler PropertyChanged;
private void NotifyPropertyChanged([CallerMemberName] String propertyName = "")
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}

Next, I populated a List<Person> for this example:

//populate a List<Person> for testing
List<Person> listPerson = new List<Person>();
Person pers;
for (int i = 0; i < 5; i++)
{
pers = new Person();
pers.FirstName = i.ToString();
pers.LastName = "L" + i.ToString();
listPerson.Add(pers);
}

And last, create the binding stuff:

//create a BindingList and a BindingSource and set the grid's DataSource to the BindingSource
BindingList<Person> bindingList = new BindingList<Person>(listPerson);
BindingSource bsB = new BindingSource();
bsB.DataSource = bindingList;
this.dataGridView1.DataSource = bsB;

Finding the value in that cell using only the colName and BindingSource will be a little trickier than it was for a DataTable. But we can make use of the PropertyInfo class and reflection to get that very easily.

// First, Get the PropertyName of the current cell of the DataGridView (same as before)
string colName = this.dataGridView1.CurrentCell.OwningColumn.DataPropertyName;

// And find the value that is in that cell, using the BindingSource
BindingSource bs = (BindingSource)this.dataGridView1.DataSource;
BindingList<Person> bl = (BindingList<Person>)bs.DataSource;

// PropertyInfo and reflection
PropertyInfo pi = bl[bs.Position].GetType().GetProperty(colName);
object colValue = pi.GetAccessors()[0].Invoke(bl[bs.Position], null);

Before I go any further, you're probably wondering if it's necessary to use a BindingSource *and* a BindingList. Well, not really. If you use the BindingList as the grid's DataSource directly, everything works fine (meaning, you will have two-way databinding), but if you don't use a BindingSource, you don't have the benefit of knowing what position (row) contains the CurrentCell. That's not a problem if you don't need it, you can use the CurrentCell.RowIndex for that. And, for that matter, you can use the CurrentCell.Value and not have to worry about any of this!! However, for the question I was answering on the forums, the requirement was to find the value using the BindingSource. So, there you go.  ;0)

Here is a blog post I wrote about 5 years ago explaining the reflection methodology:

https://geek-goddess-bonnie.blogspot.com/2016/07/pemstatus-more-with-reflection-in-net.html

Taking an example from that blog post, let's use a method to make this easier. Here's the method:

public object GetPropertyValue(object o, string name)
{
PropertyInfo pi = o.GetType().GetProperty(name);
if (pi != null)
return pi.GetAccessors()[0].Invoke(o, null);
else
return null;
}

Use it like this:

string colName = this.dataGridView1.CurrentCell.OwningColumn.DataPropertyName;
BindingSource bs = (BindingSource)this.dataGridView1.DataSource;
BindingList<Person> bl = (BindingList<Person>)bs.DataSource;

object colValue = GetPropertyValue(bl[bs.Position], colName);

How about we do something even cooler! Let's make a generic method that will do all the heavy lifting. At first, I thought that I'd have to use a method signature with generics, like this (where, in this case, T would be Person when the method is called):

public object GetValueFromBindingSource<T>(BindingSource bs, string propertyName, T listClass)

But, as it turned out, that's not necessary. We can cast the bs.DataSource to an IBindingList and it all works perfectly. So <T> generic methods are not necessary! We get this instead:

public object GetValueFromBindingSource(BindingSource bs, string propertyName)
{
object objValue = null;
if (bs.DataSource is DataTable)
objValue = ((DataTable)bs.DataSource).Rows[bs.Position][propertyName];
else if (bs.DataSource is DataSet)
{
DataTable dtNW = ((DataSet)bs.DataSource).Tables[bs.DataMember];
objValue = dtNW.Rows[bs.Position][propertyName];
}
else if (bs.DataSource is IBindingList)
{
IBindingList bl = (IBindingList)bs.DataSource;
objValue = GetPropertyValue(bl[bs.Position], propertyName);
}
return currValue;
}


The above method will work for any DataGridView.DataSource, whether it's a DataSet, DataTable or any kind of BindingList.. And, you'd call it like this:

string colName = this.dataGridView1.CurrentCell.OwningColumn.DataPropertyName;
BindingSource bs = (BindingSource)this.dataGridView1.DataSource;

object colValue = GetValueFromBindingSource(bs, colName);

ObservableCollection

One more thing worth mentioning is that the BindingList can also be used with an ObservableCollection. In fact, you *have* to use a BindingList for your ObservableCollection,  because otherwise you only get one-way databinding (grid-to-collection). The reason that the ObservableCollection doesn't work two-way as a BindingSource is because it doesn't implement the INotifyPropertyChanged interface, only the INotifyChanged, which doesn't help in this situation. But, stick it in a BindingList and that'll work fine (the syntax is the same as above):

ObservableCollection<Person> collPerson = new ObservableCollection<Person>();
// Fill your collection, I won't bother with coding that
// Then set your BindingList. As you can see, it's the same syntax as adding the listPerson:
BindingList<Person> bl = new BindingList<Person>(collPerson);

That's it for now. Happy Coding!  =0)

Saturday, January 30, 2021

Wayback Machine

You may be familiar with the Wayback Machine from old "Mr. Peabody and Sherman" segments on the "Rocky and Bullwinkle" cartoon series (1959 to 1964). If not, I just discovered that there was an animated "Mr. Peabody and Sherman" movie in 2014 and another animated TV series in 2015. If you're not familiar with any of this, the Wayback Machine is basically a time machine that Mr. Peabody (who is a very smart dog) invented.

Now, what does this have to do with software development? Well, nothing specifically, unless you are trying to find a website that you *knew* existed once upon a time, and you even have a link for it ... but it's either gone or hijacked or, even worse, has been taken over by malware! That is what happened to a link I had used on a blog post in October (see https://geek-goddess-bonnie.blogspot.com/2020/10/configure-msdtc-for-distributed.html ) and I had to scramble to be able to provide the information to write that blog post. 

At the time I wrote that post, I had totally forgotten about using the web version (as opposed to the cartoon version) of the Wayback Machine (https://web.archive.org/ ). I recently searched for the link that I had wanted to use in that October post and found many versions of it, because the web archive crawls many websites and takes snapshots of them at intervals, so one site can have many entries. Case in point, the blog post entry I was interested in was crawled 105 times between May 2015 and February 2020 (sometime after that point in time, it was compromised).

Anyway, here's a link to the archived post (and, I just updated my old October blog post with this link as well):

Have fun exploring the Wayback Machine!  =0) 

Sunday, December 27, 2020

Gotcha With Spaces In Path or Filename

I came across an interesting problem on the Forums today. The guy was having problems with files with spaces in the name. He was trying to run a Process (using the System.Diagnostics.Process class), but it wouldn't recognize the file as existing.

For example, take a look at this (my version of his problem):

string path = @"D:\Downloads\WCF Examples\WF_WCF_Samples\";
string name = "
MSDN Readmes.txt";

if (File.Exists(path + name))
{
var process = new Process();
process.StartInfo.FileName = "
Notepad++.exe";
process.StartInfo.Arguments = path + name;
process.Start();
}

The path + name is valid (and File.Exists() returns true), but when trying to Start the Notepad++ app, it can't find this file!!  It appears to be because of the spaces (but there is more to it than that, I'll explain as we go along).

The problem doesn't occur for all applications. When I tried Notepad instead of Notepad++, it worked! So, you might think, aha! it has to be a Microsoft app instead of a 3rd party app! And, you would be wrong in thinking that, because the same problem occurs with Word or Excel. Back to the drawing board!

Hmmm, maybe we should use Path.Combine() to be absolutely sure that we've got a valid file. So, let's try this:

 

process.StartInfo.Arguments = Path.Combine(path, name);

Note that in the above code, path + name is valid, but to be absolutely sure, you should always use the Path.Combine() method. For example, if your path does not have a trailing backslash (\), then path + name will give you this:  D:\MyPathMyFile.txt, but Path.Combine(path, name) correctly puts the backslash in there (but only when needed) and you will get this:  D:\MyPath\MyFile.txt

It was suggested that perhaps it's a C drive thing vs any other drive. Nope, I got the same behavior trying to Start Word with a path/name with spaces on the C drive.

So, what's going on? Turns out that to be sure it works every time, a path and filename with spaces in it should be enclosed with quotes. That will take care of it for any application!!

So, let's create a helper method and use it like this:


process.StartInfo.Arguments = AddQuotesForWhiteSpace(Path.Combine(path, name));

// And here's the helper method
public string AddQuotesForWhiteSpace(string path)
{
return !string.IsNullOrWhiteSpace(path) ?
path.Contains(" ") && !path.StartsWith("\"") && !path.EndsWith("\"") ?
"\"" + path + "\"" : path :
string.Empty;
}

For readability, you could change it to the following (it does the same thing, so it's up to you as to whether you prefer brevity or readability).

public string AddQuotesForWhiteSpace(string path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;

if (path.Contains(" ") && !path.StartsWith("\"") && !path.EndsWith("\""))
return "\"" + path + "\"";
else
return path;
}

Note that the above methods simply put quotes around the entire parameter that's passed to it (but only if there are any spaces in there) and then the process.Start() method is happy!

Happy Coding and Happy Holidays!

Friday, October 16, 2020

Use MSMQ For Logging Errors And Informational Items

I was involved in writing a Windows Service application several years ago, and one of the requirements was to have a methodology for logging error messages and informational messages. For various reasons (which I won't go into in this blog post, as they are not relevant to the topic), we didn't want to write to the Windows Event Log. Instead, we opted to write such messages into a SQL Server database table.

We needed "must deliver" capabilities for these messages ... by that I mean that if something went wrong with the application, the messages still needed to be able to be "delivered" to the database, which we accomplished by Transactions and Message Queuing (MSMQ). By making the MSMQ queue "transactional", messages would remain in the queue until able to be delivered to the database.

There are two parts to this process.

  • First, there's a class I'll call OutputLog, containing static properties and methods. This class allows you to easily send a message (composed of a string of information and an optional category) to a queue (MSMQ) by calling a static method, WriteLog("your message") from any place in your application.

  • Second, there's a class I'll call ProcessLog, which is a continuously running Thread that reads from the MSMQ queue and writes the message to a database. In the examples I'll show, it's simply doing a Console.WriteLine() rather than messing with a database (I've shown commented-out code for the database parts).

I have put all of this example into a solution that you can download and run to see it in action. You can download it here: 

We needed "must deliver" capabilities for these messages ... by that I mean that if something went wrong with the application, the messages still needed to be able to be "delivered" to the database, which we accomplished by Transactions and Message Queuing (MSMQ). By making the MSMQ queue "transactional", messages would remain in the queue until able to be delivered to the database.

There are two parts to this process.

  • First, there's a class I'll call OutputLog, containing static properties and methods. This class allows you to easily send a message (composed of a string of information and an optional category) to a queue (MSMQ) by calling a static method, WriteLog("your message") from any place in your application.

  • Second, there's a class I'll call ProcessLog, which is a continuously running Thread that reads from the MSMQ queue and writes the message to a database. In the examples I'll show, it's simply doing a Console.WriteLine() rather than messing with a database (I've shown commented-out code for the database parts).

I have put all of this example into a solution that you can download and run to see it in action. You can download it here: 

We needed "must deliver" capabilities for these messages ... by that I mean that if something went wrong with the application, the messages still needed to be able to be "delivered" to the database, which we accomplished by Transactions and Message Queuing (MSMQ). By making the MSMQ queue "transactional", messages would remain in the queue until able to be delivered to the database.

There are two parts to this process.

  • First, there's a class I'll call OutputLog, containing static properties and methods. This class allows you to easily send a message (composed of a string of information and an optional category) to a queue (MSMQ) by calling a static method, WriteLog("your message") from any place in your application.

  • Second, there's a class I'll call ProcessLog, which is a continuously running Thread that reads from the MSMQ queue and writes the message to a database. In the examples I'll show, it's simply doing a Console.WriteLine() rather than messing with a database (I've shown commented-out code for the database parts).

I have put all of this example into a solution that you can download and run to see it in action. You can download it here:

ConsoleMSMQLogging 

The code in the downloaded project has comments, that are hopefully self-explanatory. But I'm going to show bits and pieces of it here as well. So now, let's see some code.

First, the OutputLog class. You can see from this code that everything is static.

 

// For MSMQ, be sure to add: using System.Messaging;
// You may also have to add it to your Project's references, it's not one of the normal references
//
// For the DataContract (in DataLog class), add: using System.Runtime.Serialization;
// Make sure to add it to your Project's references also.
public class OutputLog
{
#region Declarations/Properties

// The MSMQ name *must* start with ".\\private$\\"
protected static string m_QueueName = ".\\private$\\MyApplication.Shared.OutputLog";
public static string QueueName
{
get { return m_QueueName; }
// If you want a different queue name for different purposes
set { m_QueueName = m_QueueName.Replace("Shared", value); }
}
// Since messages are written to the database, we must have the Connection String set
// So, this is a good spot to set up the Message Queue while we're at it
protected static string m_LoggingConnectionString;
public static string LoggingConnectionString
{
get { return m_LoggingConnectionString; }
set
{
m_LoggingConnectionString = value;
SetupQueue(QueueName);
}
}
public static MessageQueue OutputQueue { get; set; }

// private
private static object lockobject = new object();

#endregion

#region Methods

//public
public static void WriteLog(string message)
{
Send(new DataLog(DateTime.Now, message));
}
public static void WriteLog(string message, string category)
{
Send(new DataLog(DateTime.Now, message, category));
}

//protected
protected static void Send(DataLog data)
{
System.Messaging.Message msg = new Message(data);
Send(msg);
}
protected static void Send(System.Messaging.Message msg)
{
if (OutputQueue == null)
return;
lock (lockobject)
{
using (MessageQueueTransaction tx = new MessageQueueTransaction())
{
tx.Begin();
try
{
OutputQueue.Send(msg, tx);
tx.Commit();
}
catch (Exception ex)
{
tx.Abort();
}
}
}
}
protected static void SetupQueue(string QueueName)
{
if (!MessageQueue.Exists(QueueName))
{
// The true parameter makes it transactional
MessageQueue.Create(QueueName, true);
}
OutputQueue = new MessageQueue("FormatName:DIRECT=OS:" + QueueName);
OutputQueue.Formatter = new XmlMessageFormatter(new Type[] { typeof(DataLog) });
OutputQueue.MessageReadPropertyFilter = SettingsForQueue.SetPropertyFilter();
}
#endregion
}

Two other classes are needed by the OutputLog class (and also needed by the ProcessLog class, which I'll show after these two): the DataLog class (which is a serializable object that contains the data that gets written to the database), and a SettingsForQueue class (which sets a MSMQ MessagePropertyFilter for the Queue you are creating):

[DataContract(Namespace = "http://MyCompanyName")]
[Serializable]
public class DataLog
{
private static object lockobject = new object();
[DataMember]
public DateTime LogTime { get; set; }
[DataMember]
public string Message { get; set; }
[DataMember]
public string Category { get; set; }

public DataLog() { }
public DataLog(DateTime time, string message)
{
lock (lockobject)
{
LogTime = time;
Message = message;
Category = "";
}
}
public DataLog(DateTime time, string message, string category)
{
lock (lockobject)
{
LogTime = time;
Message = message;
Category = category;
}
}
}
public class SettingsForQueue
{
public static MessagePropertyFilter SetPropertyFilter()
{
MessagePropertyFilter filter = new MessagePropertyFilter();
filter.SetDefaults();
filter.AppSpecific = true;
filter.Label = true;
filter.Priority = true;
filter.Extension = true;
filter.SentTime = true;
filter.ArrivedTime = true;
filter.Id = true;
filter.CorrelationId = true;
filter.SourceMachine = true;
return filter;
}
}

And now, the ProcessLog class:

public class ProcessLog
{
private Thread ProcessThread;
private bool EndLoop = false;
private bool IsStarted = false;
private MessageQueue ProcessQueue;
private string QueueName;
private TimeSpan TimeoutInterval = TimeSpan.FromMilliseconds(1000);

public ProcessLog()
{
ThreadStart threadStart = new ThreadStart(RunThread);
this.ProcessThread = new Thread(threadStart);
}

// Public Methods
public virtual void Start()
{
if (!this.IsStarted)
{
QueueName = OutputLog.QueueName;
if (!MessageQueue.Exists(QueueName))
{
MessageQueue.Create(QueueName, true);
}
ProcessQueue = new MessageQueue("FormatName:DIRECT=OS:" + QueueName);
ProcessQueue.Formatter = new XmlMessageFormatter(new Type[] { typeof(DataLog) });
ProcessQueue.MessageReadPropertyFilter = SettingsForQueue.SetPropertyFilter();
this.ProcessThread.Start();
}
this.IsStarted = true;
}
public virtual void Stop()
{
this.EndLoop = true;
this.IsStarted = false;
}

// Private Methods
private void RunThread()
{
while (!this.EndLoop)
{
Thread.Sleep(500); // sleep 500 milliseconds, so the CPU doesn't churn constantly

// And now we'll do the work
try
{
// see my blog post about creating a Utility class to make this less messy:
// http://geek-goddess-bonnie.blogspot.com/2010/12/transactionscope-and-sqlserver.html
// If you already have a Utility class, add a static GetTransactionScope() method with these settings:
// (TransactionScopeOption.Required, new TransactionOptions() { IsolationLevel = IsolationLevel.ReadCommitted }))
using (TransactionScope scope = new TransactionScope()) // you really want to use some options, like above
{
try
{
System.Messaging.Message msg = ProcessQueue.Receive(TimeoutInterval, MessageQueueTransactionType.Automatic);
DataLog data = (DataLog)msg.Body;
if (this.Save(data))
scope.Complete();
else
{
Thread.Sleep(1000);
}
}
catch (MessageQueueException ex)
{
if (ex.MessageQueueErrorCode != MessageQueueErrorCode.IOTimeout)
Console.WriteLine(ex.MessageQueueErrorCode + ": " + ex.Message);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
private bool Save(DataLog data)
{
// For the purposes of illustrating how this works, I'll *not* actually write to a database,
// but the database code is here, commented, if you want to use it

Console.WriteLine($"{data.Message}");
if (string.IsNullOrWhiteSpace(data.Category))
Console.WriteLine($"{data.LogTime}");
else
Console.WriteLine($"{data.LogTime} \t {data.Category}");
return true;

//try
//{
// using (SqlConnection Connection = new SqlConnection(LogOutput.TrackingConnectionString))
// {
// SqlCommand Command = new SqlCommand();
// Command.Connection = Connection;
// Command.CommandType = CommandType.StoredProcedure;
// Command.CommandText = "usp_logdataput"; // write your own Stored Proc or simply use an INSERT INTO with parameters
// Command.Parameters.AddWithValue("@logdatetime", data.LogTime);
// Command.Parameters.AddWithValue("@message", data.Message);
// Command.Parameters.AddWithValue("@category", data.Category);
// Command.Parameters.AddWithValue("@machine", Environment.MachineName);
// Connection.Open();
// Command.ExecuteNonQuery();
// return true;
// }
//}
//catch (Exception ex)
//{
// Console.WriteLine(ex.Message);
// Thread.Sleep(30000);
// return false;
//}
}
}

OK, so now how do run this process? As mentioned in the comments in the code for the ProcessLog class, I'm not going to actually write to the database in this example. However, if you look at the OutputLog class, you'll see that the LoggingConnectionString property needs to be set, since it's in that property's setter that the Queue is created and configured. So, here's how we get the thing started:


OutputLog.LoggingConnectionString = "";

// Again, for illustration purposes, I will simply be spinning off a thread, that will always be running
// The thread's purpose is to simply read data off the Queue and write the data to the database
// In reality, my own Windows Service application has many "processor" threads that process data coming from many places

ProcessLog processLog = new ProcessLog();
processLog.Start();

// OK, now that the thread has started, we've got to give it something to do.
// Let's test it by logging some messages
for (int i = 0; i < 100; i++)
{
OutputLog.WriteLog("This is Message #" + i);
}

 

Those messages are getting written to the MSMQ queue by the OutputLog class.

Then the processLog thread that we just started pops them off the queue and displays them in the Console window.

 

// Hit Any Key to stop, after you've seen all the messages displayed
Console.ReadKey();
processLog.Stop();

You can take a look and see them in the MSMQ queue in Message Queuing | Private Queues from the Computer Management application (run compmgmt from another command window, or a right-click on the Start, which should have it). If you look before they are all processed, you'll see how many remain in the queue (and you can refresh the queue, to see them disappearing as they are popped off), as you can see from this screenshot:

You can also see how they stay in the queue waiting to be delivered if you stop the process before they are all displayed! Of course, after you restart it, you'll get 100 more messages dumped in the queue. Do it enough times, without letting them all get processed, and you'll start to have a lot of messages in that queue. Not a problem, you can simply right-click on the "Queue messages", under your Queue and choose "All Tasks | Purge", as shown in this screenshot:

Even though this thread is running constantly in a while loop, you can see from the screenshot below, that it's barely taking up any CPU cycles (less than 1% ... it varied, but never much more than what you see here). This is because we sleep the thread for 500 milliseconds in between each loop iteration. It doesn't have to be 500 milliseconds ... longer or shorter is fine ... but you *do* need to have a brief sleep, otherwise CPU usage would shoot up and you'd also have a tough time stopping the loop gracefully.

There is one more topic to cover and that is Distributed Transactions. Both MSMQ and SQL Server are transactional in this scenario, and consequently once you actually throw SQL Server into the mix (which you can see, I've skillfully avoided in this example), the Transactions get elevated to distributed transactions and you can run into DTC issues, unless you've already configured DTC (Distributed Transaction Controller) to properly allow for distributed transactions. I just so happen to have a blog post about this, which should be useful:

https://geek-goddess-bonnie.blogspot.com/2020/10/configure-msdtc-for-distributed.html

Happy Coding!!   =0)

Saturday, October 10, 2020

Configure MSDTC for Distributed Transactions

Many years ago, I needed to figure out how to enable and configure MSDTC (Distributed Transaction Coordinator). I had Googled, but almost everything I found seemed to leave out some of the necessary (and important) steps.

That is, until I came upon a blog by a woman named Irina Tudose, who went by "yrushka" on several technical forums. She did *not* leave out any steps! I included a link to her blog post in one of my own blog posts, because it made sense for me to not "reinvent the wheel" when she had already done it so well!  I have even given out the URL to her blog post to other people that I've worked with when they needed to set up MSDTC. I'd really like to thank her!!

But today, while I was in the midst of writing a different blog post (which will be published soon), I realized that I needed again to include a link to Irina's blog. Unfortunately, it is gone (or hijacked) and the URL gets redirected to another other site (that MalwareBytes tells me is fraudulent).

Luckily, a little over 2 years ago, I had used EverNote to "clip" the entire page about DTC (and not clip the link) for just this reason! In case Irina's blog ever disappeared. I hope that she doesn't mind if I recreate it here (and, Irina, if you happen to find *my* blog, please let me know if you have moved your site elsewhere)!

UPDATE (01/30/2021): I recently remembered the Wayback Machine, and looked up Irina's post there. And I found it. You can look at the original from Irina, instead of continuing with mine, if you prefer (or heck, read them both)!!  
https://web.archive.org/web/20160615025531/http://yrushka.com/index.php/sql-server/security/configure-msdtc-for-distributed-transactions/

Here is Irina's post, in its entirety:

Few days ago, inside a Software project, I had to enable and configure MSDTC (Distributed Transaction Coordinator) between 2 machines: a Web Server using NServiceBus to process messages in a transaction and a SQL Server.
I encountered some issues and I would like to detail the order of few configuration steps as well as testing methods that should be done/checked before MSDTC in fully functional.
 
Step 1: Network connectivity

Make sure both servers can ping each other by the machine name, cause MSDTC uses netBIOS to establish a connection.
Start a command prompt window and execute on both servers:
ping [SERVER_NAME1]
ping [SERVER_NAME2]

Step 2: Enable MSDTC on both servers.

There are pictures for Windows 2008 Server and Windows 2003 Server because MSDTC location is changed from one WIndows edition to another. The options to check are the same though.
  1. Open Component Services:
  2. Component Services
  3. Access DTC Properties
  4. Windows 2003 - MSDTC location
    Windows 2003 - MSDTC location
    Windows 2003 - MSDTC location
    Windows 2008 - MSDTC location
    Windows 2008 - MSDTC location
  5. Enable MSDTC according to below options selected.
    Check only the red check boxes and click Apply.
  6. Windows 2008 - MSDTC properties
     
     
     
     
     
     
     
     
     
     
     
     
    A warning message will be popped in to inform that the Distribution Transaction Coordinator Windows service will be started (or restarted).
    MSDTC restart
  7. Set the startup type of Distribution Transaction Coordinator Windows service to Automatic.
If you don’t have Firewalls that prohibit external access to machine’s ports than you can stop here with the MSDTC configuration.
MSDTC will function great with basic configuration (Step 1 & Step 2) done only when there is no Firewall involved in network external communication. What do you do when there is a Firewall afterwards? In most Network configurations it is mandatory for securing the external access. If you follow all the steps detailed below, you should be able to make the MSDTC work without problems. Take a look at next steps.
Step 3: Restrict MSRPC dynamic port allocation.
 
MSDTC service depends on RPC protocol which stands in front of every Windows process that uses RPC. When you deal with MSDTC, you also have to consider the RPC access points. When MSRPC protocol is left with its default setting, all depending services including MSDTC is free to use a dynamically allocated port in the port range 1024-65535 . Basically, it means that each re-start of MSDTC will result in a different port number. Fortunately you can restrict this port range which means that, instead of creating a rule in Firewall that opens all the ports from 1024 – 65535 you only need to insert the range of ports restricted in RPC setting.

There is one thing to be considered though :
 
There can be up to 100 services that depend on RPC and will be affected by this change. Make it not too small… not to big. Doing a little reading on the internet I saw that 50 – 100 ports would be a minimum – maximum for RPC depending services to function, but again it depends on each machine and how many processes depend on RPC. If you want to find out which are these look at RPC service at Dependencies tab and count the active ones.
RPC_Service
Perform these steps on both machines in order to configure a different port range. The port range does not have to be the same on both machines.
  1. Open Component Services properties windows
  2. Component Services Properties
  3. Access Default Protocols tab and insert a new port range.
  4. Change Port Range
Next, in order to be able to start a Distributed Transaction through MSDTC service – both participating servers must be configured to trust each other by allowing access to the each other’s port used by MSDTC service.

Step 4: Add Firewall Inbound rules
 
on SERVER 1: to allow inbound requests to the port range configured.
on SERVER 2: to allow inbound requests to the port range configured.
This will enable the communication between SERVER 1 and SERVER 2 through MSDTC service.
SERVER 1 will be able to access MSDTC allocated port from SERVER 2 and SERVER 2 will be able to access MSDTC allocated port from SERVER1.

Step 5: Restart both machines
 
You need to restart both machines where the port range was changed in order for these modifications to take effect.
Step 6: Testing MSDTC connectivity
 
After you had done all of the above, you might want to test first if a Distributed Transaction can be initialized and committed. There are 2 important Microsoft troubleshooting tools that I used and worked for me: DTCping and DTCtester. I will not detail the testing steps because all of them are covered in this post at step 2 and step 4.