Sie sind auf Seite 1von 447

.

NET Programming 10-Minute Solutions

.NET Programming 10-Minute Solutions


A. Russell Jones Mike Gunderloy

San Francisco London

Associate Publisher: Joel Fugazzotto Acquisitions and Developmental Editor: Tom Cirtin Production Editor: Leslie E.H. Light Technical Editor: Greg Guntle Copyeditor: Liz Welch Compositor: Chris Gillespie, Happenstance Type-O-Rama Graphic Illustrator: Jeff Wilson, Happenstance Type-O-Rama Proofreader: Nancy Riddiough Indexer: Lynnzee Elze Book Designer: Maureen Forys, Happenstance Type-O-Rama Cover Illustrator/Photographer: John Nedwidek, Emdesign Copyright 2004 SYBEX Inc., 1151 Marina Village Parkway, Alameda, CA 94501. World rights reserved. The author(s) created reusable code in this publication expressly for reuse by readers. Sybex grants readers limited permission to reuse the code found in this publication or its accompanying CD-ROM so long as the author(s) are attributed in any application containing the reusable code and the code itself is never distributed, posted online by electronic transmission, sold, or commercially exploited as a stand-alone product. Aside from this specific exception concerning reusable code, No part of this publication may be stored in a retrieval system, transmitted, or reproduced in any way, including but not limited to photocopy, photograph, magnetic, or other record, without the prior agreement and written permission of the publisher. Library of Congress Card Number: 2003109131 ISBN: 0-7821-4253-2 SYBEX and the SYBEX logo are either registered trademarks or trademarks of SYBEX Inc. in the United States and/or other countries. DevX, 10-Minute Solutions, and the 10-Minute Solutions logo are the exclusive trademarks of JupiterMedia Corporation and are used under license. Screen reproductions produced with FullShot 99. FullShot 99 1991-1999 Inbit Incorporated. All rights reserved. FullShot is a trademark of Inbit Incorporated. TRADEMARKS: SYBEX has attempted throughout this book to distinguish proprietary trademarks from descriptive terms by following the capitalization style used by the manufacturer. The author and publisher have made their best efforts to prepare this book, and the content is based upon final release software whenever possible. Portions of the manuscript may be based upon pre-release versions supplied by software manufacturer(s). The author and the publisher make no representation or warranties of any kind with regard to the completeness or accuracy of the contents herein and accept no liability of any kind including but not limited to performance, merchantability, fitness for any particular purpose, or any losses or damages of any kind caused or alleged to be caused directly or indirectly from this book. Manufactured in the United States of America 10 9 8 7 6 5 4 3 2 1

SOFTWARE LICENSE AGREEMENT: TERMS AND CONDITIONS


The media and/or any online materials accompanying this book that are available now or in the future contain programs and/or text files (the Software) to be used in connection with the book. SYBEX hereby grants to you a license to use the Software, subject to the terms that follow. Your purchase, acceptance, or use of the Software will constitute your acceptance of such terms. The Software compilation is the property of SYBEX unless otherwise indicated and is protected by copyright to SYBEX or other copyright owner(s) as indicated in the media files (the Owner(s)). You are hereby granted a single-user license to use the Software for your personal, noncommercial use only. You may not reproduce, sell, distribute, publish, circulate, or commercially exploit the Software, or any portion thereof, without the written consent of SYBEX and the specific copyright owner(s) of any component software included on this media. In the event that the Software or components include specific license requirements or end-user agreements, statements of condition, disclaimers, limitations or warranties (End-User License), those End-User Licenses supersede the terms and conditions herein as to that particular Software component. Your purchase, acceptance, or use of the Software will constitute your acceptance of such End-User Licenses. By purchase, use or acceptance of the Software you further agree to comply with all export laws and regulations of the United States as such laws and regulations may exist from time to time.

Warranty
SYBEX warrants the enclosed media to be free of physical defects for a period of ninety (90) days after purchase. The Software is not available from SYBEX in any other form or media than that enclosed herein or posted to www.sybex.com. If you discover a defect in the media during this warranty period, you may obtain a replacement of identical format at no charge by sending the defective media, postage prepaid, with proof of purchase to: SYBEX Inc. Product Support Department 1151 Marina Village Parkway Alameda, CA 94501 Web: http://www.sybex.com After the 90-day period, you can obtain replacement media of identical format by sending us the defective disk, proof of purchase, and a check or money order for $10, payable to SYBEX.

Disclaimer
SYBEX makes no warranty or representation, either expressed or implied, with respect to the Software or its contents, quality, performance, merchantability, or fitness for a particular purpose. In no event will SYBEX, its distributors, or dealers be liable to you or any other party for direct, indirect, special, incidental, consequential, or other damages arising out of the use of or inability to use the Software or its contents even if advised of the possibility of such damage. In the event that the Software includes an online update feature, SYBEX further disclaims any obligation to provide this feature for any specific duration other than the initial posting. The exclusion of implied warranties is not permitted by some states. Therefore, the above exclusion may not apply to you. This warranty provides you with specific legal rights; there may be other rights that you may have that vary from state to state. The pricing of the book with the Software by SYBEX reflects the allocation of risk and limitations on liability contained in this agreement of Terms and Conditions.

Reusable Code in This Book


The authors created reusable code in this publication expressly for reuse by readers. Sybex grants readers limited permission to reuse the code found in this publication, its accompanying CD-ROM or available for download from our website so long as the authors are attributed in any application containing the reusable code and the code itself is never distributed, posted online by electronic transmission, sold, or commercially exploited as a stand-alone product.

Shareware Distribution
This Software may contain various programs that are distributed as shareware. Copyright laws apply to both shareware and ordinary commercial software, and the copyright Owner(s) retains all rights. If you try a shareware program and continue using it, you are expected to register it. Individual programs differ on details of trial periods, registration, and payment. Please observe the requirements stated in appropriate files.

Software Support
Components of the supplemental Software and any offers associated with them may be supported by the specific Owner(s) of that material, but they are not supported by SYBEX. Information regarding any available support may be obtained from the Owner(s) using the information provided in the appropriate read.me files or listed elsewhere on the media. Should the manufacturer(s) or other Owner(s) cease to offer support or decline to honor any offer, SYBEX bears no responsibility. This notice concerning support for the Software is provided for your information only. SYBEX is not the agent or principal of the Owner(s), and SYBEX is in no way responsible for providing any support for the Software, nor is it liable or responsible for any support provided, or not provided, by the Owner(s).

Copy Protection
The Software in whole or in part may or may not be copy-protected or encrypted. However, in all cases, reselling or redistributing these files without authorization is expressly forbidden except as specifically provided for by the Owner(s) therein.

We dedicate this book to all the people who take the time to answer programming questions in newsgroups; who write documentation, technical articles, and books to help people learn; and who, collectively, act as resources to increase the level of expertise in the programming community. A. Russell Jones To the FlyBabies, who helped keep me sane. Mike Gunderloy

Acknowledgments
irst, I want to acknowledge DevX and Sybex, whose joint cooperation made this book possible. I think its often difficult for two separate publishing organizations to share content and production costs, so congratulations to everyone in both companies who brought this book concept to fruition. Thanks particularly to Tom Cirtin at Sybex, and to Michael (Mac) McCarthy, Lori Piquet, and Rachel Plut at DevX.

This book contains solutions by two authors whose names dont appear on the cover, so I want to acknowledge Evangelos Petroutsos and Ollie Cornes, whose articles have proven popular and helpful to so many people. Id like to personally thank my co-author Mike Gunderloy for being brave enough to get involved in the lengthy process of co-authoring a book, and my editors, Leslie H. Light and Liz Welch, who have borne the brunt of the burden for finding and fixing the prose. For the many other people who have a hand in taking this book from concept to publication, you have my sincere appreciation. Finally, but not least, Id like to thank my wife, Janet, for putting up with yet another book effort. Russell Jones After writing even a portion of a book, I find that the acknowledgements are a relief: theyre the chance to thank the people who have put up with being a part of the life of an author. So in that vein: thanks to Tom Cirtin and Russell Jones for bringing me into this project; its been a lot of fun. Thanks also to my colleagues at MCP Magazine, Hardcore Web Services, and elsewhere, who put up with me being occasionally preoccupied with book deadlines. And of course, thanks to my wonderful wife and two growing children, who give me all the reasons I could ever need to keep plugging away at a career in this nutty industry. Now I promise Ill make up for some of those late nights I spent writing. Mike Gunderloy

Contents

Introduction Windows Forms Solutions


Solution 1 Solution 2 Solution 3 Solution 4 ListBox ItemData Is Gone! Create Owner-Drawn ListBoxes and ComboBoxes Upgrade Your INI Files to XML Build Your Own XML-Enabled Windows Forms TreeView Control

xi 1
2 16 28 48

General .NET Topics


Solution 5 Solution 6 Solution 7 Solution 8 Solution 9 Solution 10 Solution 11 Solution 12 Solution 13 Solution 14 Solution 15 Solution 16 Take Advantage of Streams and Formatters in VB.NET File I/O in VB.NET: Avoid the Compatibility Syntax Gain Control of Regular Expressions Add Sort Capabilities to Your .NET Classes A Plethora of XML Choices Where Should I Store That Data? Performing the Most-Requested Conversions in .NET Building Custom Collections in .NET Launching and Monitoring External Programs from VB.NET Applications Build a Touch Utility with .NET Parse and Validate Command-Line Parameters with VB.NET Monitor Data and Files with a Windows Service

65
66 80 86 100 109 135 154 170 186 200 212 234

ASP.NET Solutions
Solution 17 Solution 18 Solution 19 Solution 20 Solution 21 Creating Custom Configuration Settings in ASP.NET Internationalize Your ASP.NET Applications (Part 1 of 2) Internationalize Your ASP.NET Applications (Part 2 of 2) Managing Focus in Web Forms The Missing Message Boxes in ASP.NET

253
254 265 281 294 306

Contents

ADO.NET Solutions
Solution 22 Solution 23 Solution 24 Solution 25 Solution 26 Solution 27 Solution 28 Solution 29 Solution 30 Solution 31 Solution 32 Optimizing and Troubleshooting Database Connections Replacing Recordsets with DataSets Working with Typed DataSets Saving Time with Calculated DataColumns Combining Tables in a DataSet Getting Customized XML from SQL Server XML and the DataSet DataBinding ListBoxes and ComboBoxes Advanced DataBinding Synchronizing DataSets with DiffGrams The 10-Minute Guide to Paging Data

325
326 336 347 353 360 369 380 388 396 403 412 423

Index

Introduction

his book grew out of an ongoing popular series of short, to-the-point technical articles on DevX.com (http://www.devx.com) called 10-Minute Solutions, which fuse the Ask the Expert-type question-and-answer format with the more comprehensive tutorial-type article. Melding the two provides authors not only with a forum for presenting solutions to common questions, but also the space to include full real-world code examples and in-depth explanations targeted directly toward giving readers an understanding of why the solution works. Our hope is that youll find many opportunities to apply the solutions we describe to your own work.

Choosing the Solutions


Each solution addresses what the author perceives as a common problem. In some cases, these solutions arose directly from questions posed in newsgroup posts, via e-mail, or from problems the authors have encountered and solved in their own work. In other cases, the situations are designed to mimic real-world problems. In a few cases, the solutions are explanatoryfor example, a brief tutorial designed to get you started using a .NET feature, such as regular expressions, or a solution that shows you how using the VB.NET syntax that emulates classic VBs file I/O syntax is not always your best option.

Language Choice: VB.NET vs. C#


Regardless of the underlying problem, each solution has accompanying working code that you can download, study, and alter for your own needs. In the book itself, youll find almost exclusively VB.NET code examples; however, weve provided downloadable code in both VB.NET and in C#. Thats because, in print, its clear that you either have to select a single language for code examples or double the size (and increase the cost) of the book by printing the examples in both languages. In addition, terminology differences between languages make it awkward to address people familiar with one or the other language equally using the same text; for example, in VB.NET, you test the absence of a variable assignment for Nothing, while in C#, the equivalent test is for null. Unfortunately, these problems are intractable. The choices are to irritate everyone by consistently writing something like: Test for Nothing (VB.NET) or null (C#); to publish two completely different versions of the book, one for each language; or to bite the bullet and choose to print one consistent language for the text and code examples in the book

xii

Introduction

but provide downloadable working code for both languages. We chose the third path. Because of the similarity between the two languages, we felt that C# developers would have no difficulty gleaning the intent from reading the VB.NET code, particularly if they can follow along by downloading and looking at the C# equivalent. In addition, this single-language, dual-code approach can help people trying to learn or move between the languages, because it provides equivalent solutions for both.

Who Should Read This Book?


This isnt a typical technical book, and it isnt suitable for everyone. First of all, it is not a .NET tutorial. Therefore, it is not aimed at complete beginners, and should not be read as such. Instead, its a resource containing approaches for solving common problems. Heres how to find out if this book will help you. Before you buy the book, look through the table of contents and evaluate the solution titles. If youve been programming in .NET for a while, were sure youll recognize a few problems that youve either already solved or are trying to solve. If youve already solved them, you may find tips and techniques in the solutions that can improveor validateyour own solutions. Heres the thing: If youve already had to solve some of the problems in the book, its highly likely that youll eventually have to solve some of the other problems presented here. If you recognize a problem you havent yet solved, the solution will show you how to approach and solve that problem in short order. Finally, if none of the solution titles look familiar, then this book probably isnt for you.

Why These Solutions?


These solutions were selected from among a larger number of proposed topics as being pertinent to a range of beginners and more advanced .NET programmers. In addition, the topics should be of interest to both VB.NET and C# developers, with one exception. Solution 6, File I/O in VB.NET: Avoid the Compatibility Syntax is a VB.NET-only solution, but we felt it was important to include anyway. Although some .NET developers write applications for many different areas, others work primarily in specific areas (such as Windows Forms or ASP.NET), build console applications, or work primarily with database applications and thus use ADO.NET. Therefore, we divided the book into several sections, and we grouped solutions that address topics in those areas. Still, youll find that some solutions cross these boundaries. Thats not necessarily a bad thingone of the underlying strengths of the .NET platform is that the knowledge you gain in one area often applies equally well in other areas.

Introduction

xiii

We werent able to address every major area of .NET development in this one book. For example, you wont find solutions to Web services and .NET Compact Framework development problems herebut we hope to include those in future 10-Minute Solution books! Some of these solutions were previously published on DevX (although theyve been updated for this book), but the bulk of the solutions are new.

How Should You Use this Book?


This book is both a resource and a guide to the types of problems youre likely to encounter, but it isnt a linear tutorial, and it isnt a resource in the normal senseyou wont find a substitute for the MSDN documentation here. Instead, its a resource of code examples, more along the lines of a cookbook. Not every .NET developer will be interested in every solution, but most will find solutions to a few immediately interesting problems. Its our hope that the longer you develop with .NET and the greater the diversity in your applications, the more pertinent the solutions included here will become. So, we dont expect you to read it straight through. We recommend that you spend some time familiarizing yourself with the range of solutions. Then, later when youre designing an application, youll remember the solutions that might show how to approach that design, or when you encounter a problem, youll recall that this book offers a solution that addresses that problem.

Windows Forms Solutions


SOLUTION SOLUTION SOLUTION SOLUTION

1 2 3 4

ListBox ItemData Is Gone! Create Owner-Drawn ListBoxes and Combo Boxes Upgrade Your INI Files to XML Build Your Own XML-Enabled Windows Forms TreeView Control

Windows Forms Solutions

SOLUTION

1
SOLUTION

ListBox ItemData Is Gone!


PROBLEM

Classic VB ListBoxes had an ItemData property that let you associate an item in a ListBox with something else, such as an ID value for a row in a database table, or an index for an array of items. But .NET ListBoxes dont have an ItemData property. How can I make that association now?

Place your items in a class. When you do that, you often dont need an index or ID number, because the items are directly available from the ListBoxs Items collection.

The look of those familiar VB ListBoxes and ComboBoxes hasnt changed, but the way they work has changed dramatically. For those of you just getting started with .NET, dealing with ListBoxes and ComboBoxes is often one of the first sources of serious frustration. But dont worry. In 10 minutes you can absorb the basic workings of the new .NET ListBoxes and ComboBoxes, and youll never miss ItemData again. NOTE
For the rest of this solution, Ill limit the discussion to ListBoxes, but all the information in this solution works with both ComboBoxes and ListBoxes.

The data model for classic VB ListBoxes consisted of the List property, which held a simple array of strings, and a parallel ItemData array that held Long numeric values. It was convenient to use the two lists in tandem; for example, you might populate a ListBox with a list of strings from a database table, while simultaneously populating the ItemData property with a unique numeric value from that table, such as an AutoNumber. When a user selected an item (or items), you could retrieve the ItemData value and use it to obtain the associated object, or use the value as a lookup value for a database query. Table 1 shows the classic VB ListBox data model with three items in the List array, and three Long integer values in the ItemData array.
TA B L E 1 : The Classic VB ListBox Data Model

List Array (String values)


Item 1 Item 2 Item 3

ItemData Array (Long values)


1293 2493 8271

Solution 1 ListBox ItemData Is Gone!

In VB.NET, when you drag a ListBox onto a form and then try to write the same loop to populate the ListBox, adding a text value and an ItemData numeric value for each item, youll get a compile-time error. ListBoxes in .NET dont have an ItemData property. Hmm. It does seem that the ubiquitous VB ListBox lost some backward compatibility. But in doing so, it also gained functionality. Rather than having two separate arrays limited to Strings and Longs, the .NET ListBox has only one collection, called Items, which holds objectsmeaning you can store any type of object as an item in a ListBox, and not just simple strings and numbers. However, the ListBox still needs a string to display for each item. Thats easy. By default, the ListBox calls the ToString method to display each item in the Items collection. But wait! What if the ToString method doesnt display what you need? Thats easy too. ListBoxes now have a DisplayMember property. If the DisplayMember property is set, the ListBox invokes the item number named by the DisplayMember property before displaying the item. In other words, rather than storing a single set of strings and associated ID values, and then having to do extra work of retrieving the appropriate data when a user clicks on an item, you can now store the entire set of objectsright in the Items property. Still, despite the best efforts of VB.NET experts to convince them otherwise, people arent always happy with the current ListBox implementation. One reason is that the consumers of a class arent always the creators of the classand they may not be satisfied with the class creators selections. So first, Ill show you how to re-create the functionality of the classic VB ListBox control, and then Ill show you how to move far beyond itand even beyond the probable intent of the .NET designersto create an extremely flexible strategy for displaying items in .NET ListBoxes.

Mimicking a Classic VB ListBox


What youre about to do may feel awkward at first, but youll soon find that as your thinking patterns switch from managing raw data to handling classes, it will become a natural behavior. Because youre trying to mimic an ItemData property that doesnt exist, your first inclination might be to subclass the .NET ListBox control and add your own parallel array of Integer values, accessed via an added ItemData property. But that carries baggage you dont need, because youd have to manage the new array in codewhich becomes very difficult with a control that can sort items. Youd then have to make sure the arrays stay synchronized across sorts when users modify the Item collectionit can be a mess.

Populating a ListBox
Heres an easier way. Rather than adding the ItemData property to the control itself, add the ItemData value to the items you put into the Items collection. When you do that, you dont have to subclass the control or write any special sorting or list modification code. For example,

Windows Forms Solutions

suppose you have a list of employee names and ID numbers. When a user clicks on an employee name in the ListBox, you want to show a MessageBox with that users ID number and name. Assume you have the names in a string array called names, and the IDs in a Long array called IDs. In classic VB, you would write code like this:
Dim i As Long For i = 0 To UBound(names) List1.AddItem names(i) List1.ItemData(List1.NewIndex) = ids(i) Next

In .NET, however, you create a simple class with two properties, Text and ItemData, and a constructor to make it easy to assign the two properties when you create the class. Listing 1 shows the code for such a class, named ListItem.

Listing 1

The ListItem class (ListItem.vb)

Public Class ListItem Private m_Text As String Private m_ItemData As Integer Public Sub New(ByVal Text As String, _ ByVal ItemData As String) m_Text = Text m_ItemData = ItemData End Sub Public Property Text() As String Get Return m_Text End Get Set(ByVal Value As String) m_Text = Value End Set End Property Public Property ItemData() As Integer Get Return m_ItemData End Get Set(ByVal Value As Integer) m_ItemData = Value End Set End Property End Class

Solution 1 ListBox ItemData Is Gone!

Assuming you have the names and IDs arrays already populated, you can create instances of your ListItem class and assign them to the ListBoxs Items collection using a simple loop:
Dim i As Integer For i = 0 To names.Length - 1 Me.ListBox1.Items.Add(New ListItem(names(i), ids(i))) Next

But if you run this code, youll find that the ListBox displays a list of items that look like [Projectname].ListItem rather than the list of names you were expecting. Thats because, by default, the ListBox calls the ToString method for each item to get a displayable string. In this case, however, you dont want to use the default; you want the ListBox to display the Text property. So, add this line before the loop that populates the ListBox:
Me.ListBox1.DisplayMember = Text

That tells the ListBox to display the Text property for each item rather than the results of ToString. TIP
You must assign a property member to the ListBox.DisplayMember propertyusing a public field or a function doesnt work. Thats because the display functionality works through reflectionthe ListBox dynamically queries the item at runtime for a property with the name you assign to the ListBox.DisplayMember property.

Of course, its your class, and you can eliminate the DisplayMember assignment by overriding the ToString method to show whatever you like. In this case, you want to show the Text property. So, add this code to the ListItem class:
Public Overrides Function ToString() As String Return Me.Text End Function

Now you can remove the DisplayMember assignment and the ListBox will still display the results of the Text property.

Getting the Data Back


As youve seen, you can use this simple ListItem class to work with exactly the same data you used in classic VB ListBox code. Getting the data back is just as simple. When a user clicks an item, the .NET ListBox fires a SelectedItemChanged event. That happens to be the default event for the ListBox, so if you double-click on it in design mode, Visual Studio will insert a stub event handler for you. Fill in the event-handling code as follows:
Private Sub ListBox1_SelectedIndexChanged( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles ListBox1.SelectedIndexChanged

Windows Forms Solutions

Dim li As ListItem If Me.ListBox1.SelectedIndex >= 0 Then li = DirectCast(Me.ListBox1.SelectedItem, ListItem) Debug.WriteLine(Selected Item Text: & _ li.Text & System.Environment.NewLine & _ Selected ItemData: & li.ItemData) End If End Sub

First, test to ensure that an item is selected. If so, even though you know that its a ListItem, the ListBox.Items collection doesntits a collection of objects. Therefore, you need to cast the selected item to the correct type, using either the CType or DirectCast method (DirectCast is faster when you know the cast will succeed). Now that youve seen a way to re-create VB6 ListBox behavior, Ill concentrate on other ways to use the list controls in .NET, including binding the control to a collection type.

The Class Creator Has Control


Suppose youre told to use a Person class (created by a co-worker) that has four properties: ID (Long), LastName, FirstName, and Status (see Listing 2). The Person object has an overloaded constructor so you can assign all the values when you create the object. Ive included the complete, finished code for the Person class in Listing 2, even though were assuming your co-worker didnt give you the class in quite this shape. Ive highlighted the portions that youll add in the next section of this solution. The Person class has ID, LastName, FirstName, and Status properties. Although it exposes LastFirst and FirstLast methods, the interesting parts are the DisplayPersonDelegate, the DisplayMethod property, and the overridden ToString method.

Listing 2

(VB.NET) The Person class (Person.vb)

Public Class Person Public Delegate Function DisplayPersonDelegate _ (ByVal p As Person) As String Private mID As Long Private mLastName As String Private mFirstName As String Private mStatus As String Private mDisplayMethod As DisplayPersonDelegate Public Sub New(ByVal anID As Long, ByVal lname As String, _ ByVal fname As String, ByVal statusValue As String) mID = anID mLastName = lname mFirstName = fname

Solution 1 ListBox ItemData Is Gone!

mStatus = statusValue End Sub Public Property ID() As Long Get Return mID End Get Set(ByVal Value As Long) mID = Value End Set End Property Public Property LastName() As String Get Return mLastName End Get Set(ByVal Value As String) mLastName = Value End Set End Property Public Property FirstName() As String Get Return mFirstName End Get Set(ByVal Value As String) mFirstName = Value End Set End Property Public Property Status() As String Get Return mStatus End Get Set(ByVal Value As String) mStatus = Value End Set End Property Public Overloads Overrides Function ToString() As String Try Return Me.DisplayMethod(Me) Catch Return MyBase.ToString() End Try End Function Public Property DisplayMethod() As DisplayPersonDelegate Get Return mDisplayMethod End Get

Windows Forms Solutions

Set(ByVal Value As DisplayPersonDelegate) mDisplayMethod = Value End Set End Property Public ReadOnly Property LastFirst() As String Get Return Me.LastName & , & Me.FirstName End Get End Property Public ReadOnly Property FirstLast() As String Get Return Me.FirstName & & Me.LastName End Get End Property End Class

You want to fill a ListBox with Person objects. So you create a Form and drag a ListBox onto it. You want the ListBox to fill when the user clicks a button, so you add a Fill List button to do that (see Figure 1). VB.NET makes it easy to display items in a ListBox, because you can set the ListBoxs DataSource property (binding the list) to any collection that implements the IList interface, which represents a collection of objects that you can access individually by index. Note that you dont have to populate the list through binding; you can still write a loop to add items to the ListBox, as youve already seen in the Populating a ListBox section of this solution. However, binding is convenient, as long as you understand exactly what the framework does when it displays the list. FIGURE 1:
The sample form (form2) initially contains a ListBox and a button.

Solution 1 ListBox ItemData Is Gone!

The ArrayList class implements the IList interface, so you can create an ArrayList member variable for the form, called people, and fill it with Person objects during the Form_Load event.
define an ArrayList at class level Private people As New ArrayList() Private Sub Form2_Load( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles MyBase.Load Dim p As Person Me.ListBox1.Sorted = True ListBox1.DisplayMember = ToString ListBox1.ValueMember = ID p = New Person(1, Twain, Mark, ) people.Add(p) p = New Person(2, Austen, Jane, ) people.Add(p) p = New Person(3, Fowles, John, ) people.Add(p) End Sub

Now, when a user clicks the Fill List button, the ListBox displays items automatically because the code sets the ListBoxs DataSource property to the people ArrayList:
Private Sub btnFillList_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) + Handles btnFillList.Click ListBox1.DataSource = Nothing ListBox1.DataSource = people End Sub

Unfortunately, you find that the class creator didnt override the ToString implementation or include any additional LastFirst method to provide the strings for the ListBox. So the result is that the ListBox calls the default Person.ToString implementation, which returns the class name, Solution1.Person. The result looks like Figure 2. OK, no problem. What about using the DisplayMember property? Just add the following line to the end of the Button1_Click method:
ListBox1.DisplayMember = LastName

10

Windows Forms Solutions

FIGURE 2:
The default

Person.ToString
implementation returns only the class name.

Now, run the project again. This time, the result is a little closer to what you want (see Figure 3). Setting the ListBoxs DisplayMember property to the string LastName causes the ListBox to invoke the LastName method. Unfortunately, this displays only the last names, not the last and first names. FIGURE 3:
Setting the DisplayMember property to LastName displays only the last names.

Now youre stuck. Unless you can get the class creator to add a LastFirst property, youll have to go to a good deal of trouble to get the list to display both names. (At this point, you have to pretend the class creator actually helps and adds a LastFirst property to the Person class.)
Public ReadOnly Property LastFirst() As String Get Return Me.LastName & , & Me.FirstName End Get End Property

Now you can change the ListBox.DisplayMember property, and the form will work as expected (see Figure 4):
ListBox1.DisplayMember = LastFirst

Solution 1 ListBox ItemData Is Gone!

11

FIGURE 4:
Setting the ListBox .DisplayMember property to the LastFirst method displays the list in LastName/ FirstName order.

Just as you get the form working, your manager walks in and says, Oh, by the way, the clients want to be able to change the list from Last/First to First/Lastboth sorted, of course. Now what? You could get the class creator to change the class again, but surely theres a better solution. You could inherit the class and add a FirstLast method, but then youd have two classes to maintain. You could create a new wrapper class that exposes the people ArrayList collection, as well as implements FirstLast and LastFirst properties. But what if the clients change their minds again? Youd have to keep adding methods to the class, or bite the bullet and beg the class creator for yet more changes. Also, do you really have to create a wrapper for every class you want to display in a ListBox? This is when you begin to miss the classic VB ListBoxs ItemData property. If you could assign Person.ID as the ItemData value, you could concatenate the names yourself, add them to the ListBox, and then look up the Person based on the ID when a user selects an item from the ListBox. But ItemData is gone. Of course, you can mimic it, as youve seen, but that seems like a lot of trouble when you already have a class that you could store directly into the ListBox. All these possibilities are onerous choices. Things would be a lot easier if you could just control the Person class. Whats the answer?

Delegate, Delegate, Delegate


At this point, you need to change rolestake off your reader hat and put on your control creator hat. Heres a completely different approach to displaying custom strings based on some object. Unless theres a good reason not to do so, when you create a class you typically want the class consumer to have as much control as possible over the instantiated objects. One way to

12

Windows Forms Solutions

increase class consumers power is to give them control over the method that the ListBox (or other code) calls to get a string representation of your object. In other words, rather than predefining multiple display methods within your class, you provide a public Delegate type, and then add a private member variable and a public property to your class that accept the delegate type. For example:
Public Delegate type definition Public Delegate Function DisplayPersonDelegate( _ ByVal p As Person) As String Private member variable Private mDisplayMethod As DisplayPersonDelegate Public Property Public Property DisplayMethod() As DisplayPersonDelegate Get Return mDisplayMethod End Get Set (ByVal Value As DisplayPersonDelegate) mDisplayMethod=Value End Set End Property

The DisplayPersonDelegate accepts a Person object and returns a string. The class consumer will create a DisplayPersonDelegate object and assign it to the public DisplayMethod property. Next, override the ToString method so that it returns the delegate result value. For example:
Public Overloads Overrides Function ToString() As String Try Return Me.DisplayMethod(Me) Catch Return MyBase.ToString() End Try End Function

The advantage of this scheme is that the object consumer gets the best of both worldsa default ToString implementation assignable by the class creator, and the ability to call a custom ToString method by assigning the delegate. And the class creator doesnt have to worry about all the possible ways that a user may wish to display an object. Finally, it gives the object consumer the ability to set different custom ToString methods for every instance of the Person class. The simplest way to use the Person class is to assign a collection of Person objects to some collection, setting the DisplayMethod property for each Person to a function matching the

Solution 1 ListBox ItemData Is Gone!

13

DisplayPersonDelegate signature. For example, to create an ArrayList containing the Person objects, you would first write the display functions:
Public Function DisplayPersonFirstLast _ (byVal p as Person) as String Return p.FirstName & & p.LastName End Function Public Function DisplayPersonLastFirst _ (byVal p as Person) as String Return p.LastName & , & p.FirstName End Function

Next, when you create the collection, you assign the DisplayMethod for each Person object:
define an ArrayList at class level Private people As New ArrayList() create Person objects and add them to the people ArrayList Dim p as person p = New Person(1, Twain, Mark, MT) create a DisplayPersonDelegate for the DisplayPersonLastFirst method p.DisplayMethod = New Person.DisplayPersonDelegate _ (AddressOf DisplayPersonLastFirst) people.Add(p) repeat as necessary p = New Person(2, Austen, Jane, JA) p.DisplayMethod = New Person.DisplayPersonDelegate _ (AddressOf DisplayPersonLastFirst) people.Add(p) p = New Person(3, Fowles, John, JF) p.DisplayMethod = New Person.DisplayPersonDelegate _ (AddressOf DisplayPersonLastFirst) people.Add(p)

You can see the results by clicking the buttons titled Last, First or First Last on the sample Form2 form. These buttons switch the display of the names between Last/First and First/Last order without requiring any changes to or using any special display methods in the Person class. Using the DisplayMethod delegate property, Person object consumers can

14

Windows Forms Solutions

create custom methods that display the objects data in any format they prefer. But because the scheme defaults to the .NET standard ToString method, you havent changed the base functionality of ToString in any other way. In fact, the only reason to override the ToString method at all is because thats what the ListBox calls by default. But you could just as easily write a display method and have the class consumers call that method explicitly (in this case, by setting the ListBox DisplayMember property to Display) and leave ToString out of the equation altogether. By providing a display method of any kind (ToString or otherwise) that accepts a delegate, you have, perhaps accidentally, given class consumers even more power than you may have realized.

Who Needs ItemData?


The solution youve just studied accomplishes one other thing thatuntil nowwas impossible without writing customized code, and thats that you can set a different display method for each instance of a class. The Custom button illustrates this capability by setting the Status property of the Jane Austen Person object to a custom string:
Private Sub btnCustom_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnCustom.Click Dim p As Person p = CType(people(0), Person) Mark Twain p.DisplayMethod = New _ Person.DisplayPersonDelegate( _ AddressOf DisplayPersonFirstLast) p = CType(people(1), Person) Jane Austen p.Status = Not at home. Whew! p.DisplayMethod = New _ Person.DisplayPersonDelegate( _ AddressOf DisplayPersonStatus) p = CType(people(2), Person) John Fowles p.DisplayMethod = New _ Person.DisplayPersonDelegate( _ AddressOf DisplayPersonLastFirst) ListBox1.DataSource = Nothing ListBox1.DataSource = people End Sub

Solution 1 ListBox ItemData Is Gone!

15

Public Function DisplayPersonStatus( _ ByVal p As Person) As String Return p.LastName & , & p.FirstName & _ ( & p.Status & ) End Function

Now, when you click the button, the results look like Figure 5. In other words, assigning a different DisplayMethod delegate to an object instance causes that instance to display differently than other class instances, even within the same ListBox, despite the fact that you dont have to alter the class code to control the text displayed for each item. Figure 5 shows the result when each Person instance has a different display method assigned. FIGURE 5:
The result of assigning different DisplayMethod delegates

While you wouldnt normally want to provide a customized display method for each instance in a ListBox, the capability comes in handy when some people, for example, are comfortable with displaying their nicknames while others arent, or when the ListBox contains a collection of disparate objects. Finally, giving class consumers the ability to create customized display strings for your classes goes a long way toward making the missing ItemData truly unnecessary. When you click on an item in the ListBox, it displays a MessageBox that shows the selected item and its ID, proving yet again that associating an ID with an item by using objects works just as well as the older ItemData arrayand doesnt require the class consumer to write any code. Theres one small downside to this method. If you want to post two ListBoxes side by side, both containing the same objects but with one displaying (for example) LastName/FirstName and the other displaying FirstName/LastName, you need to implement a Clone method. Doing so lets you set different display methods for the objects in each list. In this particular case, using a wrapper object (such as the ListItem class) to handle the class display may be a simpler design.

16

Windows Forms Solutions

SOLUTION

2
SOLUTION

Create Owner-Drawn ListBoxes and ComboBoxes


PROBLEM

I want to create ListBoxes and ComboBoxes that can contain icons and special fonts like the ones I see in other Windows applications. How can I do that with .NET?

Learn to use the DrawMode settings with ListBoxes and ComboBoxes to create and display customized items.

You must create an owner-drawn ListBox or ComboBox when you want to bypass the controls automatic item display to do something special, such as display an image for each item or display a list in which the items arent all the same size. The .NET Framework makes it simple to generate these custom item lists. In this solution, youll learn how to populate list and ComboBox controls with items you draw yourself. The only thing you need to do to create an owner-drawn ListBox or ComboBox is to set the DrawMode property to either OwnerDrawFixed or OwnerDrawVariable. The DrawMode property has three possible settings:

Normal, in which the system handles displaying the items automatically OwnerDrawFixed, which you should use when you want to draw the items yourself and all

the items are the same height and width

OwnerDrawVariable, which you use to draw items that vary in height or width

The default setting is, of course, Normal. When you select the OwnerDrawFixed setting, you must implement a DrawItem method. The ListBox calls your DrawItem whenever it needs to draw an item. When you select the OwnerDrawVariable setting, you must implement both the DrawItem and a MeasureItem method. The MeasureItem method lets you set the size of the item to be drawn. When you use the Normal setting, the system does not fire either the MeasureItem or the
DrawItem method.

NOTE

There are some restrictions when you use any setting but Normal. You cant create variable-height items for multicolumn ListBoxes, and CheckedListBoxes dont support either of the owner-drawn DrawMode settings.

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

17

Listing Files and Folders


Suppose you want to list the files and directories in a folder along with the associated system icons appropriate to the type of file. You must follow several steps to accomplish this task:

Validate the requested directory path. Retrieve the files and subfolders from a directory. Iterate through them, retrieving their types and names. Find the appropriate icon for each file type. Draw the items for the ListBox containing the appropriate icon and text.

Figure 1 depicts a Web form with the finished ListBox control that displays all the files in a specified directory along with their corresponding system icons. To use the example, enter a directory path in the first text field. The form ensures that the entered path is valid, and then follows the steps listed here to fill a ListBox shown in a separate dialog box. The user can double-click an item in the list, or select an item and click OK. The constructor for the dialog form (ListFilesAndFolders) requires a path string. FIGURE 1:
The ListFilesAndFolders form contains an owner-drawn ListBox that displays names of folders and files, along with their system-associated icons.

18

Windows Forms Solutions

The first step in creating the application logic is to validate the path string users enter in the main form. The easiest way to do that is to use the System.IO.DirectoryInfo class, which has an Exists method that returns True if the directory exists:
Dim di As DirectoryInfo Me.txtResult.Text = Nothing di = New DirectoryInfo(Me.txtPath.Text) If Not di.Exists Then txtPath.ForeColor = System.Drawing.Color.Red Beep() Exit Sub End If

The code turns the TextBox text red and plays a warning sound if the entered path is invalid; otherwise, it creates a new instance of the ListFilesAndFolders form, passing the validated path string to its constructor:
Dim frmFiles As New _ ListFilesAndFolders(Me.txtPath.Text)

The ListFilesAndFolders form contains a ListBox, an OK button, and a Close button. The forms constructor calls a FillList method that retrieves the files and folders in the specified path and then fills a ListBox control with the icons and names, suspending the controls display until the method completes:
Sub FillList(ByVal aPath As String) Dim fsi As FileSystemInfo lstFiles.BeginUpdate() Me.lstFiles.ItemHeight = _ CInt(lstFiles.Font.GetHeight + 4) lstFiles.Items.Clear() files = New DirectoryInfo(aPath).GetFileSystemInfos For Each fsi In files lstFiles.Items.Add(fsi) Next lstFiles.EndUpdate() End Sub

The DirectoryInfo.GetFileSystemInfos method used in this code snippet returns an array of FileSystemInfo objects. The code iterates through the returned array and adds each item to the ListBoxs Items collection. Heres where things get interesting. The ListBoxs DrawMode property is set to OwnerDrawFixed, because although you want to draw the items yourself (so you can add the file-type icons), each item will be the same height. When you set DrawMode to anything except Normal, the act of adding the items to the ListBox doesnt cause the ListBox to draw them; instead, the ListBox fires a DrawItem event whenever the ListBox needs to display an item. In this case, every time the DrawItem event fires, you want to draw an icon and the name of a FileSystemInfo

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

19

object that represents a file or folder. Because this is an owner-drawn control, you must create the DrawItem method to display the item:
Private Sub lstFiles_DrawItem( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.DrawItemEventArgs) _ Handles lstFiles.DrawItem the system sometimes calls this method with an index of -1. If that happens, exit. If e.Index < 0 Then e.DrawBackground() e.DrawFocusRectangle() Exit Sub End If create a brush Dim aBrush As Brush = System.Drawing.Brushes.Black get a reference to the item to be drawn Dim fsi As FileSystemInfo = _ CType(lstFiles.Items(e.Index), FileSystemInfo) create an icon object Dim anIcon As Icon use a generic string format to draw the filename Dim sFormat As StringFormat = _ StringFormat.GenericTypographic get the height of each item Dim itemHeight As Integer = lstFiles.ItemHeight call these methods to get items to highlight properly e.DrawBackground() e.DrawFocusRectangle()

retrieve the appropriate icon for this file type anIcon = IconExtractor.GetSmallIcon(fsi) draw the icon If Not anIcon Is Nothing Then e.Graphics.DrawIcon(anIcon, 3, _ e.Bounds.Top + ((itemHeight - _ anIcon.Height) \ 2))

20

Windows Forms Solutions

anIcon.Dispose() End If if the item is selected, change the text color to white If (e.State And _ Windows.Forms.DrawItemState.Selected) = _ Windows.Forms.DrawItemState.Selected Then aBrush = System.Drawing.Brushes.White End If sFormat.LineAlignment = StringAlignment.Center e.Graphics.DrawString(fsi.Name, lstFiles.Font, _ aBrush, 22, e.Bounds.Top + _ (e.Bounds.Height \ 2), sFormat) End Sub

In the DrawItem method shown here, the code calls a shared GetSmallIcon method exposed by the IconExtractor class (see Listing 1), which, when passed a FileSystemInfo object, calls the Win32 SHGetFileInfo API to extract the icon for the file type represented by that object. The IconExtractor class exposes two public shared methodsGetLargeIcon and GetSmallIconboth of which simply call a private GetIcon method that returns the large (3232) or small (1616) icon versions, respectively:
Public Shared Function GetSmallIcon( _ ByVal fsi As FileSystemInfo) As Icon Return IconExtractor.GetIcon _ (fsi, SHGFI_SMALLICON) End Function Public Shared Function GetLargeIcon( _ ByVal fsi As FileSystemInfo) As Icon Return IconExtractor.GetIcon _ (fsi, SHGFI_LARGEICON) End Function Private Shared Function GetIcon( _ ByVal fsi As FileSystemInfo, _ ByVal anIconSize As Integer) As Icon Dim aSHFileInfo As New SHFILEINFO() Dim cbFileInfo As Integer = _ Marshal.SizeOf(aSHFileInfo) Dim uflags As Integer = SHGFI_ICON Or _ SHGFI_USEFILEATTRIBUTES Or anIconSize Try

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

21

SHGetFileInfo(fsi.FullName, fsi.Attributes, _ aSHFileInfo, cbFileInfo, uflags) Return Icon.FromHandle(aSHFileInfo.hIcon) Catch ex As Exception Return Nothing End Try End Function

Listing 1
Imports Imports Imports Imports Imports

The IconExtractor Class calls the Win32 API to identify and return icons appropriate for a specific file type. (IconExtractor.vb)
System System.Drawing System.Runtime.InteropServices System.Windows.Forms System.IO

Public Class IconExtractor Private Private Private Private Const Const Const Const SHGFI_SMALLICON = &H1 SHGFI_LARGEICON = &H0 SHGFI_ICON = &H100 SHGFI_USEFILEATTRIBUTES = &H10

Public Enum IconSize SmallIcon = SHGFI_SMALLICON LargeIcon = SHGFI_LARGEICON End Enum <StructLayout(LayoutKind.Sequential)> _ Private Structure SHFILEINFO pointer to icon handle Public hIcon As IntPtr icon index Public iIcon As Integer not used in this example Public dwAttributes As Integer file pathname--marshal this as an unmanaged LPSTR of MAX_SIZE <MarshalAs(UnmanagedType.LPStr, SizeConst:=260)> _ Public szDisplayName As String file type--marshal as unmanaged LPSTR of 80 chars <MarshalAs(UnmanagedType.LPStr, SizeConst:=80)> _ Public szTypeName As String End Structure Private Declare Auto Function SHGetFileInfo _ Lib shell32 (ByVal pszPath As String, _ ByVal dwFileAttributes As Integer, _

22

Windows Forms Solutions

ByRef psfi As SHFILEINFO, _ ByVal cbFileInfo As Integer, _ ByVal uFlags As Integer) As Integer Public Shared Function GetSmallIcon( _ ByVal fsi As FileSystemInfo) As Icon Return IconExtractor.GetIcon _ (fsi, SHGFI_SMALLICON) End Function Public Shared Function GetLargeIcon( _ ByVal fsi As FileSystemInfo) As Icon Return IconExtractor.GetIcon _ (fsi, SHGFI_LARGEICON) End Function Private Shared Function GetIcon( _ ByVal fsi As FileSystemInfo, _ ByVal anIconSize As Integer) As Icon Dim aSHFileInfo As New SHFILEINFO() Dim cbFileInfo As Integer = _ Marshal.SizeOf(aSHFileInfo) Dim uflags As Integer = SHGFI_ICON Or _ SHGFI_USEFILEATTRIBUTES Or anIconSize Try SHGetFileInfo(fsi.FullName, _ fsi.Attributes, aSHFileInfo, _ cbFileInfo, uflags) Return Icon.FromHandle(aSHFileInfo.hIcon) Catch ex As Exception Return Nothing End Try End Function End Class

The GetSmallIcon and GetLargeIcon methods both accept a FileSystemInfo object. Internally, the GetIcon method uses the FileSystemInfo object to pass the filename and file attributes to the SHGetFileInfo API call. After drawing the icon, the DrawItem event handler calls the Graphics.DrawString method to place the filename on the image next to the icon. The ListBox calls the DrawItem method repeatedly, once for each item in its Items collection. The DrawItemEventArgs argument to the DrawItem event handler exposes an Index property whose value is the index of the item to be drawn. Watch out! The system raises the DrawItem event with an index value of -1 when the Items collection is empty. When that happens, you should call the DrawItemEventArgs.DrawBackground() and DrawFocusRectangle() methods and then exit. The purpose of raising the event is to let the control draw a focus rectangle so

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

23

that users can tell it has the focus, even when no items are present. The code traps for that condition, calls the two methods, and then exits the handler immediately. Users can select an item and close the ListFilesAndFolders form either by selecting an item and then clicking the OK button, or by double-clicking an item. Either way, the form sets a public property called SelectedItem, sets another public property called Cancel, and then closes. The main form then displays the filename of the selected item in the Result field.

Drawing Items with Variable Widths and Heights


This section presents a similar example, but this time the items youll create wont all be the same width and height. To create an owner-drawn ListBox or ComboBox with items of variable heights and widths, set the DrawMode property to OwnerDrawVariable. Then, implement a method that handles the MeasureItem event, which accepts a sender (Object) and a System .Windows.Forms.MeasureItemEventArgs argument. The sample form frmColorCombo displays all the known system colors and their names in a ComboBox. The items themselves vary between 20 and 40 pixels in height. The result is contrived and ugly (see Figure 2) but serves to illustrate the point. FIGURE 2:
The frmColorCombo form contains an owner-drawn ListBox that displays all the known color names, accompanied by a variable-height color swatch.

The code does present a couple of interesting problems. The .NET Framework defines the common colors as an enumeration. Enumerations expose a getNames method that, when passed a Type object for a particular enumeration, returns an array of names in that enumeration. In this case, you want not only the names but the colors themselves. You can create a

24

Windows Forms Solutions

Color object if you know the name by using the Color.FromName method. So the following For...Each loop retrieves the known color names, and then adds Color objects to the ComboBoxs Items collection:
Dim aColorName As String For Each aColorName In _ System.Enum.GetNames _ (GetType(System.Drawing.KnownColor)) colorCombo.Items.Add(Color.FromName(aColorName)) Next

The frmColorCombo class defines a private Random object (mRand). Because its DrawMode property is set to OwnerDrawVariable, the ComboBox control calls the MeasureItem event before drawing each item (in other words, before calling the DrawItem method):
Protected Sub colorCombo_MeasureItem( _ ByVal sender As Object, ByVal e As _ System.Windows.Forms.MeasureItemEventArgs) _ Handles colorCombo.MeasureItem e.ItemHeight = mRand.Next(20, 40) End Sub

In this code snippet, the comboColor_MeasureItem event handler calls the overloaded Random.Next method to get the next random number between 20 and 40, and assigns that to the ItemHeight property of the MeasureItemEventArgs parameter. The DrawItem event handler used here is similar to the one in the previous example. It retrieves the Color object from the Items collection as specified by the Index value of the DrawItemEventArgs parameter, and then retrieves the color name from that Color object. The method draws a square and fills it with the appropriate color, and then draws the color name to the right of the square. As you can see in Listing 2, the DrawItem method uses the bounds set randomly in the MeasureItem method for each item.

Listing 2

The colorCombo_DrawItem method displays a random-height color swatch and the color name for each color shown.

Protected Sub colorCombo_DrawItem( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.DrawItemEventArgs) _ Handles colorCombo.DrawItem If e.Index < 0 Then e.DrawBackground() e.DrawFocusRectangle() Exit Sub

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

25

End If Get the Color object from the Items list Dim aColor As Color = _ CType(colorCombo.Items(e.Index), Color) get a square using the bounds height Dim rect As Rectangle = New Rectangle _ (2, e.Bounds.Top + 2, e.Bounds.Height, _ e.Bounds.Height - 4) Dim br As Brush call these methods first e.DrawBackground() e.DrawFocusRectangle() change brush color if item is selected If e.State = _ Windows.Forms.DrawItemState.Selected Then br = Brushes.White Else br = Brushes.Black End If draw a rectangle and fill it e.Graphics.DrawRectangle(New Pen(aColor), rect) e.Graphics.FillRectangle(New SolidBrush _ (aColor), rect) draw a border rect.Inflate(1, 1) e.Graphics.DrawRectangle(Pens.Black, rect) draw the Color name e.Graphics.DrawString(aColor.Name, _ colorCombo.Font, br, e.Bounds.Height + 5, _ ((e.Bounds.Height - colorCombo.Font.Height) _ \ 2) + e.Bounds.Top) End Sub

In the sample form, when you select a color from the frmColorCombo window, the main form changes the button color to reflect your choice. If you close the frmColorCombo window without selecting a color, the main form changes the button back to its default color.

Building a Font Combo That Displays Fonts


Heres another useful example. Its relatively easy to create a ComboBox that lets a user select a font, but in Microsoft Word and other commercial applications, you sometimes see font selection ComboBoxes that display the names of the fonts using the fonts themselves, rather

26

Windows Forms Solutions

than using a single fixed typeface. To do that, you first need to retrieve the list of font families installed on the machinea process called enumerating fontsand add them to an ownerdrawn ComboBox. Then, in the MeasureItem event, you create an instance of that font and use it to measure the font name. Similarly, in the DrawItem event, you create an instance of the font and use that to draw the font name. Because most of the code is identical to that of the frmColorCombo form, Ill only show the relevant portion in Listing 3, although the accompanying code (available on the Sybex Web site, www.sybex.com) contains the complete implementation. The system maintains a list of the installed font families, which you can retrieve either by using the FontFamilies.Families property or by creating a new InstalledFontsCollection object and calling its Families method:
Dim installedFonts As New InstalledFontCollection()

In that way, you can retrieve a complete list of installed fontsincluding the line-drawing and non-character WingDings fonts. Some of those dont look very good in a ComboBox, so Ive eliminated the worst offenders by testing the height of the letter A at a font size of 9 points. If the font height measurement is greater than 20 pixels, the code in Listing 3 doesnt add it to the ComboBox.

Listing 3

The Form_Load event handler fills the ComboBoxs Items collection with a list of fonts. (frmFontCombo.vb)

Private Sub frmFontCombo_Load( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Me.Size = New Size(New Point(240, 60)) Me.ControlBox = True Me.FormBorderStyle = _ Windows.Forms.FormBorderStyle.FixedToolWindow fontCombo = _ New System.Windows.Forms.ComboBox() fontCombo.DrawMode = _ Windows.Forms.DrawMode.OwnerDrawVariable fontCombo.Location = New Point(0, 0) fontCombo.Width = Me.Width - 5 fontCombo.MaxDropDownItems = 20 fontCombo.IntegralHeight = True Dim aFontFamily As FontFamily Dim installedFonts As New InstalledFontCollection() Dim g As Graphics = Me.CreateGraphics Dim families() As FontFamily = FontFamily.GetFamilies(g) For Each aFontFamily In families installedFonts.Families

Solution 2 Create Owner-Drawn ListBoxes and ComboBoxes

27

If aFontFamily.IsStyleAvailable _ (FontStyle.Regular) Then If g.MeasureString(A, New Font(aFontFamily, 9, _ FontStyle.Regular, GraphicsUnit.Point)).Height _ < 20 Then fontCombo.Items.Add(aFontFamily) End If End If Next g.Dispose() Me.Controls.Add(fontCombo) End Sub

Similar to the previous example, after you select a font from the ComboBox, the sample changes the View Font Combo Example button font to match your selection (see Figure 3). Closing the window without selecting a font switches the button text back to the default font. By selecting the appropriate DrawMode setting for your ListBoxes and ComboBoxes and implementing the MeasureItem and DrawItem event handlers, you gain complete control over the contents of ListBoxes and ComboBoxes within the .NET Framework. You can extend the owner-drawn techniques shown here to create complex interactive ComboBoxes. FIGURE 3:
Selecting a font from the ComboBox changes the View Font Combo buttons font to match the selection.

28

Windows Forms Solutions

SOLUTION

3
Migrate your legacy INI data to XML using the Windows API and this XML INI file wrapper class.
SOLUTION:

Upgrade Your INI Files to XML


PROBLEM:

The .NET Framework wraps many underlying Windows API calls, but doesnt provide an easy way to get to legacy data and configuration settings stored in application initialization (INI) files.

The INI (application Initialization) file format became popular because it provides a convenient way to store values that might change (such as file locations and user preferences) in a standard format accessible to but outside of compiled application code. INI files are textbasedmeaning you can read and change most values manually if necessaryand logically arrangedmeaning its easy even for nontechnical personnel to understand the contents. In addition, the functions for reading and modifying the files are built into Windows. You can still use the existing Win32 API calls to read and write from standard INI files using the DllImport attribute with C#, or with the Declare Function statement in VB.NET; however, there are a couple of tricks. WARNING The API calls to interact with INI files have been obsolete since the release of Windows 95, and are supported in Win32 for backward compatibility only. The INIWrapper class shown here wraps the most important API calls for interacting with INI files.

The API INI Functions


The API functions that deal with INI files are usually paired; theres a Get and a Write version for most of the functions. The API contains special functions to read and write the win.ini file in the Windows folder (which arent discussed in this solution); however, if you need to modify win.ini through .NET, youll see enough here to declare the function prototypes yourself. For INI files associated with individual applications, the most important Win32 API INI-related functions are: GetPrivateProfileString and key. Retrieves an individual value associated with a named section

WritePrivateProfileString Sets an individual value associated with a named section and key. GetPrivateProfileInt Retrieves an integer value associated with a named section and key.

Solution 3 Upgrade Your INI Files to XML

29

WritePrivateProfileInt

Sets an integer value associated with a named section and key.

GetPrivateProfileSection Retrieves all the keys and values associated with a named section. WritePrivateProfileSection Sets all the keys and values associated with a named section. Retrieves all the section names in an INI file.

GetPrivateProfileSectionNames

For example, the GetPrivateProfileString API function retrieves an individual value from an INI file. You specify the file, the section, the key, a default value, a string buffer for the returned information, and the size of the buffer. In classic VB, you use a Declare Function statement to declare the API function:
Classic VB declaration Public Declare Function _ GetPrivateProfileString _ Lib kernel32 _ Alias GetPrivateProfileStringA _ (ByVal lpApplicationName As String, _ ByVal lpKeyName As Any, _ ByVal lpDefault As String, _ ByVal lpReturnedString As String, _ ByVal nSize As Long, _ ByVal lpFileName As String) As Long

In .NET the equivalent declaration is


VB.NET declaration Private Declare Ansi Function _ GetPrivateProfileString _ Lib KERNEL32.DLL Alias GetPrivateProfileStringA _ (ByVal lpAppName As String, _ ByVal lpKeyName As String, _ ByVal lpDefault As String, _ ByVal lpReturnedString As StringBuilder, _ ByVal nSize As Integer, _ ByVal lpFileName As String) As Integer

C# handles things a little differently. In C#, you use the DllImport attribute to declare function prototypes, so the equivalent declaration is
// C# function prototype [ DllImport(KERNEL32.DLL, EntryPoint=GetPrivateProfileString)] protected internal static extern int GetPrivateProfileString(string lpAppName, string lpKeyName, string lpDefault, StringBuilder lpReturnedString, int nSize, string lpFileName);

30

Windows Forms Solutions

The interesting point here is that the lpReturnedString parameter expects a string buffer nSize in length. In .NET, you pass a StringBuilder object for the lpReturnedString parameter rather than a String object (remember to add a using System.Text; line to your class file in C#; in VB.NET use the Imports System.Text statement). Thats because strings in .NET are immutable, so while you can pass them into unmanaged code without errors, any changes made to the string buffer in unmanaged code arent visible in your .NET code when the .NET Framework marshals the data back into managed code. Fortunately, StringBuilder objects act as mutable strings and you can create them with a fixed buffer size. When calling unmanaged code that needs a fixed-size string buffer, try a StringBuilder first. The EntryPoint parameter in the C# declaration in the previous snippet isnt strictly required. The EntryPoint parameter contains the name (or the index) of the function you want to declare. You need to include this parameter only if the name of your .NET function isnt the same, because .NET looks for a function named identically to the .NET function if you dont include the parameter. However, if you were to rename the .NET function to getAppInitValue, you would have to include the EntryPoint parameter:
[ DllImport(KERNEL32.DLL, EntryPoint=GetPrivateProfileString)] protected internal static extern int getAppInitValue(string lpAppName, string lpKeyName, string lpDefault, StringBuilder lpReturnedString, int nSize, string lpFileName);

NOTE

I declared the prototypes in the C# version of this class using the protected internal static accessibility level, which means theyre only visible from this project and any derived classes. You may want to change the accessibility level, depending on how you want to use the functions.

Suppose you had a simple INI file that looks like this:
[textvalues] 1=item1 2=item2 3=item3 [intvalues] 1=101 2=102 3=103

After setting up the imported GetPrivateProfileString function definition, you can call it just like any other function. For example, using the INI file shown earlier, and assuming it was saved as c:\INIinterop.ini, the following code would retrieve the value of the item in

Solution 3 Upgrade Your INI Files to XML

31

the [textvalues] section with the key 1the string item1and write it to the output window. Note that you dont have to instantiate an instance of the INIWrapper class, because all the methods are class-level methods (static methods in C# and shared methods in VB.NET).
Dim buffer As StringBuilder = New StringBuilder(256) Dim sDefault As String = Dim bufLen As Integer = _ INIWrapper.GetPrivateProfileString _ (textvalues, 1, , buffer, buffer.Capacity, _ c:\INIinterop.ini) <> 0) Debug.WriteLine(buffer.ToString())

In contrast, you can write a new value without using a StringBuilder, because you dont need a return value:
INIFileInterop.WritePrivateProfileString (textvalues, 1, new Item 1, c:\INIinterop.ini)

You can retrieve and write integer values with the GetPrivateProfileInt and WritePrivateProfileInt methods. (See Listing 1 later in this solution for the full declarations.) To call the GetPrivateProfileInt function, pass the section name, key name, a default integer value (which is returned if the key doesnt exist), and the name of the INI file. For example, the following code writes 101 to the output window:
int result = INIWrapper.GetPrivateProfileString (intvalues, 1, 0, c:\INIinterop.ini); Debug.WriteLine(result.ToString()); dim result as Integer = _ INIWrapper.GetPrivateProfileString _ (intvalues, 1, 0, c:\INIinterop.ini) Debug.WriteLine(result.ToString())

Unfortunately calling the GetPrivateProfileSection function isnt quite as easy. The function returns a buffer filled with a null-delimited list of all the keys and values (items) in a specified section, with an additional trailing null character after the last item, so the returned buffer looks like this, where the \0 characters denote nulls:
1=item1\02=item2\03=item3\0\0

You would expect to declare the function using a StringBuilder object with a predefined length for the lpReturnedString buffer parameter, just as with the GetPrivateProfileString functionbut that doesnt work. When you call the function, it returns the proper number of characters, but the StringBuilder contains only the first item, 1=item1. However, the return value of the function contains 24, which is correctthe length of the text of the three

32

Windows Forms Solutions

items in the [textvalues] section plus one null character after each item. In other words, the StringBuilder buffer contains the second and third itemsbut you cant reach them; the first null character in the StringBuilder buffer determines the length of the contents available, and the StringBuilder throws an error if you attempt to index a character past that point. Obviously, you need to pass a managed type that isnt quite so sensitive to nulldelimited strings. Using a Char array doesnt work eitherthe function doesnt alter the array, even though it still returns the correct number of characters. Instead, after much fiddling with the problem, youll find that you can use a byte array. You can see the full declaration in Listing 1. When the function call returns, the byte array contains the entire set of items, separated with null characters, as expected. I havent found a truly simple way to convert the byte array to a set of strings; the best method Ive found is to iterate through the byte array creating the individual strings using a StringBuilder object. The sample GetINISection method here wraps the call to the GetPrivateProfileSection API, converts the returned items to strings, collects them in a StringCollection, and returns that to the calling code:
********************************************** * VB.NET code * ********************************************** Public Shared Function GetINISection(ByVal filename _ As String, ByVal section As String) _ As StringCollection Dim items As StringCollection = New _ StringCollection() Dim buffer(32768) As Byte Dim bufLen As Integer = 0 Dim sb As StringBuilder Dim i As Integer bufLen = GetPrivateProfileSection(section, _ buffer, buffer.GetUpperBound(0), filename) If bufLen > 0 Then sb = New StringBuilder() For i = 0 To bufLen - 1 If buffer(i) <> 0 Then sb.Append(ChrW(buffer(i))) Else If sb.Length > 0 Then items.Add(sb.ToString()) sb = New StringBuilder() End If End If Next End If

Solution 3 Upgrade Your INI Files to XML

33

Return items End Function

// ********************************************** // * C# code * // ********************************************** public static StringCollection GetINISection (String filename, String section) { StringCollection items = new StringCollection(); byte[] buffer = new byte[32768]; int bufLen=0; bufLen = GetPrivateProfileSection(section, buffer, buffer.GetUpperBound(0), filename); if (bufLen > 0) { StringBuilder sb = new StringBuilder(); for(int i=0; i < bufLen; i++) { if (buffer[i] != 0) { sb.Append((char) buffer[i]); } else { if (sb.Length > 0) { items.Add(sb.ToString()); sb = new StringBuilder(); } } } } return items; }

To use the method, add the line using System.Collections.Specialized; (in C#) or Imports System.Collections.Specialized (in VB.NET) to the top of the calling class, and then you can write code such as this:
********************************************** * VB.NET code * ********************************************** Dim s As String Dim items as StringCollection = _ INIFileInterop.GetINISection _ (c:\INIinterop.ini, textvalues) For Each s In items Debug.WriteLine(s) Next // **********************************************

34

Windows Forms Solutions

// * C# code * // ********************************************** StringCollection items = INIFileInterop.GetINISection (c:\INIinterop.ini, textvalues); foreach(String s in items) { Debug.WriteLine(s); }

The sample code in Listing 1 (downloadable from the Sybex Web site) includes an INIWrapper class (INIWrapper.cs in C#, INIWrapper.vb in VB.NET) that contains the API function prototypes and some wrapper methods to simplify calling the APIs. The classes work with any standard INI file. There are a few other Win32 API calls that work with INI files, and over the years, Ive found it useful to add wrapper functions that, for example, return just a list of keys in a section, or just the list of values from a section. Ive also found it useful to write wrapper functions to insert comments at various places. You can probably think of many more extensions to these simple classes. Finally, the code in Listing 1 is meant for example use only. You should add error trapping and checking. See the DllImportAttribute.SetLastError field and the Marshal.GetLastWin32Error method in the .NET Framework documentation for more information.

Listing 1

The VB.NET INIWrapper class (INIWrapper.vb)

Option Strict On Imports System Imports System.Runtime.InteropServices Imports System.Collections.Specialized Imports System.Text Imports System.IO Public Class INIWrapper Private Declare Ansi Function GetPrivateProfileString _ Lib KERNEL32.DLL Alias GetPrivateProfileStringA _ (ByVal lpAppName As String, ByVal lpKeyName As _ String, ByVal lpDefault As String, _ ByVal lpReturnedString As StringBuilder, _ ByVal nSize As Integer, _ ByVal lpFileName As String) As Integer Private Declare Ansi Function GetPrivateProfileInt _ Lib KERNEL32.DLL (ByVal lpAppName As String, _ ByVal lpKeyName As String, ByVal iDefault As _ Integer, ByVal lpFileName As String) As Integer Private Declare Ansi Function _ WritePrivateProfileString Lib KERNEL32.DLL _

Solution 3 Upgrade Your INI Files to XML

35

Alias WritePrivateProfileStringA _ (ByVal lpAppName As String, ByVal lpKeyName As _ String, ByVal lpString As String, ByVal lpFileName _ As String) As Boolean Private Declare Ansi Function GetPrivateProfileSection _ Lib KERNEL32.DLL Alias _ GetPrivateProfileSectionA _ (ByVal lpAppName As String, ByVal lpReturnedString _ As Byte(), ByVal nSize As Integer, ByVal lpFileName _ As String) As Integer Private Declare Ansi Function _ WritePrivateProfileSection _ Lib KERNEL32.DLL Alias _ WritePrivateProfileSectionA _ (ByVal lpAppName As String, ByVal data As Byte(), _ ByVal lpFileName As String) As Boolean Private Declare Ansi Function _ GetPrivateProfileSectionNames _ Lib KERNEL32.DLL Alias _ GetPrivateProfileSectionNamesA _ (ByVal lpReturnedString As Byte(), ByVal nSize As _ Integer, ByVal lpFileName As String) As Integer Public Shared Function GetINIValue _ (ByVal filename As String, ByVal section As String, _ ByVal key As String) As String Dim buffer As StringBuilder = New StringBuilder(256) Dim sDefault As String = If (GetPrivateProfileString(section, key, sDefault, _ buffer, buffer.Capacity, filename) <> 0) Then Return buffer.ToString() Else Return Nothing End If End Function Public Shared Function WriteINIValue _ (ByVal filename As String, ByVal section As String, _ ByVal key As String, ByVal sValue As String) _ As Boolean Return WritePrivateProfileString(section, key, _ sValue, filename) End Function Public Shared Function GetINIInt _ (ByVal filename As String, ByVal section As String, _ ByVal key As String) As Integer Dim iDefault As Integer = -1 Return GetPrivateProfileInt(section, key, iDefault, _ filename) End Function

36

Windows Forms Solutions

Public Shared Function GetINISection _ (ByVal filename As String, ByVal section As String) _ As StringCollection Dim items As StringCollection = New _ StringCollection() Dim buffer(32768) As Byte Dim bufLen As Integer = 0 Dim sb As StringBuilder Dim i As Integer bufLen = GetPrivateProfileSection(section, buffer, _ buffer.GetUpperBound(0), filename) If bufLen > 0 Then sb = New StringBuilder() For i = 0 To bufLen - 1 If buffer(i) <> 0 Then sb.Append(ChrW(buffer(i))) Else If sb.Length > 0 Then items.Add(sb.ToString()) sb = New StringBuilder() End If End If Next End If Return items End Function Public Shared Function WriteINISection _ (ByVal filename As String, ByVal section As String, _ ByVal items As StringCollection) As Boolean Dim b(32768) As Byte Dim j As Integer = 0 Dim s As String For Each s In items ASCIIEncoding.ASCII.GetBytes(s, 0, s.Length, b, j) j += s.Length b(j) = 0 j += 1 Next b(j) = 0 Return WritePrivateProfileSection(section, _ b, filename) End Function Public Shared Function GetINISectionNames _ (ByVal filename As String) As StringCollection Dim sections As StringCollection = New _ StringCollection() Dim buffer(32768) As Byte Dim bufLen As Integer = 0 Dim sb As StringBuilder Dim i As Integer bufLen = GetPrivateProfileSectionNames(buffer, _

Solution 3 Upgrade Your INI Files to XML

37

buffer.GetUpperBound(0), filename) If bufLen > 0 Then sb = New StringBuilder() For i = 0 To bufLen - 1 If buffer(i) <> 0 Then sb.Append(ChrW(buffer(i))) Else If sb.Length > 0 Then sections.Add(sb.ToString()) sb = New StringBuilder() End If End If Next End If Return sections End Function End Class

Moving Beyond Interop


All that COM Interop code is interesting, but simply reading existing INI files doesnt help you move them into XML. Youve seen how to read and write INI files with .NET using DllImport to access the Windows API functions from within a C# or VB.NET class. Wrapping the Windows API functions lets you use existing INI files from .NET, but doesnt address the problems inherent in the INI file format itselfand INI files have a number of deficiencies. For example, total file size is limited to 64 KB total, individual values cannot exceed 256 characters, and the Windows API provides no programmatic way to read and write comments. Translating the files to XML solves these problems. The first task is to analyze exactly how INI files are constructed and decide how best to preserve their advantage (a text format thats easy to read and modify) while maintaining and extending the programmatic capabilities.

The Ubiquitous INI File


An INI file has three types of information: sections, keys, and values. A section is a string enclosed in square brackets; the keys and values are paired. A key does not have to have a value, but when present, an equal sign (=) separates the key from the value. The keys and values together create an item. The items are always children of a section header. Each section can have any number of child items. For example, heres a simple INI file structure:
[Section 1] key=value [Section 2] key=value key=value

38

Windows Forms Solutions

INI files first appeared in Windows 3.x, and were originally intended to hold global Windows settings for various applications. Therefore, the section items of the INI file format were initially called applicationa name that persists in the Win32 API function calls to this day, even though the later documentation uses the section/key/value terminology. Windows provides special APIs to read and write the win.ini file in the Windows folder, as well as functionally identical private versions that read and write values from named INI files specific to one application. Windows provides a number of functions in kernel32.dll to read and write INI filesall of which are marked as obsolete ( see http://msdn.microsoft.com/
library/default.asp?url=/library/en-us/vbcon/html/vbconupgraderecommendationadjustdatatypesforwin32apis.asp for more information).

Microsoft began calling the API functions obsolete seven years ago with the release of Windows 95, when it began touting the Registry as the perfect place to store application-level settings. The current MDSN documentation states that the functions are supported only for backward compatibility with Win16. Nevertheless, INI files have remained popular among developers, partly because Microsoft never made the Registry easy to use programmatically and partly for many of the same reasons that XML became popular: INI files are easy to understand; easy to modify, either manually or programmatically; and you can simply copy them from one machine to another. The strongest evidence in favor of INI files is that despite Microsofts insistence that INI files are obsoletetheyre ubiquitous even in Microsoft software (search your local Documents and Settings\accountName\Local Settings\Application Data folder for examples).

Why Not Use .NET Configuration Files?


Your .NET applications are supposed to use the AppSettings section of configuration files to store key-value pair information. Unfortunately, the INI file format doesnt translate well to a simple list of key-value pairs, because you lose a level of information. Items in INI files exist as children of named sections; therefore, many INI files repeat key names for each section. Any line in an INI file that starts with a semicolon is a comment. For example, the sample code for this chapter uses this INI file:
; Company employees [Employee1] name=Bob Johnson department=Accounting [Employee2] name=Susan Fielding department=Sales

If you were to remove the section names and attempt to place this information into a configuration file as key-value pairs, you would have duplicate key names. In other words, the

Solution 3 Upgrade Your INI Files to XML

39

default AppSettings data provides no way to group the items into something equivalent to INI sections. You could write a custom configuration handler to handle more levels of structure than configuration files support by default, but using a separate file is easier.

The XML INI File Structure


The IniFileReader project sample code for this solution reads standard INI files or XMLformatted INI files. Table 1 shows the IniFileReaderNotInitialized exception property, and Table 2 shows a complete list of methods and properties for the IniFileReader class.
TA B L E 1 : Class: IniFileReaderNotInitializedException

Property Type
Message (read-only)

Description
String The IniFileReader class throws this error when it cant successfully read the file passed to the constructor.

TA B L E 2 : Class: IniFileReader

Property/Method
Message (read-only) SetIniSection SetIniValue

Type
String Boolean Boolean

Description
The IniFileReader class throws this error when it cant successfully read the file passed to the constructor. Updates a section name. Updates the value associated with a specified section and key. When the keyName parameter is null, the method deletes the section. When the value parameter is null, the method deletes the specified key. Otherwise, if the specified sectionName or keyName do not already exist, the method creates them. If both the specified sectionName and keyName exist, the method updates the value with the string contained in the value parameter. Updates a key name. Retrieves the value associated with a specified section and key. Retrieves a StringCollection filled with all the comments associated with a specified section. Writes a set of comments contained in a StringCollection parameter to the specified section. The method first removes any existing comments. In this implementation, you must set and retrieve all comments at one time.
Continued on next page

SetIniKey GetIniValue GetIniComments SetIniComments

Boolean String StringCollection Boolean

40

Windows Forms Solutions

TA B L E 2 : C O N T I N U E D Class: IniFileReader

Property/Method
AllKeysInSection AllValuesInSection AllItemsInSection

Type
StringCollection StringCollection StringCollection

Description
Returns a StringCollection object filled with the key names associated with a specified section. Returns a StringCollection object filled with all the values associated with all keys in the specified section. Returns a StringCollection object filled with all the keys and values from the specific section. Each item has the form key=value. Returns the value of a specified attribute associated with a specified key and section. Updates the value of a specified attribute associated with a specified section and key. If the attribute does not exist, the method creates it. Saves the XML-formatted INI file to the file specified using the SaveAs property. Performs an XSLT transform to translate the loaded XML-formatted INI file back to a standard text INI file and returns the results as a string. Returns the name of the file passed to the new constructor. Returns true when the class has been properly initialized with a standard INI or XML-formatted INI file. Returns a Boolean indicating whether the instance is case-sensitive or case-insensitive. When case-sensitive, all section, key, and attribute name parameters to the methods must match those in the source file exactly. The default is false (not case sensitive). Returns a StringCollection object filled with the section names that exist in the file. The filename to which the class will save the XML-formatted INI file during the next Save operation. Returns the XmlDocument object containing the current XML-formatted INI file. Returns a string containing the XML-formatted contents of the current INI file.

GetCustomIniAttribute SetCustomIniAttribute

String Boolean

Save AsIniFile

void String

IniFilename (read-only) Initialized (read-only) CaseSensitive (read-only)

String Boolean Boolean

AllSections (read-only) SaveAs XmlDoc XML

StringCollection String XmlDocument String

The IniFileReader class constructor requires a filename. The class first tries to open the specified file as an XML file, using the XmlDocument.Load method. If that fails, the class assumes the file is in the INI format. It then creates a very simple default XML string and loads that, using the XmlDocument.LoadXml method, after which it opens the file in text mode and parses the lines of the file, adding elements for the sections, items, and comments in the order they appear (see Listing 2).

Solution 3 Upgrade Your INI Files to XML

41

Listing 2

The ParseLineXML (IniFileReader.vb)

Private Sub ParseLineXml(ByVal s As String, _ ByVal doc As XmlDocument) Dim key As String Dim value As String Dim N As XmlElement Dim Natt As XmlAttribute Dim parts() As String s.TrimStart() If s.Length = 0 Then Return End If Select Case (s.Substring(0, 1)) Case [ this is a section trim the first and last characters s = s.TrimStart([) s = s.TrimEnd(]) create a new section element CreateSection(s) Case ; new comment N = doc.CreateElement(comment) N.InnerText = s.Substring(1) GetLastSection().AppendChild(N) Case Else split the string on the = sign, if present If (s.IndexOf(=) > 0) Then parts = s.Split(=) key = parts(0).Trim() value = parts(1).Trim() Else key = s value = End If N = doc.CreateElement(item) Natt = doc.CreateAttribute(key) Natt.Value = SetNameCase(key) N.Attributes.SetNamedItem(Natt) Natt = doc.CreateAttribute(value) Natt.Value = value N.Attributes.SetNamedItem(Natt) GetLastSection().AppendChild(N) End Select End Sub

42

Windows Forms Solutions

The sample INI file looks like this after the IniFileReader finishes loading it:
<?xml version=1.0 encoding=UTF-8?> <sections> <comment> Company employees</comment> <section name=employee1> <item key=name value=Bob Johnson /> <item key=department value=Accounting /> </section> <section name=employee2> <item key=name value=Susan Fielding /> <item key=department value=Sales /> </section> </sections>

Getting the INI file contents into the simple XML structure makes it easy to mimic and extend the actions that you can perform with a standard INI fileand makes them easier to remember. The root <sections> element can contain any number of child <comment> or <section> elements, each of which can contain any number of <item> or <comment> elements. You may be wondering why the project doesnt use the standard API functions through DllImportas discussed in the article Use COM Interop to Read and Write to INI Files with .NET (see www.devx.com/dotnet/discussions/040902/cominterop.asp). This is because the standard API functions provide no way to read comments, so you cant get a complete translation using the API functions alone. Instead, for this particular purpose, its better to parse the file line by line.

Retrieving Values
To retrieve the value of an item, you use the GetIniValue method, which accepts sectionName and keyName parameters. The method creates an XPath query that searches for the section with a name attribute matching the supplied section name, and then searches within that section for an item with a key attribute matching the supplied key name. If the XPath query matches an item, the function returns the text value of the value attribute of that item; otherwise it returns Nothing (null in C#).
********************************************** * VB.NET code * ********************************************** Public Function GetIniValue( _ ByVal sectionName As String, _ ByVal keyName As String) As String If Not Initialized Then Throw New _ IniFileReaderNotInitializedException() Dim N As XmlNode = GetItem(sectionName, keyName)

Solution 3 Upgrade Your INI Files to XML

43

If Not N Is Nothing Then Return (N.Attributes.GetNamedItem(value).Value) End If Return Nothing End Function

There is one major difference between XML-formatted files and INI filesXML files are case sensitive, while standard INI files arent. The INIFileReader class deals with this potential problem by treating all data as case-insensitive by default. The following section provides a more detailed discussion of the problem and the solution.

Dealing with XMLs Case Sensitivity


Case sensitivity is an intrinsic feature of many languages, and XML is no exception. Unfortunately, case sensitivity is also one common source of bugs in code. Because the old INI files were not case-sensitive, translating them directly to XML risks breaking existing code logic because of the difference in the way the Windows API function treats case and the way an XML parser treats case. Therefore, the IniFileReader class uses only lowercase tags by default; however, it treats queries in a case-insensitive manner by first converting the section or key names to lowercase and then performing the query. While the change in case sensitivity might cause problems for some file types due to section or key name conflicts where the only difference was in case, its not a problem for INI filesthe API functions for INI file update and retrieval arent case-sensitive either. Therefore, translating all the section and key names to lowercase has no effect on file modificationsit just makes the names look different. If you prefer to use the IniFileReader in case-sensitive mode, you can set the CaseSensitive property to False in VB.NET (false in C#). If you prefer this, remember that you must set the CaseSensitive property when you instantiate an IniFileReader using the optional overloaded constructor as shown in the following code snippet. After creating an instance of the class, theres no way to change the CaseSensitive property value.
ifr = new IniFilereader(someFilename, True);

All methods that retrieve nodes apply the results of the private setNameCase method to parameters. The method ensures that names are shifted to lowercase when CaseSensitive is true.
Private Function SetNameCase( _ ByVal aName As String) As String If (CaseSensitive) Then Return aName Else Return aName.ToLower() End If End Function

44

Windows Forms Solutions

So to retrieve a section, for example, the GetSection code checks to ensure that the sectionName argument is not Nothing or an empty string, and then calls SetNameCase before constructing the XPath query that searches for the section node:
Private Function GetSection( _ ByVal sectionName As String) As XmlElement If (Not (sectionName = Nothing)) AndAlso _ (sectionName <> String.Empty) Then sectionName = SetNameCase(sectionName) Return CType(m_XmlDoc.SelectSingleNode _ (//section[@name= & sectionName & ]), _ XmlElement) End If Return Nothing End Function

Updating and Adding Items


Updating individual values is similar to retrieving them. You provide a section name, a key name, and the new value. The class uses the GetItem method to locate the appropriate <item> element, and then updates that items value attribute with the specified new value. The Windows API function WritePrivateProfileString creates new sections and items if you call it with a section or key name that doesnt already exist. Although its not good object-oriented design, for consistency the IniFileReader class acts identically, meaning that you can create a new section simply by passing a nonexistent section name. To update a section name, key name, or value for existing sections or items, select the section and item you want to update, enter the new values in the appropriate fields, and then click the Update button to apply the changes. To create a new section or key on the sample form, first click the New Section or New Key button to clear the current selections, and then enter the new section or key name and click the Update button to apply your changes. To delete a section using the API, you pass the section name and a null key value to the WritePrivateProfileString functionand you do the same with the IniFileReader class, except that you use the SetIniValue method. For example, the following code would delete the section named section1.
SetIniValue(section1, Nothing, Nothing)

Similarly, to delete an item within a section, you pass the section name, the key name, and a null value for the value argument. The following code would delete the item with the key key1 in the section named section1.
SetIniValue(section1, key1, Nothing)

Solution 3 Upgrade Your INI Files to XML

45

On the sample form (see Figure 1), you can delete a section or value by selecting the appropriate item in either the section or item list, and then pressing the Delete key. FIGURE 1:
The sample form lets you test the INIFileReader methods and edit INI files.

The class has several other properties that may interest you. First, the Save method saves the file to a filename you specify using the OutputFilename property. The Save method checks whether the specified directory exists and then uses the Save method of the underlying XmlDocument to save the file. Second, the XmlDoc property gives the calling code direct access to the underlying XmlDocument object. The IniFileReader class has a number of properties that extend the standard API functionality. For example, the GetAllSections method retrieves all the section names. Whenever the class returns multiple values, it returns a StringCollection object rather than a string array. While this marginally affects the classs performance, from the caller point of view StringCollections are more convenient than simple arrays.

46

Windows Forms Solutions

Extensibility
The three types of information in a standard INI file often did not suffice to store the information needed by the application. Ive seen (and built) applications that rely on custom string formats to perform tasks beyond the INI file formats native capabilities. For example, its not uncommon to see INI files containing strings that use separators to squash more values into an INI file:
[testing] emp1=Bob Johnson|Accounting|04/03/2001 8:23:14|85 emp2=Susan Fielding|Sales|03/23/2001 15:41:48|92

Not only are such files difficult to read and editand maintainbut they also require custom code to retrieve the item values and assign them to variables within the program. The data in such files is unusable by other programs without re-creating the code to retrieve the data. In contrast, you can add new items to an XML-formatted INI file with few problems. At the most simplistic level, you can use the GetCustomIniAttribute and SetCustomIniAttribute methods to read, write, and delete custom strings, stored as attributes within the <item> elements. For example, the following XML document shows the same data shown in the preceding INI file added as custom attributes:
<?xml version=1.0 encoding=UTF-8?> <sections> <comment>Company employees</comment> <section name=employees> <item key=employee1 value=Bob Johnson department=Accounting testedon=04/03/2001 8:23:14 score=85/> <item key=employee2 value=Susan Fielding department=Sales testedon=03/23/2001 15:41:48 score=92 /> </section> </sections>

Its much easier to discover the meaning of the data in the XML version. At a more complex level, although I havent implemented it in the sample code, you could add GetCustomIniElement and SetCustomIniElement methods to add custom child elements and values to the <item> elements. These methods would be overloaded to accept an element name and value, an XmlElement instance, or an XmlDocumentFragment instance, so you

Solution 3 Upgrade Your INI Files to XML

47

could make the file as complicated as necessary. The Extensibility button on the sample form contains code that shows how to use the extensibility methods. Beyond the built-in extensibility methods, you can, of course, subclass the IniFileReader and override or add methods to do anything you like.

Dealing with Comments


You use the SetIniComments and GetIniComments methods to add and retrieve comments. The GetIniComments method returns a StringCollection containing the text of comments at either the file or section level. The SetIniComments method accepts a StringCollection containing a list of comments you want to add at either the file or section level. While this implementation is very crude, and could be greatly improvedfor example, you could extend the class to attach comments directly to individual itemsits already an improvement over standard INI files, which provide no way to create or remove comments automatically. You can also add XML-formatted comments manually or use the DOM directly to add comments to an XML-formatted INI file.

Wheres My INI File?


For straightforward (unextended) files, you can get the original INI file back. In some cases, you will want to read and modify the INI file with a .NET application, but you still need access to the file in its original format usable by pre-.NET Windows applications. An XSLT transform performs the work of turning the file back into a standard INI file. You initiate the transform using the SaveAsIniFile method. However, extensibility comes at a price. If you make custom modifications to the file using the writeCustomIniAttribute method, those changes will not appear in the results of the transform; however, theres little reason to translate the files back to standard INI format, so that restriction seems reasonable. Having a separate file for storing application initialization information is a good idea. The .NET Framework contains built-in methods for handling configuration data, butas deliveredthey arent suitable for complex information structures, nor are they dynamically updatable using the Configuration classes in the Framework. As you migrate existing applications into .NET, the INIFileReader class described in this solution lets you use and even extend your existing INI files. Nonetheless, .NET configuration files have some advantages even over these custom external XML-formatted initialization files, and you should study their capabilities.

48

Windows Forms Solutions

SOLUTION

4
SOLUTION

Build Your Own XML-Enabled Windows Forms TreeView Control


PROBLEM

Displaying XML in a TreeView seems like it would be a no-brainerand for some simple, regular XML documents, it is. But for complex, mixed-content documents, or documents with content split between attributes and elements, or documents containing unneeded nodes, displaying the XML becomes much more complicated.

Because of XMLs simple, repeating syntax, you can treat any wellformed XML document generically. Create this XML-enabled TreeView control and display customized views of any wellformed XML document.

The .NET Windows Forms TreeView control lets you display a hierarchical view of information; therefore, its a perfect match for displaying data in XML documents, which is innately hierarchical. But the Windows Forms TreeView control cant display an XML document nativelyif you want to display XML in a TreeView, you have to add the functionality yourself. The basic process is simple; iterate through the nodes in the XML document. For each node, add a new TreeNode and set its text to the text of the current XML nodeand thats the idea behind most of the examples youll find about filling TreeView controls with XML data. But those examples use XML thats conducive to TreeView display; when you try to use them directly on many XML documents, they workbut not the way you want them to work.

Iterating through Nodes


The iteration process is recursive. A method such as the AddNode method shown in the following code snippet (from http://support.microsoft.com/default.aspx?scid=kb;ENUS;Q308063) expects an XmlNode argument and a TreeNode argument. The method tests to see if the XML node has child nodes. If so, it gets a list of the child nodes and iterates through them, creating a new TreeNode and calling itself recursively for each child node. Finally, it sets the text of the TreeNode to the OuterXML property of the XML node. The comments in the code indicate that you might want to change that based on the type of node.
Private Sub AddNode(ByRef inXmlNode As XmlNode, _ ByRef inTreeNode As TreeNode)

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

49

Dim Dim Dim Dim

xNode As XmlNode tNode As TreeNode nodeList As XmlNodeList i As Long

Loop through the XML nodes until the leaf is reached. Add the nodes to the TreeView during the looping process. If inXmlNode.HasChildNodes() Then nodeList = inXmlNode.ChildNodes For i = 0 To nodeList.Count - 1 xNode = inXmlNode.ChildNodes(i) inTreeNode.Nodes.Add(New TreeNode(xNode.Name)) tNode = inTreeNode.Nodes(i) AddNode(xNode, tNode) Next Else Here you need to pull the data from the XmlNode based on the type of node, whether attribute values are required, and so forth. inTreeNode.Text = (inXmlNode.OuterXml).Trim End If End Sub

So basically, if you pass the AddNode method the root element of an XML document, it will fill the TreeView control with the XML of each node in the document. Heres another example. Suppose you have a simple XML document that looks like Listing 1.

Listing 1

A simple XML document (SimpleXML.xml)

<?xml version=1.0 encoding=Windows-1252?> <departments> <department id=d3 name=IT expanded=False> <employees> <employee id=e50> <lastname>Chen</lastname> <firstname>Kelly</firstname> <address><![CDATA[37118 Second Hill Dr.]]></address> <email>kchen@company.com</email> <phones> <work>(253) 703-7277</work> <home>(253) 703-3168</home> <fax>(253) 703-5633</fax> </phones> <active>True</active> </employee> <employee id=e45> <lastname>Chen</lastname>

50

Windows Forms Solutions

<firstname>William</firstname> <address>133 S. Chilhowee Dr.</address> <email>wchen@company.com</email> <phones> <work>(221) 410-3615</work> <home>(221) 410-0402</home> <fax>(221) 410-3788</fax> </phones> <active>False</active> </employee> <!-- more employees here --> </employees> </department> <!-- more departments here --> </departments>

To see the results, you create an XmlDocument object and load the XML file into it, create a root TreeView node, and call the AddNode method, passing the XmlDocument.DocumentElement and the root TreeNode as arguments. For example, the following code loads the sample employee.xml file:
Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Dim doc As New XmlDocument() Dim execFile As FileInfo = New _ FileInfo(Application.ExecutablePath) Dim fldr As DirectoryInfo = execFile.Directory fldr = fldr.Parent Dim filename As String = \employees.xml doc.Load(fldr.FullName & filename) Dim tNode As TreeNode = Me.TreeView1.Nodes.Add _ (doc.DocumentElement.Name) AddNode(doc.DocumentElement, tNode) Me.TreeView1.ExpandAll() End Sub

The results look like Figure 1. The first thing youll probably notice is that it looks different than you might expect. Sure, it contains the data, but it also has a number of little problems. First, every node has a child node. Because the final line in the Form1_Load code calls the TreeViews ExpandAll method, it looks slightly less onerous than it is. But if you collapse all the nodes in one of the employee nodes, for example, youll find that to see any value, your users will have to drill down one more level than you might think. In addition, your users probably dont care about the XML document element namesand you might not even want your users to see those names.

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

51

FIGURE 1:
The results of loading a TreeView control with XML using the AddNode method

To be fair, the comment at the bottom of the AddNode method states that you would probably want to customize it based on your needs. In this case, it would be much better if you could ignore the <employees> nodes and simply show the names of each department, with the names of the employees below that. Similarly, note that the AddNode method doesnt show attributes. You would probably want to show the name of each department, which, in the employees.xml file, resides in an attribute of each <department> node. To do that, youd need to add code in the Else block, such as
If inXmlNode.LocalName = department Then inTreeNode.Text = inXmlNode.getAttribute(name) End If

Its easy to create such custom code and make it work for this document. But if you take that route for every XML document you want to display, youll have to customize an AddNode implementation each and every time. Theres a better way.

What Capabilities Do You Need?


Rather than writing custom code, consider the functionality you might like to have in a TreeView that displays XML. The simplest way to extend the functionality of controls within the .NET Framework is to subclass them, so one solution is to create a TreeViewXml control thats capable of displaying XML in a TreeView control in customized ways.

52

Windows Forms Solutions

Right off the bat, youll probably decide that its usually much more intuitive to show the text string for a node than to show the tag names. The tag names may (or may not) help users understand exactly what theyre looking at. For example, theres little point in showing this in a TreeView
<departments> <department> IT <department> Marketing <department> Manufacturing

when you could show this:


departments IT Marketing Manufacturing

The second version displays only the data, not the element names, but the intent is clear, and its far easier to read. To display the data without the names, you must be able to identify elements based on their relative hierarchical position in the document. Youll also want to be able to hide elements based on their name or their position relative to the document itselfin other words, their path. Heres the full path for the department name attribute and the type:
departments/department/name departments/department/employees/employee/name

Using the node path rather than a simple name clearly differentiates between two identically named nodes in different locations within the node tree. While many XML documents already have exactly the content you want in exactly the format you need, many dont. The goal is to identify the nodes you want to change (using their path) and then alter the contents. For example, you dont have to show the <department> tag; you want to show the name attribute value instead. Similarly, the <employee> nodes have <lastname> and <firstname> children, but rather than displaying them separately, it would be far friendlier to concatenate them, displaying the name as a single lastname, firstname string. In other words, you want the capability to surface attributes and child elements and display them at the parent level. In the sample code for this solution, Ive termed that functionality display rules. The code that defines each rule is localized to the DisplayRule class.

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

53

The DisplayRule Class


The following code contains five types of display rules, exemplified by the DisplayRuleContentType enumeration in the class:
Public Enum DisplayRuleContentType ShowOnlyNodesNamed HideNodesNamed ShowOnlyNodesWithPath HideNodesWithPath XpathQuery XslTemplate End Enum

These five are by no means comprehensive, and you may find many ways to extend the class to provide additional customizing capabilities. To do that, you can add new types to the enumeration. However, these five are sufficient for many purposes. Table 1 shows the effect of each DisplayRuleContentType.
TA B L E 1 : Display Rules

DisplayRuleContentType
ShowOnlyNodesNamed HideNodesNamed ShowOnlyNodesWithPath HideNodesWithPath XpathQuery XslTemplate

Rule Effect
Causes the TreeViewXml control to hide all nodes not associated with the rule type Causes the TreeViewXml control to hide nodes associated with the rule type Causes the TreeViewXml control to hide all nodes whose path does not match one of the paths associated with the rule type Causes the TreeViewXml control to hide all nodes whose path matches one of the paths associated with the rule type Causes the TreeViewXml control to display the result of an XPath query for all nodes whose path matches the path associated with the rule type Causes the TreeViewXml control to display the result of an XSLT style sheet applied to all nodes whose path matches the path associated with the rule type

The first four types simply hide or show nodes. The ShowOnlyNodesNamed and HideNodesNamed types show and hide nodes, respectively; the difference is that when you add nodes to the ShowOnlyNodesNamed list, the TreeViewXml control shows only nodes associated with that rulein other words, if you begin adding element names to this rule type, you must add all the names you want to display. In contrast, the HideNodesNamed rule simply hides the nodes associated with the rule. The ShowOnlyNodesWithPath and HideNodesWithPath rules work identically but use node paths rather than the node LocalName property.

54

Windows Forms Solutions

DisplayRule Properties
Each DisplayRule has three properties: Name, Value, and DisplayRuleType. The DisplayRuleType property is one of the DisplayRuleContentType enumeration values shown in Table 1. The Name property holds either a simple node LocalName or a path from the document root to some element. For example, employees is the LocalName for all the <employees> elements. In contrast, the path from the document root to each <employees> element is departments/ department/employees. This dual naming scheme simplifies element identification; when the element names are unique within the document, you can use the LocalName, and when theyre not, you can use the path. Each DisplayRule applies either to all elements whose LocalName (such as employees or address) matches the value of its Name propertyregardless of where those elements appear in the documentor where the path stored in the Name property matches the path to a node in the document. For example, a DisplayRule.Name value of departments/department/ employees/employee/lastname applies to all <lastname> child elements of each <employee> element. The Value property holds either an XPath query or an XslTransform object. Simple show/hide DisplayRule types dont require a value; you need the Value property only when you plan to alter the display value of an element or attribute.

Creating DisplayRules
You create display rules by adding them to a DisplayRulesCollection exposed by the custom TreeViewXml class. For example, using the sample document from Listing 1, you can hide everything but the
<departments> and <department> nodes by creating two DisplayRule objects whose Name property contains the strings departments and department, respectively, and the DisplayRuleType property is ShowOnlyNodesNamed.

As another example, the <department> nodes contain a name attribute. To display, or surface, that name attribute in the TreeNode for each <department> node, you can create a DisplayRule that identifies <department> nodes by their path and then display the result of an XPath query that retrieves the name attribute value. In other words, the Name property would be departments/department, the Value property would be the XPath query @name, and the DisplayRuleType property would be XpathQuery. Simple XPath substitutions work fine for surfacing attributes, but not so well for surfacing or concatenating child elements. Therefore, display rules also accept an instance of the XslTransform class. The rule substitutes the output of the XslTransform applied to the nodes

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

55

identified by the DisplayRule. For example, to obtain a string containing each employees ID followed by that employees name in lastname/firstname order, you could write this XSLT style sheet:
<?xml version=1.0 encoding=UTF-8?> <xsl:stylesheet version=1.0 xmlns:xsl=http://www.w3.org/1999/XSL/Transform> <xsl:output method=text encoding=UTF-8/> <xsl:template match=employee> <xsl:value-of select=@id/>: <xsl:value-of select=lastname />, <xsl:value-of select=firstname /> </xsl:template></xsl:stylesheet>

When you transform an employee node using this style sheet, the output is a string that looks like this:
e45: Chen, Kelly

In other words, the text assigned to the TreeNode for each <employee> node is the output of the transformation.

Finding Node Paths


To identify nodes unambiguously based on their path, you must be able to obtain the path, given any node in an XML document. You can do that by walking recursively up the Document Object Model (DOM) tree until you reach the document node, concatenating a string that describes the relative hierarchical position of the node in question. Listing 2 shows a getPath method that returns the path string for a node.

Listing 2

The getPath method returns the path from the document root to any child node. (TreeViewXml.vb)

Private Function getPath(ByVal aNode As XmlNode, _ ByVal sPath As String) As String If sPath Is Nothing Or sPath Is String.Empty Then If N.NodeType = XmlNodeType.Attribute Then sPath = @ & N.LocalName Else sPath = N.LocalName End If Else sPath = N.LocalName & / & sPath End If If Not N.ParentNode Is Nothing Then

56

Windows Forms Solutions

call this function recursively until you reach the document node If N.ParentNode.NodeType <> XmlNodeType.Document Then sPath = getPath(N.ParentNode, sPath) End If End If Return sPath End Function

The method begins by checking the length of the sPath String argument. If its empty, the method sets it to the LocalName property of the XmlNode argument. Next, the method checks to see if the Parent property of the XmlNode is Nothing. If not, and the parent node is not the root node (the XmlNodeType.Document node), it calls itself recursively, passing the current value of sPath and the parent node as arguments. In effect, this builds the path string by moving upward through the document. The process stops when the current nodes parent is the root node. Using the concatenated path string, you can check each node against a set of display rules to see how to treat that node. Display rules consist of a key-value pair where the key is a path string and the value is either an XPath string or an XslTransform object. The DisplayRulesCollection class shown in Listing 3 implements the collection.

Listing 3

The DisplayRulesCollection class contains a collection of key-value pairs used to determine node display values. (DisplayRulesCollection.vb)

Option Strict On Imports System.Collections.Specialized Imports System.Xml.Xsl Public Class DisplayRulesCollection Inherits System.Collections.CollectionBase Public Sub Add(ByVal aDisplayRule As DisplayRule) Me.List.Add(aDisplayRule) End Sub Public Sub Add(ByVal nodeName As String, _ ByVal aType As DisplayRule.DisplayRuleContentType) Me.Add(New DisplayRule(nodeName, aType)) End Sub Public Sub Add(ByVal nodePath As String, _ ByVal anXPathQuery As String, _ ByVal aType As DisplayRule.DisplayRuleContentType) Me.Add(New DisplayRule(nodePath, anXPathQuery, _ DisplayRule.DisplayRuleContentType.XpathQuery)) End Sub Public Sub Add(ByVal nodePath As String, _ ByVal anXslTransform As XslTransform, _

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

57

ByVal aType As DisplayRule.DisplayRuleContentType) Me.Add(New DisplayRule(nodePath, anXslTransform, _ DisplayRule.DisplayRuleContentType.XslTemplate)) End Sub Public Sub Add(ByVal nodeNames As String(), _ ByVal aType As DisplayRule.DisplayRuleContentType) Dim nodeName As String For Each nodeName In nodeNames Me.Add(New DisplayRule(nodeName, aType)) Next End Sub Public Sub Remove(ByVal key As String) Dim dr As DisplayRule Dim o As Object For Each o In Me.List dr = DirectCast(o, DisplayRule) If dr.Name = key Then Me.List.Remove(o) End If Next End Sub Public Sub Remove(ByVal key As String, _ ByVal aType As DisplayRule.DisplayRuleContentType) Dim dr As DisplayRule Dim o As Object For Each o In Me.List dr = DirectCast(o, DisplayRule) If dr.Name = key AndAlso _ dr.DisplayRuleType = aType Then Me.List.Remove(o) End If Next End Sub Public ReadOnly Property GetRulesOfType( _ ByVal aType As DisplayRule.DisplayRuleContentType) _ As DisplayRuleSet Get Dim dr As DisplayRule Dim o As Object Dim drList As New DisplayRuleSet() For Each o In Me.List dr = DirectCast(o, DisplayRule) If dr.DisplayRuleType = aType Then drList.Add(dr) End If Next Return drList End Get End Property End Class

58

Windows Forms Solutions

The class is by no means a complete typed collection implementationit contains only the functionality needed for the sample project that accompanies this solution. Note the overridden Add methods, which simplify adding the various types of DisplayRules you can add to the class. The DisplayRulesCollection class makes it easy to retrieve any rules that apply to a specific DisplayRuleContentType. The GetRulesOfType method returns all the rules that match a specific type as a DisplayRuleSet object. Because the DisplayRuleSet class inherits from DictionaryBase, you can then use the Exists method to test whether a particular nodes LocalName or path matches an existing rule. So far, youve seen how to fill a TreeView with XML using a recursive method, how to identify specific nodes so you can alter the display characteristics for those nodes, and how you can use XPath and XSLT style sheets to manipulate the text content displayed for any particular node. All you need to do now is wrap all that up into a class that provides all the functionality.

The TreeViewXml Control


The TreeViewXml class in the sample project inherits from TreeView, and adds the capabilities you need. To display an XML document in a TreeViewXml instance, you call its overloaded LoadXml method, passing the name of an XML file, an XML string, or a populated XmlDocument object. In all cases, the controls response is the sameit clears any existing nodes, and then populates itself from the specified data source. At the simplest level, you can simply pass a filename or string containing XML to the control. When you do that, the control displays all elements and all attributes. The sample project form named Form2 displays a TreeViewXml control and three buttons. NOTE
Youll need to switch the startup form to Form2 in the project properties dialog box to run Form2.

At startup, the form retrieves a string containing the contents of an embedded XML resource file named employees.xml and stores it in a String variable named employeesXml using the following code:
Dim sr As New System.IO.StreamReader( _ [Assembly].GetEntryAssembly. _ GetManifestResourceStream( _ TreeViewXMLTest.employees.xml)) employeesXml = sr.ReadToEnd sr.Close()

If you werent aware that you could embed files as resources in your .NET assemblies, see Anthony Glenwrights excellent article, How to Embed Resource Files in .NET Assemblies, which you can find at www.devx.com/dotnet/Article/10831/0/page/1.

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

59

Using the TreeViewXml Control


The form creates the TreeViewXml control at startup and uses the variable tvx1 to refer to the control throughout the class. The Default XML Load button sets a few properties and displays the employees.xml string by calling the TreeViewXml controls LoadXml method:
Private Sub btnDefaultLoad_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) _ Handles btnDefaultLoad.Click tvx1.ShowAttributesAsChildren = True tvx1.AttributeColor = Color.Red tvx1.DisplayRules.Clear() show the tree tvx1.LoadXml(employeesXml) End Sub

The ShowAttributesAsChildren property takes a Boolean value and controls whether the TreeViewXml instance displays attributes as child nodes below each element. By default, the value is False. The AttributeColor property accepts or returns a Color value that determines the color in which the control displays attributes. The default attribute color is Color.Blue. The code clears the DisplayRules collection to ensure that the XML will display without applying any rules that may have been added when you clicked the other buttons on the form. Figure 2 shows Form2 after clicking the Default XML Load button. FIGURE 2:
The default XML string load

60

Windows Forms Solutions

Using the DisplayRules collection, a few simple commands can alter the display of the XML content radically. For example, the Show Only Departments button causes the control to display only the root <departments> node and the child nodes for each department. It uses one DisplayRule to limit the tree display to only those two elements, and a second to apply an XPath query result to each <department> element, causing the tree to display the value of that elements name attribute (see Listing 4).

Listing 4

Using DisplayRules to control the tree display

Private Sub btnDepartments_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) _ Handles btnDepartments.Click tvx1.ShowAttributesAsChildren = False tvx1.DisplayRules.Clear() display only departments and department elements tvx1.DisplayRules.Add( _ New String() {departments, department}, _ DisplayRule.DisplayRuleContentType.ShowOnlyNodesNamed) Display the value of the name attribute for each department tvx1.DisplayRules.Add(departments/department, _ ./@name, _ DisplayRule.DisplayRuleContentType.XpathQuery) load the tree tvx1.LoadXml(employeesXml) End Sub

Figure 3 shows the results. You can alter the display even further by using an XSLT style sheet to modify the display for specific nodes. Clicking the Show Employees Names button displays all the departments and all the employees within each department, showing each employees id attribute value and the text values of its child <lastname> and <firstname> concatenated into a single string, as discussed earlier in the section What Capabilities Do You Need. The button code obtains the style sheet by reading another embedded resource file named ConcatEmpNames.xsl. Again, the code for the button Click event shown in Listing 5 simply creates the DisplayRules and calls the LoadXml method.

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

61

FIGURE 3:
Displaying only departments

Listing 5

Using an XSLT style sheet to display customized node content

Private Sub btnEmpNames_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) _ Handles btnEmpNames.Click tvx1.ShowAttributesAsChildren = False tvx1.DisplayRules.Clear() display only the following elements tvx1.DisplayRules.Add(New String() {departments, _ department, employees, employee}, _ DisplayRule.DisplayRuleContentType. _ ShowOnlyNodesNamed) display department names tvx1.DisplayRules.Add(departments/department, _ ./@name, _ DisplayRule.DisplayRuleContentType.XpathQuery) load the XSLT stylesheet Dim template As New XslTransform() template.Load(New XmlTextReader( _ New System.IO.StringReader(concatEmpNameXsl)))

62

Windows Forms Solutions

add a DisplayRule for employee nodes using the XSLT stylesheet and a DisplayRuleContentType of XslTemplate tvx1.DisplayRules.Add( _ departments/department/employees/employee, _ template, _ DisplayRule.DisplayRuleContentType.XslTemplate) tvx1.LoadXml(employeesXml) End Sub

Figure 4 shows the results. FIGURE 4:


Using XSLT to display custom employee node strings

How TreeViewXml Works


When you call the TreeViewXml classs LoadXml method, the control creates one DisplayRuleSet object for each DisplayRuleContentType by calling the private InitializeDisplayRuleSets method, which in turn calls the DisplayRulesCollection.GetRulesOfType method:
Private Sub InitializeDisplayRuleSets() for each type in the DisplayRule.DisplayRuleContentType enumeration get a DisplayRuleCollection containing only rules of that type mShowOnlyNodesNamed = Me.DisplayRules.GetRulesOfType _

Solution 4 Build Your Own XML-Enabled Windows Forms TreeView Control

63

(DisplayRule.DisplayRuleContentType. _ ShowOnlyNodesNamed) Me.mShowOnlyNodesWithPath = _ Me.DisplayRules.GetRulesOfType _ (DisplayRule.DisplayRuleContentType. _ ShowOnlyNodesWithPath) ... additional calls to GetRulesOfType here End Sub

Next, the control loads the XML passed to the LoadXml method (except for the overloaded version that accepts an XmlDocument object), calls the BeginUpdate method, clears any existing nodes from the TreeView control, and then attempts to create a set of TreeNodes using the XML by passing the document element to the FillTree method. The FillTree method recursively fills the tree in a manner similar to the AddNode method shown in the section Iterating through Nodes; however, it adds checks to determine whether it should display each node based on the various hide/show DisplayRuleSets, using code such as the following:
the ShowOnlyNodesNamed list contains a list of element names that you want to show. Check to see if this node is in that list. If Me.mShowOnlyNodesNamed.Count > 0 Then showNode = Me.mShowOnlyNodesNamed.Exists(N.LocalName) End If the HideNodesNamed list contains a list of element names that you want to hide. Check to see if this node is in that list. If showNode Then If Me.mHideNodesNamed.Count > 0 Then showNode = Not mHideNodesNamed.Exists(N.LocalName) End If End If ... additional similar checks here

Whenever the FillTree method determines that it should display a node, it calls the setNodeText method, which checks to see if the node is associated with an XPathQuery or XslTransform DisplayRule. If so, it executes the query or performs the transformation and applies the result to the TreeNodes Text property; otherwise, it either uses the XML nodes LocalName for the TreeNode.Text property (when the XML node has child nodes), or simply assigns the XML nodes InnerText property as the text.

64

Windows Forms Solutions

So, the overall logic flow is: 1. For each node... 2. Determine whether to show or hide the node. 3. For displayed nodes, apply XPathQuery or XslTransform rules, if any, or display the node name (non-leaf nodes) or the node text content (leaf nodes). One final note: The FillTree method returns a single TreeNode containing a hierarchical set of TreeNode objects. The calling code then uses the inherited TreeView.Nodes.AddRange method to attach all the nodes to the TreeView control in a single operation. In contrast, if you add nodes to the TreeView directly in the FillTree method, it takes many times as long to populate the control.

TreeViewXml Extensions
The sample version simplifies the process for displaying customized XML in a TreeView control, but there are many additional ways to extend the TreeViewXml control so you can provide even more customization. For example, you might want to create a new DisplayRuleContentType that accepts a Delegate for the Value property, so that you could run custom code to create a display string for specific nodes. You could add Color and Font properties to the DisplayRule class and additional constructors so that you could specify the ForeColor, BackColor, and Font for specific nodes. Note that, as delivered, the DisplayRule class constructors are specified as Friend, so youll also have to create additional overloaded Add methods for the DisplayRulesCollection class to instantiate new DisplayRule types or add properties.

General .NET Topics

SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION

5 6 7 8 9 10 11 12 13 14 15 16

Take Advantage of Streams and Formatters in VB.NET File I/O in VB.NET: Avoid the Compatibility Syntax Gain Control of Regular Expressions Add Sort Capabilities to Your .NET Classes A Plethora of XML Choices Where Should I Store That Data? Performing the Most-Requested Conversions in .NET Building Custom Collections in .NET Launching and Monitoring External Programs from VB.NET Applications Build a Touch Utility with .NET Parse and Validate Command-Line Parameters with VB.NET Monitor Data and Files with a Windows Service

SOLUTION SOLUTION SOLUTION

66

General .NET Topics

SOLUTION

5
SOLUTION

Take Advantage of Streams and Formatters in VB.NET


PROBLEM

The absence of traditional file I/O support in .NET is one of the first things I noticed when I started learning VB.NET. Microsoft has replaced the classic I/O operations with stream operations.

Although you can find functions that are similar to classic VB file I/O syntax in the Microsoft.VisualBasic.Compatibility namespace, you should avoid using themtheyre slow. Instead, by spending a few minutes mastering the concept of streams, youll find that all I/O operations become similar; open a stream, read or write data, and close the stream.

NOTE

This solution was written by Evangelos Petroutsos for DevX.com and updated here.

A stream is a simple concept that originated in the Unix world. You can think of a stream as a channel through which data flows from your application to a sequential data store (such as a file, a string, a byte array, or another stream), or vice versa. To understand why Microsoft replaced the traditional file I/O operations with streams, you must consider that not all data resides in files. Modern applications acquire data from many different data stores, including files, in-memory buffers, and the Internet. The stream analogy enables applications to access all these data stores with the same programming model. Theres no need to learn how to use sockets to access a file on a remote Web server. You can establish a stream between your application and a remote resource and read the bytes as the server sends them. A stream encapsulates all the operations you can perform against a data store. The big advantage is that after you learn how to deal with streams for one data source, you can apply the same techniques to widely differing data sources. This solution focuses primarily on using streams with files, but youll see a few examples of using streams with other data stores, such as resources on a remote server.

Types of Streams
The Stream class is abstract; you cant declare a new instance of type Stream in your code. Instead, the .NET Framework includes several classes that derive from the Stream class

Solution 5 Take Advantage of Streams and Formatters in VB.NET

67

and you can create new instances of these. In other words, you dont use a Stream class instance directly; you use one of the following derived classes instead: FileStream Supports sequential and random access to files. Supports sequential and random access to memory buffers.

MemoryStream

NetworkStream Supports sequential access to Internet resources. The NetworkStream resides in the System.Net.Sockets namespace. CryptoStream Supports data encryption and decryption. The CryptoStream resides in the System.Security.Cryptography namespace. BufferedStream their own. Supports buffered access to streams that do not support buffering on

Although all the stream classes have common functionality inherited from Stream, not all streams support exactly the same operations. A stream for reading a local file, for example, can supply the length of the file and the current position in the file, with the Length and Position properties, respectively. You can jump to any location in the file with the Seek method. In contrast, a stream for reading a remote file doesnt support those features. But the Stream classes help you differentiate streams programmatically, by providing CanSeek, CanRead, and CanWrite properties. Despite some data storedependent differences, the basic methods of all Stream classes let you write data to or read data from the underlying data store.

Using the FileStream Class


To work with a local disk file, you use the FileStream class, which lets you move data to and from the stream as arrays of bytes. To make it easier to read and write basic data types, you can use the methods of the BinaryReader and BinaryWriter classes, or for text, the equivalent methods of the StreamReader and StreamWriter classes. These classes wrap an underlying FileStream and provide methods that facilitate reading and writing data in the appropriate format. The BinaryReader/Writer classes use the native form of the basic data types and produce binary files that are not readable by humans. The StreamReader/Writer classes write text files and can convert basic data types into XML format. In addition, all these classes work with any type of data, so the distinction between text and binary files is no longer as important as it used to be in classic VB. You can store numbers either as text or in their native format. VB.NET supports traditional random access files, but it doesnt really need them. You can still create files that store structures and access them by record numbers, as you did with

68

General .NET Topics

previous versions of Visual Basic using the FileOpen and FileGet functions in the Microsoft .VisualBasic.Compatibility namespace. But for the most part, the functionality of random access files has been replaced by XML and/or databases. If you are designing new applications and dont need compatible random access capability, you should use the newer .NET capabilities. No matter which class you decide to use to access a file, you must first create a FileStream object. There are several ways to do that. The simplest method is to specify the file and how it will be opened in the FileStream objects constructor, which has the following syntax:
Dim fStream As New FileStream(path, _ fileMode, fileAccess)

The path argument contains the full pathname of the file you want to open. The fileMode argument is a member of the FileMode enumeration (see Table 1) that determines how to open (or create) the specified file. The fileAccess argument is a member of the FileAccess enumeration: Read (for reading only), ReadWrite (for reading and writing), and Write (for writing only).
TA B L E 1 : The Members of the FileMode Enumeration

Name
Append Create CreateNew Open OpenOrCreate Truncate

Description
Opens an existing file and moves to the end of it, or creates a new file. Use this mode when the file is opened for writing. Creates a new file if the specified file exists or overwrites the existing file. Creates a new file. If the path argument specifies an existing file, an exception will be thrown. Opens an existing file. If the path argument specifies a file that doesnt exist, an exception will be thrown. Opens the specified file if it exists or creates a new one. Opens the specified file and resets its size to 0 bytes.

Creating a FileStream object is not the only way to open a file. You can also use one of the various Open methods of the File object (Open, OpenRead, OpenText, OpenWrite). These methods accept the files path as an argument and return a Stream object:
Dim FS As New FileStream = _ IO.File.OpenWrite(c:\Stream.txt) Another way to open a file is to use the OpenFile method of the OpenFileDialog and SaveFileDialog controls. With the OpenFile method of these two controls, you dont have to

specify any arguments; both methods open the file selected by the user in the dialog box.

Solution 5 Take Advantage of Streams and Formatters in VB.NET

69

The OpenFileDialog.OpenFile method opens the file in read-only mode, whereas the SaveFileDialog.OpenFile method opens the file in read/write mode. The FileStream class supports only the most basic file operationmoving data into or out of files as bytes or arrays of bytes. To use a FileStream instance to write something to a file, you must first convert the data to an array of bytes and then pass it as an argument to the FileStream objects Write method. Likewise, the FileStream objects Read method returns an array of bytes. You must also specify how many bytes should be read from the file. You probably will not use the FileStream class methods directly often, but its worth exploring briefly to see the base capabilities. After creating a FileStream object, you can call its WriteByte method to write a single byte or its Write method to write an array of bytes to the file. The WriteByte method accepts a byte as an argument and writes it to the file, and the Write method accepts three arguments: an array of bytes, an offset in the array, and the number of bytes to be written to the file. The syntax of the Stream.Write method is
Write(buffer, offset, count)

The buffer argument is the array containing the bytes to be written to the file, offset is the index of the first byte you want to write from the array, and count is the number of bytes to write. The syntax of the Read method is identical, except that the Read method fills the array buffer with count characters from the file. Converting even basic data types to bytes is not trivial, and you should usually avoid using FileStreams directly; however, if you do plan to use the FileStream object to write to a file, you should investigate the GetBytes and GetChars methods of the ASCIIEncoding and UnicodeEncoding classes (part of the System.Text namespace). For example, you can convert a string to an array of bytes with the following code:
Dim buffer() As Byte Dim encoder As New System.Text.ASCIIEncoding() Dim str As String = This is a line of text ReDim buffer(str.Length - 1) encoder.GetBytes(str, 0, str.Length, buffer, 0) FS.Write(buffer, 0, buffer.Length)

Notice that you must resize the buffer array to the length of the string you want to convert. To convert an array of bytes returned by the FileStream.Read method, use the GetChars method of the encoder variable. The sample project (downloadable from www.sybex.com) consists of a set of buttons that perform the operations described in this solution. For example, the Using The FileStream Class button (see Figure 1) uses code similar to the preceding code snippet to store a text string to a file.

70

General .NET Topics

FIGURE 1:
Clicking the Using The FileStream Class button stores text in a file.

More Flexible I/O Operations


As you can see, converting data to and from byte arrays is cumbersome. To avoid the conversions and simplify your code, you can use the StreamReader/StreamWriter classes to access text files, and the BinaryReader/BinaryWriter classes to access binary files. The BinaryReader/ BinaryWriter classes derive from the Stream class, because they write binary data (bytes) to an underlying stream. In contrast, the StreamReader/StreamWriter classes derive from the TextReader/TextWriter classes, respectively, and perform byte-encoding conversions automatically. To read data from a binary file, create an instance of the BinaryReader class. The BinaryReader classs constructor accepts one argumenta FileStream object representing the file you want to open. You obtain the FileStream by building on the ways youve already seen to open a file, such as the File.OpenRead or File.OpenWrite method:
Dim BR As New IO.BinaryReader _ (IO.File.OpenRead(path))

Solution 5 Take Advantage of Streams and Formatters in VB.NET

71

The syntax for the BinaryWriter classs constructor is similar:


Dim BW As New IO.BinaryWriter _ (IO.File.OpenWrite(path))

The BinaryWriter class exposes Write and WriteLine methods. Both methods accept any of the basic data types as arguments and write the data to the file (the WriteLine method appends a newline character to the end of the data). The BinaryReader class exposes numerous methods for reading data back. The class stores data values in their native format, with no indication of their type, so the program that reads them back should use the appropriate overloaded Read method. The following statements assume that BW is a properly initialized BinaryWriter object and show how you might write a string, an integer, and a double value to a file:
BW.Write (A String) BW.Write (12345) BW.Write (123.456789999999)

To read the values back, you must use the appropriate methods of a properly initialized BinaryReader object:
Dim s As String = BR.ReadString() Dim i As Int32 = BR.ReadInt32() Dim dbl As Double = BR.ReadDouble()

In the sample project, click the Using The Binary Reader And Writer Classes button to see the results in the Results window (see Figure 2). To access text files, use the StreamReader/StreamWriter classes. The methods are nearly identical. To write text to a file, use either the Write or the WriteLine method. To read the data back, use the Read, ReadLine, or ReadToEnd method. The Read method reads a single character from the stream, ReadLine reads the next text line (up to a carriage return/line feed), and ReadToEnd reads all the characters to the end of the file. You can find more examples of reading from and writing to binary and text files in the Common File I/O Scenarios section of this solution.

Object Serialization
So far, youve seen how to save simple data types to a file and read them back. However, most applications dont store their data in simple variables. Instead, they use more complicated structures to store their data, such as Arrays, ArrayLists, HashTables, specialized collection types, and custom classes. With only a few commands, you can store an object or even an entire array to a file using a process called serialization. To store an object, you convert its property and field values to a sequence of bytes, which you then store to a file. The opposite processreading the data back into an objectis called deserialization. Fortunately, both are fairly simple operations in .NET.

72

General .NET Topics

FIGURE 2:
Using the BinaryReader and BinaryWriter classes

The basic serialization procedure is simple. To save an object to a file and read it back, you use the Serialize and Deserialize methods of the BinaryFormatter class. First, import the System.RunTime.Serialization.Formatters namespace into your project to avoid typing excessively long statements. That namespace contains the BinaryFormatter class, which knows how to serialize basic data types in binary format. Create an instance of the BinaryFormatter class and then call its Serialize method, passing two arguments: a writeable FileStream instance for the file where you want to store the serialized object, and the object itself:
Dim BinFormatter As New Binary.BinaryFormatter() Dim R As New Rectangle(10, 20, 100, 200) BinFormatter.Serialize(FS, R)

To re-create the object from the file, the Deserialize method of the BinaryFormatter class accepts a single argumenta FileStream instanceand then deserializes the object at the current position in the FileStream and returns it as an object. You usually cast the deserialized object to the proper type with the CType function. For example, the following statement returns the serialized Rectangle object saved in the preceding code snippet:
Dim R As New Rectangle() R = CType(BinFormatter.Deserialize(FS), Rectangle)

Solution 5 Take Advantage of Streams and Formatters in VB.NET

73

You can also persist objects in text format using the XmlFormatter object. To do so, add a reference to the System.Runtime.Serialization.Formatters.Soap namespace with the Project Add Reference command. After doing that, you can create an instance of the SoapFormatter object, which exposes the same methods as the BinaryFormatter object but serializes objects in XML format. The statements in the following code snippet serialize a Rectangle object in XML format.
Dim FS As New IO.FileStream(c:\Rect.xml, _ IO.FileMode.Create, IO.FileAccess.Write) Dim XMLFormatter As New SoapFormatter() Dim R As New Rectangle(40, 30, 100, 100) XMLFormatter.Serialize(FS, R)

Clicking the XML Serialization Example button in the sample application displays the Rectangle in a PictureBox control (see Figure 3) and shows you the XML document text that persisted to the file. The examples youve just seen persist and reinstantiate a Rectanglewhich is a built-in Framework objectbut the sequence of commands for persisting and reinstantiating custom objects is almost identical. See the Persisting Objects section at the end of this solution for an example. FIGURE 3:
The XML description of a persisted Rectangle object

74

General .NET Topics

Common File I/O Scenarios


In the last section of this solution, youll find code prototypes for the file operations youre likely to use most frequently. The simplest, and most common, operation is moving text in and out of text files. Binary files are not generally used to store individual values; instead, modern applications most often use them to store objects, collections of objects, and other machine-readable data. Lets look at code examples for each of these scenarios.

Writing and Reading Text Files


To save text to a file, create a StreamReader object based on a FileStream object for the appropriate file and then call its Write method, passing the text you want to write to the file as an argument. The following statements prompt the user to specify a filename with a SaveFileDialog instance, and then write the contents of the TextBox1 control to the selected file:
Example: Saving Text to a File SaveFileDialog1.Filter = _ Text Files|*.txt|All Files|*.* SaveFileDialog1.FilterIndex = 0 If SaveFileDialog1.ShowDialog = DialogResult.OK Then Dim FS As FileStream = SaveFileDialog1.OpenFile Dim SW As New StreamWriter(FS) SW.Write(TextBox1.Text) SW.Close() FS.Close() End If

To read a text file and display it on a TextBox control, use a similar set of statements and call the ReadToEnd method of a StreamReader object. This ReadToEnd method reads the entire file and returns its contents as a string:
Example: Reading Text from a File OpenFileDialog1.Filter = _ Text Files|*.txt|All Files|*.* OpenFileDialog1.FilterIndex = 0 If OpenFileDialog1.ShowDialog = DialogResult.OK Then Dim FS As FileStream FS = CType(OpenFileDialog1.OpenFile, FileStream) Dim SR As New StreamReader(FS) TextBox1.Text = SR.ReadToEnd SR.Close() FS.Close() End If

Solution 5 Take Advantage of Streams and Formatters in VB.NET

75

Persisting Objects
You can serialize individual objects in binary form with the BinaryFormatter class or as XML-formatted text with the SoapFormatter class. If you replace all references to the BinaryFormatter class with references to the SoapFormatter class, you can serialize objects in XML without making any other changes to the code. Start by creating an instance of the BinaryFormatter class:
Dim BinFormatter As New Binary.BinaryFormatter()

Then create a FileStream instance based on the file where you want to serialize the object:
Dim FS As New System.IO.FileStream(c:\test.txt, _ IO.FileMode.Create)

After creating the BinFormatter and FS variables, call the Serialize method to serialize any serializable Framework object:
R = New Rectangle(rnd.Next(0, 100), _ rnd.Next(0, 300), rnd.Next(10, 40), _ rnd.Next(1, 9)) BinFormatter.Serialize(FS, R)

To serialize your own objects, add the Serializable attribute to the class:
Example: A Simple Serializable Class <Serializable()> Public Structure Person Dim Name As String Dim Age As Integer Dim Income As Decimal End Structure

To serialize an instance of the Person structure, create an instance of the class and initialize it. Then serialize the Person object by creating a formatter and calling its Serialize method:
Example: Serializing a Custom Object using the BinaryFormatter Class P = New Person() P.Name = Joe Doe P.Age = 35 P.Income = 28500 BinFormatter.Serialize(FS, P)

You can continue serializing additional objects serialized on the same stream and then later read them back in the same order. For example, to serialize a Rectangle object immediately after the Person object in the same stream, use a statement like this:
BinFormatter.Serialize(FS, New Rectangle _ (0, 0, 100, 200))

76

General .NET Topics

To deserialize the Person object, create a BinaryFormatter object, call its Deserialize method, and then cast the methods return value to the appropriate type. The Deserialize method deserializes the next available object in the stream. Suppose youve serialized a Person and a Rectangle object, in that order. To deserialize them, open the FileStream for reading and use the following statements:
Example: Deserializing Custom Objects Dim P As New Person() P = BinFormatter.Serialize(FS, Person) Dim R As New Rectangle R = BinFormatter.Serialize(FS, Rectangle)

Persisting Collections
Most applications deal with collections of objects rather than individual object variables. To work with sets of data, you can create an array (or any other collection, such as an ArrayList or a HashTable), populate it with objects, and then serialize the entire collection with a single call to the Serialize method. The following statements create an ArrayList with two Person objects and serialize the entire collection:
Example: Persisting a Collection of Custom Objects Dim FS As New System.IO.FileStream _ (c:\test.txt, IO.FileMode.Create) Dim BinFormatter As New Binary.BinaryFormatter() Dim P As New Person() Dim Persons As New ArrayList P = New Person() P.Name = Person 1 P.Age = 35 P.Income = 32000 Persons.Add(P) P = New Person() P.Name = Person 2 P.Age = 50 P.Income = 72000 Persons.Add(P) BinFormatter.Serialize(FS, Persons)

To read the instances of the Person class youve stored to a file, create an instance of the BinaryFormatter class and call its Deserialize method, passing a FileStream object that represents the file as an argument. The Deserialize method returns an Object variable, which you can cast to the appropriate type. The following statements deserialize all the objects persisted in a file and process the objects of the Person type:
Example: Deserializing Custom Objects

Solution 5 Take Advantage of Streams and Formatters in VB.NET

77

FS = New System.IO.FileStream _ (c:\test.txt, IO.FileMode.OpenOrCreate) Dim obj As Object Dim P As Person(), R As Rectangle() Do obj = BinFormatter.Deserialize(FS) If obj.GetType Is GetType(Person) Then P = CType(obj, Person) Process the P objext End If Loop While FS.Position < FS.Length - 1 FS.Close()

To deserialize an entire collection, call the Deserialize method and then cast the methods return value to the appropriate type. The following statements deserialize the Persons array:
Example: Deserializing a Collection of Custom Objects FS = New System.IO.FileStream(c:\test.txt, _ IO.FileMode.OpenOrCreate) Dim obj As Object Dim Persons As New ArrayList obj = CType(BinFormatter.Deserialize(FS), ArrayList) FS.Close()

Downloading Internet Resources


To connect to a remote Web server and request a file, you must create a WebRequest object and call its GetResponse method. The GetResponse method returns a Stream object, which you can use to read the remote file almost as if it were local. To create a WebRequest object (which represents a request you make from within your application to a remote file), call the Create method of the WebRequest class, passing the URL of the remote resource as an argument. To retrieve the filewhich in this case is the response from the remote Web serveryou call the GetResponse method of the WebRequest object that represents the request. The GetResponse method returns a WebResponse object, which you can then pass as an argument to the StreamReader constructor. The following statements show how to request a file from a Web server and display it in a TextBox control:
Example: Reading a File from a Remote Web Server Dim url As Uri = New Uri(http://www.devx.com) Dim Req As WebRequest Req = WebRequest.Create(url) Dim Resp As WebResponse Me.Cursor = Cursors.WaitCursor Try Resp = Req.GetResponse Catch exc As Exception

78

General .NET Topics

MsgBox(exc.Message) Exit Sub End Try Me.Cursor = Cursors.Default Dim netStream As StreamReader netStream = New StreamReader(Resp.GetResponseStream) Dim RD As New ResultDisplay(Me.txtResults) RD.ShowResultString(netStream.ReadToEnd) Resp.Close() netStream.Close()

You can see this code in action in the sample project. Click the Downloading Web Resources button to request the DevX home page. Figure 4 shows the results. FIGURE 4:
The Returned HTML for www.devx.com

Solution 5 Take Advantage of Streams and Formatters in VB.NET

79

The MemoryStream Class


The MemoryStream represents a stream in memory, effectively letting you treat your computers memory as a file. One common use of the MemoryStream class is to create clones (or copies) of objects. If you serialize an object to a MemoryStream and then deserialize the stream and assign the resulting object to a new variable, youll get back a copy of the original objectan exact duplicate, or clone. The following statements outline the process. The public Clone function is a method of the Person structure shown earlier in this solution:
Example: Creating a Copy of a Custom Object Public Function Clone() As Person Dim BinFormatter As New Binary.BinaryFormatter() Dim memStream As New System.IO.MemoryStream() BinFormatter.Serialize(memStream, Me) memStream.Position = 0 Return CType(BinFormatter.Deserialize _ (memStream), Person) End Function

To test the Clone method, create a Person instance and initialize its fields. Then, declare another Person variable and assign the clone of the first variable to it:
Dim P1 As New Person Dim P2 As New Person P1.Name = my name P1.Age = 35 P1.Income = 40000 P2 = P1.Clone()

Note that if you simply assign P2 to P1, both variables will point to the same object and every change you make to P1 will also affect P2. However, by cloning the object, you have created two separate instances of the Person structure that both contain the same member data, and you can subsequently manipulate them individually and independently. The Cloning An Object With Memory Streams button runs code similar to the preceding code snippets to clone an object. Figure 5 shows the results. By this time, you have probably noticed that almost all the code in the various examples looks extremely similar. As you can see, reading or writing streams that manipulate files is essentially identical to the process of reading and writing streams that manipulate Web data, serialize/deserialize objects, or write directly to memory. By abstracting the operations to read and write objects of all types to any medium, the .NET Stream classes unify and simplify the process of reading and writing to all types of sequential data stores.

80

General .NET Topics

FIGURE 5:
Cloning an object with the MemoryStream class

SOLUTION

6
SOLUTION

File I/O in VB.NET: Avoid the Compatibility Syntax


PROBLEM

VB.NET contains file I/O compatibility commands that simplify moving existing code and help you get started using the .NET Framework, but the commands are slow.

After you see just how much the compatibility syntax affects the speed of your code, youll be eager to move to the newer .NET stream-based I/O syntax.

Solution 6 File I/O in VB.NET: Avoid the Compatibility Syntax

81

VB.NET is a mixed blessing. It giveth with one hand by being similar enough to classic VB to ease the pain of learning the .NET Framework; it provides experienced VB programmers with a core set of familiar commands. With the other hand, it taketh away because those commands are not quite as convenient as they seem. Case in point: VB.NETs file I/O compatibility syntax. The System.IO classes in the .NET Framework implement stream-based I/O (see Solution 5 for a more complete introduction to stream-based operations). Using streams for I/O is actually easier, once you understand the principles, because it makes reading or writing to any sequential store essentially like reading to any other sequential store; in other words, reading and writing files is similar to reading/writing to a Web server, or reading/writing to memory, and so forth. Still, the VB.NET designers decided to include file I/O compatibility methods in VB.NET, primarily to simplify upgrading existing code, but possibly also to ease the transition between classic VB and VB.NET. The compatibility statements are like sea sirens, luring the unwary programmer to adhere to the older syntax, becausewell, they work. And they are sufficiently similar to classic VB syntax to make you feel comfortable. But dont get comfortable. The file I/O compatibility statements have serious performance problems; its hard to believe until you try them for yourself. This Solution follows through on Solution 5 (based on Evangelos Petroutsos solution from the DevX website) by giving you the ability to measure exactly how much your code can suffer if you stick to the older syntax. Because theres a considerable difference between one computer and another, even if youre running exactly the same code you should run the examples and time them yourself as you read through this solution. This solution stems from several posts in the DevX vb.dotnet.technical discussion group. Thanks to Phil Weber for posting the following two VB6 and VB.NET code examples, which read a large compiled help file (.chm) that ships with Visual Studio 6. NOTE
The file you use for the test doesnt matter much, but many readers will have the file referenced in the code that follows installed on their systems, and its large enough to provide a good test (10,481 KB). If you cant find this file, use any large file on your system. The code does not alter the file. However, you may need to alter the paths shown in the following code fragments accordingly.
-- VB6: Dim TT As Double Dim Temp As String TT = Timer Temp = Space$(2 ^ 15) 10.2 MB file

82

General .NET Topics

Open D:\Program Files\Microsoft & _ Visual Studio\MSDN\2001OCT\1033 & _ \DSMSDN.CHM For Binary As 1 Do Until EOF(1) Get 1, , Temp Loop Close MsgBox Elapsed time: & _ Format$(Timer - TT, ######.00)

-- VB.NET: Dim TT As Double Dim Temp As String Temp = Space(32768) TT = DateAndTime.Timer() FileOpen(1, D:\Program Files\Microsoft & _ Visual Studio\MSDN\2001OCT\1033\DSMSDN.CHM, _ OpenMode.Binary) Do Until EOF(1) FileGet(1, Temp) Loop FileClose() MsgBox(Elapsed time: & _ CStr(DateAndTime.Timer() - TT))

Both versions open a large binary file, and then read the file contents in blocks of 32,768 bytes until they reach the end of the file. With the VB6 code compiled to p-code, VB.NET was the clear winnerover twice as fast as the VB6 code. However, with the VB6 version compiled to native code and all advanced optimizations selected, the VB6 versions performance improved dramaticallyit was consistently about four times as fast as VB.NET.

Another Test with Changed Syntax


That seemed odd. A little experimentation shows that you should pay close attention to your VB.NET IO code, because youll encounter a significant difference in performance between the various options. In this case, the main culprit seems to be the FileGet method, which is included in VB.NET for backward compatibility with the classic VB I/O commands. I rewrote the VB.NET code using standard .NET FileStream syntax to read the file data into a byte array, as follows:
Dim TT As Double Dim temp() As Byte

Solution 6 File I/O in VB.NET: Avoid the Compatibility Syntax

83

Dim s As String ReDim temp(CInt(2 ^ 15)) Dim st As FileStream = File.OpenRead _ (D:\Program Files\Microsoft & _ Visual Studio\MSDN\2001OCT\1033\DSMSDN.CHM) TT = DateAndTime.Timer() Do While st.Position < st.Length st.Read(temp, 0, temp.Length - 1) Loop st.Close() MessageBox.Show(Elapsed Time: & _ CStr(DateAndTime.Timer() - TT)

This version consistently outperforms the classic VB version. On my machine, using the 10.2-MB (10,735,616 bytes) DSMSDN.CHM file, the VB.NET version was fast enough to outperform the resolution limits of the timer, so I retested using the much larger 286-MB (300,040,192 bytes) TECHART.CHM file. The results show clearly that the rewritten VB.NET version using the FileStream class is roughly three times as fast as the VB6 version. More important, the rewritten VB.NET version is over 10 times as fast as the compatibility version that uses the FileGet function.

Try It Yourself
So that you can experiment on your own machine, Ive created a short example that compares the two VB.NET versions. When the application starts up, it checks to see if a test file (ThisIsATest.txt) exists; if not, it displays a button that lets you create the file. When you click the button, the application runs the btnCreateTest method shown here, which creates the file and fills it with data by writing the string This is a test. one million times, producing a 14,649-KB file. Thats sufficiently large to provide easily comparable test times.
Private Sub btnCreateTestFile_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) _ Handles btnCreateTestFile.Click Try Dim sw As StreamWriter = New StreamWriter _ (New FileStream(Application.StartupPath & _ \ThisIsATest.txt, FileMode.Create), _ System.Text.Encoding.ASCII, 32768) Dim s As String = This is a test. Dim i As Integer For i = 1 To 1000000 sw.Write(s) Next sw.Close() Dim fi As FileInfo = New FileInfo _

84

General .NET Topics

(Application.StartupPath & \ThisIsATest.txt) Me.Panel1.Visible = fi.Exists Me.btnCreateTestFile.Visible = Not (fi.Exists) Catch ex As Exception MessageBox.Show(ex.Message) Return End Try End Sub

After creating the test file, the application displays a panel that contains some display labels and buttons (see Figure 1). One buttons click event handler uses the classic compatibility (FileOpen/FileGet) syntax, another uses a FileStream to read the data as raw bytes, and the last two instantiate a StreamReader object and use its methods to read the file. You may be surprised by the results. Using the FileStream to read raw bytes is between seven and nine times faster than the classic syntax (individual run results vary), while using the StreamReader to read bytes as text is approximately twice as fast.

So Whats Happening?
Still, the question remains: Why is the compatibility code so much slower? Viewing the Intermediate Language (IL) doesnt help in this case (at least, it doesnt help me) because all you can see is the call out to the Microsoft.VisualBasic compatibility library. Part of the reason the compatibility code is slower is because in that version the system performs a byteto-string conversion to read bytes into the string for each read iteration, whereas it doesnt have to perform any conversion using the FileStream, which simply reads the raw bytes directly into the byte array. If you instead create a string variable(s) and add this line into the loop in the FileStream version, youll find that it slows down until its just about 30 percent faster than the compatibility version. This is because it now has to convert the bytes to characters as well.
s = System.Text.Encoding.Default.GetString(Temp)

FIGURE 1:
Experiment with the sample code.

Solution 6 File I/O in VB.NET: Avoid the Compatibility Syntax

85

But you can go a little further than that by knowing a bit more about the file. Because you know the file contains ASCII text, you can use
s = System.Text.Encoding.ASCII.GetString(Temp)

Changing the encoding from the Default (Unicode) to ASCII nearly doubles the speed. Doing that sparked an impulse for another test. Because this test reads a text file anyway, you can create a StreamReader and call its ReadBlock method to read a specific number of characters. You wouldnt expect to find much difference between using a StreamReader and converting the bytes yourself, and as it turns out, when both versions use System.Text.Encoding.ASCII encoding, thats truetheres very little difference. But if you use System.Text.Encoding.Default for both the FileStream version and the StreamReader version, the StreamReader version is considerably faster. Heres the StreamReader version code from the sample application:
Private Sub btnStreamReader_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) Handles btnStreamReader.Click Dim TT As Double Dim temp() As Char Dim counter As Integer = 0 Dim s As String ReDim temp(CInt(2 ^ 15)) disableControls() Dim sr As StreamReader = New StreamReader( _ New FileStream(Application.StartupPath & _ \ThisIsATest.txt, FileMode.Open), _ System.Text.Encoding.Default) TT = DateAndTime.Timer() Do While sr.ReadBlock(temp, 0, temp.Length - 1) > 0 counter += 1 Loop sr.Close() Me.lblResultStreamReader.Text = _ CStr(DateAndTime.Timer() - TT) Me.lblIterationsStreamReader.Text = CStr(counter) enableControls() End Sub

Beware of Convenience
Finally, all versions of the code use a fixed-length buffer 2 (32,768) bytes in length to read data from the file. When you create a StreamReader to read a large file, you should take a little extra time to set the buffer size. The documentation states: When reading from a Stream, it is more efficient to use a buffer that is the same size as the internal buffer of the stream.
15

86

General .NET Topics

So you can change the line that creates the StreamReader to


Dim sr As StreamReader = New StreamReader( _ New FileStream(Application.StartupPath & _ \ThisIsATest.txt, FileMode.Open), _ System.Text.Encoding.ASCII, False, 32768)

Increasing the buffer size can improve the speed by several percent. Finally, you might think that it would be faster to simply use the StreamReader.ReadToEnd method to read the entire file with one fell swoop, but its notit consistently takes nearly as long to read the file with the StreamReader.ReadToEnd method as it does to use the slower compatibility syntax, regardless of the buffer size. So, although you can simplify your code by using the ReadToEnd method, you also affect performance considerably by doing so. The lesson here is that you should eschew the compatibility syntax and use VB.NET streams to read files when you care about performance optimization. Even using streams, if you care about performance, pay close attention to how you define the buffer size for your streams, and take the time to manage buffers yourself rather than simply reading the entire file into memory using ReadToEnd or similar methods.

SOLUTION

7
SOLUTION

Gain Control of Regular Expressions


PROBLEM

Until .NET, regular expressions were not directly available to many Microsoft programmers other than through the various scripting languages supported by the Microsft Scripting Runtime.

Learn a procedure for building .NET regular expressions to perform powerful text searches and replacements.

Regular expressions are essentially a language for text processing. To master regular expressions, youll need to understand the syntax of the languageand the syntax is probably as far as possible from your normal programming language. Fortunately, regular expressions consist of a relatively small set of commands compared to general-purpose programming languages, so with a little practice, youll be able to write and read regular expressions. I put read

Solution 7 Gain Control of Regular Expressions

87

last in the preceding sentence on purpose, because its generally easier to write regular expressions than it is to read them. The goal of this solution isnt to give you a couple of regular expressions you can use in your codeyou can search the Web and find innumerable regular expression examples. Instead, the purpose is to show you a process that you can use to build your own custom regular expressions.

Using the Regex Class


For programmers experienced with earlier versions of VB, regular expressions take the place of the Like keyword and often the InStr function as well. At the simplest level, you use regular expressions to search one text string for occurrences, or matches of a pattern. The key to using regular expressions is being able to write, read, and understand the pattern language, which is a set of symbols, each of which has a precise meaning and matches specific characters, or sets of characters.

Matching Individual Characters


First, you need a way to find individual characters and sequences of characters. You dont always know which characters might appear in text, though, so you need a symbol that will match any character. In a regular expression pattern, that symbol is the period (.), which by default matches any character except a newline (\n; in Windows, a newline is also the two consecutive ASCII characters 13 and 10). So, if you create a new regular expression and search for the pattern . in any given string of characters, youll always find a matchin fact, youll always find exactly the same number of matches as there are characters in the string, unless the string contains embedded newline charactersin which case youll find the number of matches up to the first newline character. NOTE
You can include newlines in the matched character set for the period (.) symbol by using the RegexOptions.Singleline option.

WARNING One thing to remember about regular expression patterns is that white space is significant. That also makes them difficult to write about, because without some other cue, its difficult to discern extra spaces. Because white space is significant, the regular expression x is not the same as the regular expression x (note the extra space in the first expression).

In .NET, you use the System.Text.RegularExpressions.Regex class to work with regular expressions. To avoid having to write the full name for classes in the System.Text.RegularExpressions namespace, add an Imports (VB.NET) or using (C#) statement at the top of your classes.

88

General .NET Topics

Using the Regex Class


You can create an instance of the Regex class and assign a match pattern (a regular expression pattern) to it, or you can use the instance with many patterns by passing the search pattern as a parameter to methods. Alternatively, you can often avoid creating an instance and use the shared (static in C#) methods built into the class, passing the search pattern as a parameter to the methods. When you create a specific Regex instance, you can compile its regular expression pattern using one of the overloaded constructors. You can also compile it later by calling the CompileToAssembly method, which compiles the expression into MSIL in a specific assembly. Theres no particular advantage to compiling a regular expression unless youre planning to use the same expression many times, but if you are, then compiling the expression will produce a hefty performance increase.

The Matches Method


You use the Regex.Matches method to find items in one string (the target) that match a regular expression pattern. The Matches method returns the results in a MatchCollection containing a list of Match objects, each of which corresponds to a matched substring in the target. For example, if you search a string containing the lowercase alphabet using the regular expression pattern ., youll get a MatchCollection containing 26 matches, one for each letter. Listing 1 shows an example.

Listing 1

Searching a string with a pattern that matches any character

Private Sub btnFindAnyChar_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnFindAnyChar.Click create a target string Dim target As String = _ abcdefghijklmnopqrstuvwxyz create a Regex instance using the all character match pattern Dim reg As New Regex(.) Perform the search Dim matches As MatchCollection = _ reg.Matches(target) loop through the matches, displaying information about each match Dim aMatch As Match Dim sb As New StringBuilder()

Solution 7 Gain Control of Regular Expressions

89

sb.Append(Found & matches.Count _ & matches: & _ System.Environment.NewLine) loop through the MatchCollection For Each aMatch In matches appending the value (the matched text) and the index value (the match position) to the StringBuilder sb.Append(aMatch.Value & , _ & aMatch.Index & _ System.Environment.NewLine) Next display the results Me.txtResult.Text = sb.ToString() End Sub

Listing 1 is an artificial example, but it serves as a good starting point. What you need to remember is that regular expression symbols represent individual characters, groups of characters, or groups of matched text. You dont need symbols to represent every character; most characters in regular expressions represent themselves. A regular expression of ough, for example, would match the sequence of ough characters in dough and bough and rough. You need symbols only to represent characters that the regular expression language uses (to avoid confusion) and for logical concepts, such as counting.

Finding All Dates in a String


Suppose you had the task of finding all the dates in the following string, which contains a mixture of date types:
9/6/03, 08/05/03, 07/04/2003, 3/2003, & _ 6/6, 02/04, 98/105 12/17/1770/03/26/1827

Before you can find the dates, you must establish exactly what a date looks like. The simplest rule is that a date consists of numbers separated by slashes and delimited by white space or punctuation. However, thats not sufficient to find all dates, because the number of digits within a single number (were considering U.S. dates only) can vary. The first number can contain one or two digits. Thats fairly simple. The number of digits in the second number varies depending on how many numbers there are; if there are three numbers separated by slashes, the second number may have either one or two characters, but if there are only two numbers separated by slashes, the second number may have either two or four digits.

90

General .NET Topics

Using the description, you can derive some rules for the search, such as the following: 1. All dates consist of either two or three numbers separated by a forward slash. 2. The first number always has either one or two digits. 3. The last number always has either two or four digits. 4. Dates may be preceded by white space or nothing (the beginning of the text). 5. Dates must be followed by either white space or punctuation or nothing (the end of the text). 6. When there are three numbers, the middle number may have either one or two digits. You can write those rules almost exactly as you would read them. Because the regular expression syntax is dense and difficult to remember, you should expect to have to look up the syntax for the various regular expression symbols until youve worked with them a long time. TIP
To find the appropriate regular expression syntax for matching individual characters or sequences of characters, look at the regular expression character classes topic in the .NET documentation.

The period (.) character youve seen already is a character class. Other character classes are available that match any character within a group, white space, non-white space, digits, nondigits, word characters, or non-word characters. Ill explain the ones used in this article as needed, but the list will not be complete by any meansyou should find and learn to use the regular expression .NET documentation.

Implementing Rule 1
Start by implementing the first rule: All dates consist of either two or three numbers separated by a forward slash. In other words, dates such as 11/98, 1/1, and 03/04/2003 are all valid dates. So you want an expression that matches sequences of characters like n/n, where n is a one-, two-, or four-digit number. NOTE
At first, limit the search to matching expressions such as n/n. After you have a regular expression that reliably matches n/n, it will be simple to extend it to match full-length dates, in n/n/n format.

You match numbers using the \d (for decimal) symbol. The forward slash matches itself, because its not used as a character in the regular expression language. So you could write
\d/\d

Solution 7 Gain Control of Regular Expressions

91

That would match any n/n pattern where each n consists of a single digit, but it wont match n/n patterns where n has more than one digit. According to the second rulethe first number always has either one or two digitsyou need to control the number of consecutive digits matched by the expression.

Implementing Rule 2
Whenever you need to control the number of matches, you use a quantifier. One type of quantifier looks like this:{min, max}, where min corresponds to the minimum (optional) number of matches you need, and max represents the maximum number of matches you need. You separate the values with a comma and enclose the quantifier in curly brackets. Place all quantifiers immediately after the expression they should affect. To find regular expression rules that affect the number of matches, look at the regular expression quantifiers topic in the .NET documentation. So to find exactly two consecutive digits, you could write \d{2,2}. A shorthand version lets you write only one number between the curly brackets; the regular expression will match only if it finds exactly that many items. So \d{2} also specifies exactly two consecutive digits and is the equivalent of \d{2,2}. To find either one or two consecutive digits, use the full {min, max} syntax and write \d{1,2}. To find any number of digits, but at least some minimum number, leave the second (maximum) value blankfor example \d{1,} matches one or more digits. So you can find all numbers n where n consists of either one or two digits using the expression \d{1,2}.

Implementing Rule 3
After learning about quantifiers, it should be easy to implement the third rule as well, which states: The last number always has either two or four digits. To do that, duplicate the expression:
\d{1,2}/\d{1,2}

For example, given the string 01/03 or 02/03, the expression would find

First short date: 01/03 Second short date: 02/03

But if you test that expression against the string 01/2003 or 02/20, the MatchCollection contains exactly the same content for both dates, because it matches only two digits after the forward slash. You might not know whether the date strings in the target contain month/year or month/day values (one-digit day, two-digit year, or four-digit year), so you could alter the expression to match up to four digits after the forward slash:
\d{1,2}/\d{1,4}

92

General .NET Topics

At first glance, that appears to work for everything except dates in month/day/year format. Unfortunately, it also captures non-dates, such as 98/105and you want to exclude those. Heres another try:
\d{1,2}/(\d{1,2}|\d{4})

The portion of the preceding expression in parentheses is an alternation construct, with the parts of the alternation separated by the vertical bar. You can read the parts of the alternation sequentially as either/or. In other words, the expression matches n/n patterns that have one or two digits (\d{1,2}), followed by a forward slash (/), followed by either one or two digits \d{1,2} or four digits \d{4}. That expression works for everything except four-digit years. The reason it fails for those is that the regular expressions engine evaluates patterns in the order theyre written. The preceding pattern always matches the first part of the alternation\d{1,2}so it never evaluates the second part, \d{4}. The solution is to switch the order of the alternation so it will attempt to match the fourdigit number first. Heres the expression with the reversed alternation:
\d{1,2}/(\d{4}|\d{1,2})

Thats a little closer, but you can see that the expression would find several matches in sequences such as
11/02/91/92/65/1567

Implementing Rule 4
To solve that problem, implement the fourth rule: Dates must be preceded by either white space or nothing (the beginning of the text). To find white space, use the /s symbol. The uppercase version (\S) matches non-whitespace characters. (Yes, regular expressions are case-sensitive.) So, to allow white space in front of the matched date, you could write
\s\d{1,2}/((\d{4})|(\d{1,2}))

Unfortunately, that expression finds only dates preceded by white spaceit leaves out the valid date at the start of the line. What you really need is a symbol that finds dates that are preceded either by white space or nothing (at the beginning of the text). One way to meet that requirement is to add a quantifier that allows 0 or more matches after the \s. You could use the numbering quantifier {0,}, but the need for such a quantifier is so common that theres a special symbol for it, the asterisk (*), so the expression becomes
\s*\d{1,2}/(\d{4}|\d{1,2})

That expression finds every n/n pattern, whether or not its preceded by white space. However, it still finds dates in sequences such as 11/02/91/92/65/1567.

Solution 7 Gain Control of Regular Expressions

93

Implementing Rule 5
To prevent the expression from finding dates in sequences such as that weve just shown you, implement the fifth rule: Dates must be followed by either white space or punctuation or nothing (the end of the text). Simply adding the /s symbol to the end of the expression isnt sufficient in this case; you also need to find dates followed by either white space or punctuation. The key is the either/or in the preceding sentence. When you want to find either one thing or another thing, use an alternation. If you look at the documentation for the character classes, youll find that you can match non-word characters with the symbol \W. By combining this with the white-space symbol \s in an alternation, you can match either white space or a non-word character, such as a punctuation mark. The new expression is
\s*\d{1,2}/(\d{4}|\d{1,2})(\s|\W)

That expression finds the first two numbers of all n/n patterns that are followed by either white space or a non-word character, but doesnt find the last one because you need a quantifier that accepts nothing {0, } to implement the rule. But now youre in a quandary, because if you add the * quantifier to the last part of that expression, it will always matchno matter what follows the last digitand thats not what you want either. You only want to match nothing if the date is at the end of the line. Fortunately, theres a character escape the \b characterthats used for two different purposes in regular expressions. The \b character escape matches a backspace when its enclosed in square brackets, and it matches a word boundary (such as the beginning or end of a line) when its used outside square brackets. Thats how you want to use it here. You can add it both to the beginning and to the end of the expression, which now becomes
(\b|\s*)\d{1,2}/(\d{4}|\d{1,2})(\s|\W|\b)

Youre getting close. This version matches all the n/n patterns, including the last n/n, but it matches some that clearly cant be dates. It also matches a slash after the second number, which you dont want. To keep from matching the slash, you must learn how to create your own character classes.

Creating Character Classes


You can match any character or set of characters by enclosing them in square brackets. Similarly, you can perform the reversenot match any character or set of characters by enclosing them in square brackets but including a caret (^) as the first symbol inside the brackets. The sequences you make using this syntax are also called character classes.

94

General .NET Topics

For example, [abc] matches any of the characters a, b, or c. Its important that you understand the difference between matching abc and matching [abc]. The first matches a sequence of charactersa followed by b followed by cand the second matches each of the characters, whether or not they appear in sequence. To reverse the effect, and explicitly not match characters, you could use the character class [^abc], which would then cause the expression not to match a, b, or c. However, the reversed effect matches any character other than those within the brackets; in other words [^abc] (which you can think of as "not a, not b, and not c") matches d, or f, or a period, or anything except a, b, or c, so use the reverse version judiciously. So, you can add an alternate character class to the portion of the expression that matches non-word characters which explicitly excludes a forward slash, the character class in square brackets shown in the following snippet: NOTE
At this point, the expression is getting too long to fit on a single line, so I will begin breaking it across two or more lines. Remember though, that the expression in code is a single line.
(\b|\s*)\d{1,2}/(\d{4}| \d{1,2})(\s|(\W[^/])|\b)

That version is almost perfect. It matches all the n/n patterns, but it also matches the nondate pattern 98/105. How could that be? The pattern explicitly says to only match one, two, or four numbers after the slash, yet the pattern is matching three numbers after the slash. The answer is that its matching the third number in the last portion of the expression (\s|(\W[^/])|\b). You can read that as Match white space or a non-word character thats not a forward slash, or the end-of-line. Non-word characters dont exclude numbers, so the expression happily matches the third number after the slash. To fix it, you need to fully implement Rule 5. To exclude numbers, add the \d symbol into the character class that excludes the forward slash:
(\b|\s*)\d{1,2}/(\d{4}| \d{1,2})(\s|(\W[^/\d])|\b)

Finally, this version appears to work perfectlyat least, against this target string. Now its simple to extend it to work with either an n/n pattern or the full-date n/n/n pattern; you simply duplicate the middle portion and insert it before the part of the pattern that matches the end of the second number, and then use a quantifier on the section that makes it optional. You want it to match either 0 or 1 times, but no more. You could use the {0,1} quantifier, but its so common that theres a shorthand version for it too, the question mark (?):
(\b|\s*)\d{1,2}/(\d{4}| d{1,2})/(\d{4}|\d{1,2})? (\s|(\W[^/\d])|\b)

Heres something to think about. Suppose you add some extra numbers to the beginning of the target string. What would happen? How about excluding a forward slash? Do you need

Solution 7 Gain Control of Regular Expressions

95

to add the [^/\d] character class to the beginning of the expression to exclude the extra numbers or a forward slash? The answer is: You dont. The expression wont match extra numbers or indeed any other characters preceding the date other than white space or the beginning of a line, because the \d{1,2} clause forces it to match only the first or second number preceding a forward slash. There is still one small problem, though. Youve extended the expression so it matches all valid dates, but the expression also captures leading spaces as well as trailing spaces and other non-word characters. Sure, you can clean those up with a programming language before you convert the captured text to a Date value, but its much easier to extend the regular expression a bit more.

Capturing Groups
The parentheses youve been adding to the expression dont just set off portions of the expression, nor do they determine precedence, like parentheses in many programming languages. Instead, they cause the Regex object to capture individual groups matched by the portion of the expression within the parentheses. You can assign a name to a group, and then use code to retrieve the text specifically matched by that named group. To create a group, place a portion of the expression in parentheses. To create a named group, place a question mark (?) after the opening parenthesis and then enter the name enclosed in angle brackets or single quotes; for example:
(?<mygroup>\d{1,2}) or... (?mygroup\d{1,2})

Using the preceding expression, you could subsequently retrieve the named groups matched text from the Match object containing the group. In other words, by creating and naming a group that includes only the portion of your date-matching expression that matches the n/n/n pattern, you can retrieve the date characters without any surrounding white space or non-word characters:
(\b|\s*)(?<date>\d{1,2}/(\d{4}| d{1,2})/(\d{4}|\d{1,2})?) (\s|(\W[^/\d])|\b)

The preceding expression creates a group named date. Heres the code for extracting the date group from the MatchCollection returned by the Regex.Matches method:
get the MatchCollection returned by the Matches method Dim matches As MatchCollection = _ reg.Matches(target) For Each aMatch In matches If aMatch.Groups.Count > 0 Then If aMatch.Groups(date).Value _

96

General .NET Topics

<> Then sb.Append(Date: & _ aMatch.Groups(date).Value & _ System.Environment.NewLine) Else sb.Append(Matched text: & & _ aMatch.Value & & , & _ Space(12 - aMatch.Value.Length) _ & At position: & aMatch.Index _ & System.Environment.NewLine) End If End If Next

In the sample application, the result looks like this:


Target: 9/6/03, 08/05/03, 07/04/2003, 3/2003, 6/6, 02/04, 98/105 12/17/1770/03/26/1827 Found 8 matches: Using the regular expression: (\b|\s*)(?<date>\d{1,2}/(\d{4}|\d{1,2}) (/(\d{4}|\d{1,2}))?)((\s|(\W[^/\d]))|\b) Date: 9/6/03 Date: 08/05/03 Date: 07/04/2003 Date: 3/2003 Date: 6/6 Date: 02/04 Date: 12/17/1770 Date: 03/26/1827

As you can see, the expression now matches all possible dates in the stringand matches only dates. By using named groups, you can exclude unwanted matched text from your results.

Validating Phone Numbers


Finding values is not the only use for regular expressions; theyre helpful for validating information as well. For example, suppose you want people to enter a U.S.-formatted phone number, including the area code. Several valid entries are possible: NNN-NNN-NNNN (NNN) NNN-NNN NNNNNNNNN 1 NNN.NNN.NNNN 1(NNN) NNN-NNNN

Solution 7 Gain Control of Regular Expressions

97

You probably get the picture. Basically, the users entry has to have 10 digits. You can discard any intervening white space or any preceding 1. In the end, you want to store the values in a database in three separate columns: Area_code, Prefix, and LineNumber. In this instance, the user enters the data in a single-line TextBox called txtPhone. Your task is to write a regular expression to validate the entries. The validator must reject all non-compliant phone numbers, but must not reject compliant entries. Again, break the problem down into some rules: Rule 1: If an entry begins with 1, discard the first number. To implement this rule, use an expression that ignores a leading 1. Because 1 is not a reserved character, you can use it directly, with a quantifier that allows 0 or 1 matches:
1?

Rule 2: If the area code begins with 0 or 1, the phone number is invalid. An area code consists of three numbers. The first number may not be 1 or 0; therefore, you should capture only 2 through 9. However, the second and third numbers of the area can be any digit, so this expression captures all three digits:
1?[2-9][0-9]{2}

Rule 3: All valid phone entries have 10 digits. The portion of the expression youve created so far captures the first three digits. You need an expression to capture the other seven. These seven digits usually appear as a prefix (three digits) followed by a line number (four digits). Therefore, you can write the expression as:
1?[2-9][\d]{2}[\d]{3}[\d]{4}

Rule 4: Numbers within entries may be separated by parentheses, dashes, white space, or periods. You already know how to capture white space and non-word characters (which includes punctuation and parentheses). Add a group to capture those items before and between each section of the expression youve written so far. You dont know if the characters will appear between the numbers, and you dont know exactly which characters might appear, so you can simply capture all white space and non-word characters before and between the numbers:
1? (\s|\W)*[2-9][\d]{2}(\s|\W)*[\d]{3}(\s|\W)*[\d]{4}

Rule 5: Numbers typically appear in groups of 3, 3, and 4, corresponding to Area_code, Prefix, and LineNumber. Youve already written the regular expression code to capture these sequences; now, place them into named groups. Not only will this make them easier to identify in the captured text, but it also serves to exclude any captured white space or non-word characters:
(\s|\W)*1?(\s|\W)*(?<areacode>[2-9]) [\d]{2})(\s|\W)*(?<prefix>[\d]{3}) (\s|\W)*(?<linenumber>[\d]{4})

98

General .NET Topics

Rule 6: Numbers may be preceded or succeeded by white space or by any other characters except numbers. Any amount of white space or other text (except numbers) may appear either before or after a valid phone number. In addition, its perfectly possible that no characters precede or succced the phone number. Your expression must ignore leading or trailing spaces or non-numeric characters.
(\s|\W)*1?(\s|\W)* (?<areacode>[2-9] [\d]{2})(\s|\W)*(?<prefix>[\d]{3})(\s|\W)* (?<linenumber>[\d]{4})(\s|\W)*

It turns out that you dont really need the trailing white-space capture, because if the phone number is valid, it will stop matching when it reaches the 10th (or 11th, if the number begins with 1) digit. If the number isnt valid, it wont matter whether you capture the trailing white space. You can remove the trailing white space and non-word character capture expression:
(\s|\W)*1?(\s|\W)* (?<areacode>[2-9]) [\d]{2})(\s|\W)*(?<prefix>[\d]{3}) (\s|\W)*(?<linenumber>[\d]{4})[^\d]

Using this expression, you can write an isValidPhone method such as the one shown in Listing 2.

Listing 2

The isValidPhone method returns True for valid U.S.-formatted phone numbers.

Function isValidPhone( _ ByVal phone As String) As Boolean create a new regular expression to check the phone number Dim reg As New Regex( _ (\s|\W)*1?(\s|\W)*(?<areacode>[2-9] & _ [\d]{2})(\s|\W)*(?<prefix>[\d]{3}) & _ (\s|\W)*(?<linenumber>[\d]{4})([^\d]|\b){1}) get the match list Dim matches As MatchCollection = _ reg.Matches(phone) Dim aMatch As Match Dim sb As New StringBuilder are there any matches? If matches.Count = 0 Then Me.txtResult.Text = _ Not a valid phone number. Return False Else display each match For Each aMatch In matches sb.Append(Matched text: & _ & aMatch.Value & & _

Solution 7 Gain Control of Regular Expressions

99

, & Space(20 - _ aMatch.Value.Length) _ & At position: & _ aMatch.Index & _ System.Environment.NewLine) If aMatch.Groups.Count > 0 Then show area code match If aMatch.Groups _ (areacode).Value <> Then sb.Append(Area Code: & _ aMatch.Groups _ (areacode).Value & _ System.Environment.NewLine) Else Return False End If If aMatch.Groups(prefix).Value _ <> Then show prefix match sb.Append(Prefix: & _ aMatch.Groups _ (prefix).Value & _ System.Environment.NewLine) Else Return False End If If aMatch.Groups _ (linenumber).Value <> Then show line number match sb.Append(Line Number: & _ aMatch.Groups _ (linenumber).Value & _ System.Environment.NewLine) Else Return False End If End If Next End If show final result Me.txtResult.Text = sb.ToString Return True End Function

Now that youve followed the process for building these two regular expressions, you can probably see that the hard part isnt writing the expressionits deciding exactly what you want to capture and how to ignore or discard text that you dont want to capture. The number of possibilities for variation in text makes building (and especially reading) regular expressions difficult. On the other hand, the flexibility of regular expressions makes it possible to perform extremely complex pattern matching in just a few lines of code.

100

General .NET Topics

SOLUTION

8
SOLUTION

Add Sort Capabilities to Your .NET Classes


PROBLEM

Writing sort routines is always painful. Although .NET contains built-in Sort methods for some types of collections, for others, you must implement your own Sort method.

Use the IComparable interface to simplify your sorting code and compare objects in any way you wish.

If youre tired of writing special sort routines to sort your classes, youll be happy to know thatin most casesyou can simplify your sorting code considerably using the .NET Frameworks built-in IComparable interface. Adding the IComparable interface to your classes lets you compare one instance of a class to another in any way that you like. The interface requires you to implement only one method:
Public CompareTo(object obj) As Integer

The implemented method must be public and must not be static (Shared in VB.NET). After you implement the IComparable interface, the simplest way to sort items is to use the built-in Sort method of the ArrayList class. The class uses a QuickSort algorithm to sort the items by calling the CompareTo method of the IComparable interface.

Sorting Base Types


Several base types (Enum, String, and Version) already implement IComparable, so you can sort arrays of these types by calling the Sort method. For example, heres a way to sort a simple list of Integers:
create an integer array Dim ar(1000) As Integer For i = 1000 To 0 Step -1 ar(1000 - i) = i Next sort the array Array.Sort(ar)

If you then use the ar array as the value of the DataSource property for a ListBox control, youll see that the Integers appear in ascending order (see Figure 1):

Solution 8 Add Sort Capabilities to Your .NET Classes

101

set the new DataSource listBox1.DataSource = ar

Note that in this case you dont need to set the ListBoxs DataMember propertyby default, the ListBox calls the ToString method for each item, which works fine for base types. NOTE
See Solution 1 for an in-depth discussion of displaying items in ListBoxes and ComboBoxes in .NET.

Sorting String objects is essentially identicalyou place them in an array and call the Sort method:
create a new String array Dim ar() As String = New String() {One, Two, Three, _ Four, Five} sort it Array.Sort(ar)

You can also use the ArrayList class to sort arbitrary lists of base types by adding items to the list and then calling the Sort method. But watch out! Sorting base types with an ArrayList is far less efficient than sorting a typed array. Until the Framework provides typed collections, stick to arrays when you need sorted base type values. WARNING Despite the ease with which you can sort using the ArrayList class, you should not normally use it to sort large lists of base types, such as a list of Integers, because the Framework has to box and unbox the values, which slows down the sort.

FIGURE 1:
A list of Integers sorted with the

ArrayList.Sort
method

102

General .NET Topics

Sorting Custom Classes


Although you can sort base types using built-in functionality in the .NET Framework, you have to do just a bit more work to be able to sort custom classes. But the Framework does help. All you need to do is implement the IComparable interface in your classes, and you can sort arrays of those classes in much the same way you sort arrays of base types.

Sorting Using the IComparable Interface


To implement the IComparable interface for your own classes, first decide how you want to compare each item, and then use that as the basis for your implementation of the CompareTo method. For example, suppose you want to fill a ListBox with a list of peoples names, sorted by last name, and then by first name. When a user clicks an item, you want to get that persons ID value. You can easily create a class Person to hold the names and IDs and use that for the ListBox items, as demonstrated in Listing 1.

Listing 1

The Person class (Person.vb)

Imports System.Collections Public Class Person Implements IComparable Public StateOfResidence As String Public Lastname As String Public Firstname As String Public ID As Integer new person constructor Public Sub New(ByVal state As String, ByVal last As String, _ ByVal first As String, ByVal ID As Integer) Me.StateOfResidence = state Me.Lastname = last Me.Firstname = first Me.ID = ID End Sub Implementation of IComparable.CompareTo Public Function CompareTo(ByVal aPerson As Object) As Integer _ Implements IComparable.CompareTo Dim p As Person = CType(aPerson, Person) Return (Me.Lastname & , & _ Me.Firstname).CompareTo(p.Lastname & , & p.Firstname) End Function Public ReadOnly Property Name() As String Get Return Me.Lastname & , & Me.Firstname End Get End Property

Solution 8 Add Sort Capabilities to Your .NET Classes

103

Format return value as (ST)Last, First Public ReadOnly Property StateAndName() As String Get Return ( & Me.StateOfResidence & ) & Me.Name End Get End Property Format return value as ID Last, First Public ReadOnly Property IDAndName() As String Get Return Me.ID & & Me.Name End Get End Property End Class

The CompareTo method in the Person class is the method you must implement for the IComparable interface. It accepts an Object instance parameter, so you will normally need to cast that object back to the correct typein this case, a Person object. In the preceding code, the CompareTo method implementation performs that cast, concatenates the last and first names for both Person objects into a single string, and then returns the result of using the String.CompareTo method (which, because its also an implementation of IComparable, returns an int value) to compare the two strings. TIP
Be aware of the compare order. The order in which you compare objects is important!

To sort in ascending order, you want to compare the current Person to the passed aPerson argument. If you compare them the other way around, youll sort the list in reverse order; for example, the following implementation would sort the ArrayList in descending order:
Implementation of IComparable.CompareTo Public Function CompareTo(ByVal aPerson As Object) As Integer _ Implements IComparable.CompareTo Dim p As Person = CType(aPerson, Person) Return (Me.Lastname & , & _ Me.Firstname).CompareTo(p.Lastname & , & p.Firstname) End Function

After you implement IComparable, sorting the new Person class becomes essentially identical to sorting base types. The code for the btnPersonSort_Click event in the sample form creates an ArrayList of Person objects and then calls the Sort method, which in turn calls the CompareTo method to perform the sort, as shown in the following code:
create a new Person array Dim ar As ArrayList = New ArrayList()

104

General .NET Topics

ar.Add(New ar.Add(New ar.Add(New ar.Add(New ar.Add(New

Person(MI, Person(MI, Person(MI, Person(AR, Person(CA,

Barbara, Boling, 8573)) Khula, Fatima, 1627)) Smith, John, 5921)) Smith, Arthur, 9217)) Jones, Fred, 3972))

sort the array ar.Sort()

Sorting Using the IComparer Interface


Sorting using the IComparable interface is convenient, but sometimes you want to be able to sort in both ascending and descending order, or to be able to sort on more than one property. You might expect that the IComparable.CompareTo method would be overloaded to accommodate multiple sorts, but its not. Instead, you can use overloaded versions of the ArrayList.Sort method that accept a class instance which implements the IComparer interface. Like IComparable, the IComparer interface also has only one method that you must implementthe Compare method. For example, suppose you want to be able to sort the list of Person objects not only in lastname/firstname order, but also by their state of residence. Without changing the code in the Person class, you can create a new class that implements the IComparer interface and pass an instance of that class to the ArrayList.Sort method. Listing 2 shows an example.

Listing 2

The PersonSorterByState class (PersonSorterByState.vb)

Imports System.Collections IComparer Implementation Compares Persons collection by StateOfResidence Public Class PersonSorterByState Implements IComparer Public Function Compare(ByVal o1 As Object, _ ByVal o2 As Object) As Integer Implements IComparer.Compare Dim p1 As Person = CType(o1, Person) Dim p2 As Person = CType(o2, Person) Return p1.StateOfResidence.CompareTo(p2.StateOfResidence) End Function End Class

Using the overloaded version causes the ArrayList to call the IComparer.Compare method of the passed-in class to compare the items in the ArrayList. Internally, the Compare method implementation shown in Listing 2 simply casts the two Object arguments to Person objects and then compares their stateOfResidence property values. Now, after creating an ArrayList

Solution 8 Add Sort Capabilities to Your .NET Classes

105

of Person objects, you can call the Sort method and pass an instance of the PersonSorterByState class to sort the Person list by state in ascending order:
create a new Person array Dim ar As ArrayList = New ArrayList() ar.Add(New Person(MI, Barbara, Boling, 8573)) ar.Add(New Person(MI, Khula, Fatima, 1627)) ar.Add(New Person(MI, Smith, John, 5921)) ar.Add(New Person(AR, Smith, Arthur, 9217)) ar.Add(New Person(CA, Jones, Fred, 3972)) sort it using the PersonSorterByState IComparer implementation ar.Sort(New PersonSorterByState()

Partial Sorts
You dont have to sort the entire list of objects in an ArrayList. A third overloaded Sort method accepts a starting and ending position in the list, as well as an IComparer class, as in the preceding code snippet. You can use this method to sort a specific subset of a list. For example, you may have noticed that the PersonSorterByState IComparer implementation described in the preceding section produced a list in proper StateOfResidence order but did not sort the Person objects within each state in lastname/firstname order. One way to solve that problem is to add a new IComparer implementation that takes the name into account. Another approach is to perform a second sort for each subset of the list where the state remains constant but the names differ. To do that, you can first sort by state and then iterate through the sorted list, tracking the state and sorting the list subset for each state as you encounter a change. I wont show the code here, but the sample application contains an example.

Sorting Typed Arrays


So far, youve seen examples showing that, at least for sorting purposes, its often convenient to place objects in untyped ArrayLists. But you dont have to use untyped collections. The Array class also implements a Sort method with eight overloaded versions, and you can use it to sort arrays of typed objects in the same way you sort base types. To sort an array of Person objects, for example, you can still use the classes you created in the previous section that implement the IComparer interface. The only significant difference, from a coding point of view, is that you use the static (Shared) Array.Sort method, which accepts the array you want to sort as an argument. Other than that, the code should look familiar by this time. To use the method, create an array of some class type, and then call the Array.Sort method, passing

106

General .NET Topics

the array and the IComparer implementation class you want to use to sort the array contents. For example:
Dim persons(4) As Person persons.SetValue(New Person(MI, Barbara, Boling, 8573), 0) persons.SetValue(New Person(MI, Khula, Fatima, 1627), 1) persons.SetValue(New Person(MI, Smith, John, 5921), 2) persons.SetValue(New Person(AR, Smith, Arthur, 9217), 3) persons.SetValue(New Person(CA, Jones, Fred, 3972), 4) Array.Sort(persons, New PersonSorterByStateByName())

Sorting Custom Collections


Not all the collections you use are Arrays or ArrayListsand unfortunately, no other built-in collection type implements the Sort method (although some controls, such as the ListBox, ComboBox, and DataGrid, support sorting). However, it turns out that one of the most commonly inherited base collection classes (CollectionBase) exposes an InnerList property that returns an ArrayList containing the objects in the collection. Using that ArrayList, you can sort the items in the collection in exactly the same ways youve already seen described in this solution. For instance, the sample class Persons (see Listing 3) inherits System.Collections.CollectionBase and uses the InnerList property to sort its contents via a public Sort method.

Listing 3

The Persons custom collection (Persons.vb)

Imports System.Collections Public Class Persons Inherits System.Collections.CollectionBase add a new Person to the collection Public Sub Add(ByVal p As Person) Me.List.Add(p) End Sub remove the specified Person from the collection Public Sub Remove(ByVal p As Person) Me.List.Remove(p) End Sub Shadows required -- Overrides wont work Remove the Person object at the specified index position Public Shadows Sub RemoveAt(ByVal index As Integer) Me.List.RemoveAt(index) End Sub Shadows required -- Overrides wont work Clear the list Public Shadows Sub Clear()

Solution 8 Add Sort Capabilities to Your .NET Classes

107

Me.List.Clear() End Sub Insert a Person object at the specified index position Public Sub Insert(ByVal index As Integer, ByVal p As Person) Me.List.Insert(index, p) End Sub Sort the collection using the specified IComparer class Public Sub Sort(ByVal comparer As IComparer) Me.InnerList.Sort(0, Me.InnerList.Count, comparer) End Sub End Class

Sorting Multidimensional Arrays


Unfortunately, the Array.Sort method applies to single-dimensional arrays only, so when you want to sort multidimensional arrays, you must either write custom sort classes or copy the data to a single-dimensional array and sort that. As an example of copying data to singledimensional arrays, consider this two-dimensional Person[3][] ragged arraythe 0th item in each child array contains People objects who are Managers, while the subsequent items contain People objects who report to those Managers. The goal is to sort the array in lastname/firstname order and then to fill a TreeView control with the sorted list. To do that, you need to get an array containing only the Managers (the 0th item in each child array), sort that, and then sort the rest of the items by copying them to a new array and sorting. Listing 4 shows the code.

Listing 4

Performing a multidimensional sort (Form1.vb)

Private Sub btnMultiDimensionalSort_Click( _ ByVal sender As Object, ByVal e As System.EventArgs) _ Handles btnMultiDimensionalSort.Click Dim i, j As Integer create a three-item ragged array. Dim p(2)() As Person fill p(0) = New New New } it with managers and people. New Person() { _ Person(MI, Manager, 2, 3819), _ Person(MI, Smith, John, 5921), _ Person(AR, Smith, Arthur, 9217) _

p(1) = New Person() { _

108

General .NET Topics

New Person(MI, Manager, 1, 5813), _ New Person(MI, Khula, Fatima, 1627), _ New Person(MI, Barbara, Boling, 8573) _ } p(2) = New Person() { _ New Person(MI, Manager, 3, 2873), _ New Person(CA, Jones, Fred, 3972) _ } create an array to hold a copy of the managers Dim mgrCopy(p.Length - 1) As Person For i = 0 To p.GetLength(0) - 1 copy the managers mgrCopy(i) = p(i)(0) now create an array to copy the employees for this manager Dim empCopy(p(i).Length - 2) As Person copy the employees Array.Copy(p(i), 1, empCopy, 0, empCopy.Length) sort the employees Array.Sort(empCopy) copy them back in sorted order Array.Copy(empCopy, 0, p(i), 1, empCopy.Length) Next sort the managers Array.Sort(mgrCopy) and copy them back For i = 0 To mgrCopy.Length - 1 p(i)(0) = mgrCopy(i) Next now fill the TreeView with the managers and employees tv1.BeginUpdate() tv1.Nodes.Clear() For i = 0 To p.Length - 1 Dim mgrNode As TreeNode = New TreeNode() For j = 0 To p(i).Length - 1 Dim prsn As Person = p(i)(j) If Not prsn Is Nothing Then If j = 0 Then mgrNode = tv1.Nodes.Add(prsn.Name)

Solution 9 A Plethora of XML Choices

109

Else mgrNode.Nodes.Add(prsn.Name) End If End If show all nodes tv1.ExpandAll() tv1.EndUpdate() Next Next End Sub

As you can see, sorting hierarchical data is considerably more complicated than sorting simple lists and collections. In fact, if the hierarchy went deeper than one level, unless the data were extremely stable youd probably be much better off putting the data into a database and sorting it there, or writing an XML file and using XSLT to sort it.

Consider Efficiency
While the built-in QuickSort algorithm available through the ArrayList class is easy to use and sufficiently speedy for most purposes, it isnt always the best choice. In some cases, youll find that writing your own sorting method specifically for your particular data, class, or classes is more efficient. For example, the QuickSort method is not the fastest algorithm when the data is already nearly sorted. Other sorting algorithms are available, well documented, and well publicized. When speed is an issue, you should do a little research and implement your own sort using a more appropriate sorting algorithm.

SOLUTION

9
SOLUTION

A Plethora of XML Choices


PROBLEM

XML seems to be the choice for almost every data file these days. The .NET Framework provides so much support for XML that its difficult to know when to use what.

Although there are a number of classes in the System.Xml namespace, youll often find that the correct choice is clear. Follow these guidelines and examples to select the classes that best suit your needs when working with XML in the .NET Framework.

110

General .NET Topics

The .NET Frameworks extensive XML capabilities let you work with XML-formatted data in several ways. When the data is regular, you can use DataSets. For irregular data, or for querying XML documents when the structure doesnt fit neatly into a DataSet, you can use a DOM model (XmlDocument). For reading XML very quickly, or to consume large documents, you can use an XmlTextReader. The Framework also provides the XPathDocument class to improve XPath query speed, the XPathNodeIterator to iterate through node sets, and the XslTransform class to hold XSLT style sheets and perform XML transformations. This solution explains the differences between these classes and how and when you might use each one. At the simplest level, you can consider an XML file as nothing more than a delimited text file, where the delimiters are either angle brackets (elements, processing instructions, comments, etc.), white space (attributes), or quotes (attribute values). Therefore, its not terribly difficult to write a parser that reads one chunk or node of XML at a time. Thats what the XmlTextReader class does.

Using the XmlTextReader Class


You almost always have a choice of which XML class to use, but here are some guidelines for using the XmlTextReader class. Choose an XmlTextReader when you:

Want to read a document one time, as quickly as possible Have to work with very long documents that dont need to reside in memory Only need to check to see if a document is well formed Want to copy a document from one file to another or make an altered copy of the document.

The XmlTextReader class is very fast, because it makes a single read-only forward-only pass through the data. As it reads nodes, the XmlTextReader maintains an internal state, which you can test to find and extract data. Listing 1 shows a short example that reads an XML file and displays the result. WARNING Although the sample code doesnt include error trapping, you should definitely trap for
errors whenever you perform file operations.

Listing 1

Reading and displaying the contents of an XML file (Form1.vb)

Private Sub btnSimpleXmlTextReader_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnSimpleXmlTextReader.Click

Solution 9 A Plethora of XML Choices

111

Dim sbResult As New StringBuilder(50000) open the employees.xml file Dim xtr As New XmlTextReader( _ File.OpenRead(empFilename)) clear the textbox Me.txtResult.Clear() read each node Do While Not xtr.EOF xtr.Read() append the result to a StringBuilder sbResult.Append(xtr.ReadOuterXml) Loop close the XmlTextReader xtr.Close() show the results Me.txtResult.Text = sbResult.ToString End Sub

The example opens an XmlTextReader on the file named employees.xml (see Solution 4 for an explanation of the file contents) and reads nodes until it reaches the end of the file, appending a string representation of each node to a StringBuilder. It obtains the string representation by calling the XmlTextReader.ReadOuterXml method. Finally, the code closes the reader and displays the result in a TextBox on the sample form. You might ask where it gets the XML file to read. The employees.xml file and all the other XML and XSLT files in this solution are embedded resources. At startup, the Form1_Load method in the sample Form1 form class looks for copies of the files in the directory obtained using the Application.StartupPath property. If the files already exist, it deletes them, and then calls the createEmbeddedFiles method, which extracts the resource strings and writes them to disk in the Application.StartupPath.
Private Sub Form1_Load( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles MyBase.Load empFilename = Application.StartupPath & \employees.xml empSchemaFilename = Application.StartupPath & _ \employees.xsd empStylesheetFilename = Application.StartupPath & _ \employees.xsl

112

General .NET Topics

empsToHtmlStylesheetFilename = _ Application.StartupPath & \empsToHTML.xsl If File.Exists(empFilename) Then File.Delete(empFilename) End If If File.Exists(empSchemaFilename) Then File.Delete(empSchemaFilename) End If If File.Exists(empStylesheetFilename) Then File.Delete(empStylesheetFilename) End If If File.Exists(empsToHtmlStylesheetFilename) Then File.Delete(empsToHtmlStylesheetFilename) End If createEmbeddedFiles() End Sub

Private Sub createEmbeddedFiles() check for the employees.xml file Dim names As String() = New String() _ {employees.xml, employees.xsd, employees.xsl, _ empsToHTML.xsl} Dim aName As String Dim i As Integer For i = 0 To names.Length - 1 aName = names(i) If Not File.Exists(Application.StartupPath & _ \ & aName) Then get the embedded copy of the file Dim resReader As New EmbeddedResourceReader() Dim s As String = _ resReader.GetResourceString _ (_4253c9. & aName) Dim sw As New StreamWriter( _ File.OpenWrite(Application.StartupPath & _ \ & aName)) write it to disk sw.Write(s) close the stream sw.Close() End If Next End Sub

Solution 9 A Plethora of XML Choices

113

The createEmbeddedFiles method creates an instance of an EmbeddedResourceReader class and then calls its GetResourceString method, passing the full name of the resource as a parameter. The GetResourceString method retrieves the specified resource as a stream from an assembly (in this case, the startup assembly) by using the Assembly class GetManifestResourceStream method. Finally, it reads the stream contents into a string and returns the string. As delivered with this Solution, the EmbeddedResourceReader class contains only one method, but you could extend the class to handle other types of resources, such as image data, and/or to return specific classes of resources. For more information on embedded resources, see Anthony Glenwrights article, How to Embed Resource Files in .NET Assemblies, which you can find here: www.devx.com/dotnet/Article/10831. Listing 2 shows the complete EmbeddedResourceReader class code.

Listing 2

The EmbeddedResourceReader class code (EmbeddedResourceReader.vb)

Imports System.Reflection Imports System.IO Public Class EmbeddedResourceReader Public Function GetResourceString( _ ByVal key As String) As String Dim s As String = Nothing Try Dim sr As New System.IO.StreamReader( _ [Assembly].GetEntryAssembly. _ GetManifestResourceStream(key)) s = sr.ReadToEnd sr.Close() Return s Catch ex As Exception Throw ex End Try End Function End Class

Heres another example of manipulating XML with the XmlTextReader. The employees.xml file contains name, address, and phone data for sets of employees arranged by department. Suppose you just want to extract all the employees last and first names and IDs, and display them as a sorted list. The XmlTextReader shines at tasks such as this, because you can query the reader for the node type and node name, as well as the depth (how deep the current node is in the element hierarchy); the text content of the node; its attributes, if any; and the line number and position of the node within the XML file. All this information makes it very easy to extract only the data you need. Listing 3 shows the code.

114

General .NET Topics

Listing 3

Extracting specific XML values using the XmlTextReader class (Form1.vb)

Private Sub btnComplexXmlTextReader_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnComplexXmlTextReader.Click Dim xtr As New XmlTextReader(File.OpenRead(empFilename)) Dim names As New ArrayList() Dim aName As String Dim sb As StringBuilder Me.txtResult.Clear() Do While Not xtr.EOF xtr.Read() Select Case xtr.NodeType you only care about elements Case XmlNodeType.Element xtr.i() If xtr.Name = employee Then sb = New StringBuilder() Do While xtr.MoveToNextAttribute If xtr.Name = id Then sb.Append(xtr.Value & : ) Exit Do End If Loop End If If xtr.Name = lastname Then sb.Append(xtr.ReadString()) End If If xtr.Name = firstname Then sb.Append(, & xtr.ReadString()) aName = sb.ToString() aName = aName.Substring(aName.IndexOf( ) _ + 1) & , & (aName.Substring(0, _ aName.IndexOf(: ) - 1)) names.Add(aName) End If Case Else do nothing End Select Loop xtr.Close() names.Sort() sb = New StringBuilder() Dim i As Integer For i = 0 To names.Count - 1 aName = CType(names(i), String)

Solution 9 A Plethora of XML Choices

115

sb.Append(aName & System.Environment.NewLine) Next Me.txtResult.Text = sb.ToString End Sub

The XmlTextWriter simplifies fast one-way XML parsing, but you also need to be able to write XML. The XmlTextWriter class simplifies write operations.

Using the XmlTextWriter Class


The XmlTextWriter class contains methods for writing well-formed XML. Before .NET, most Microsoft programmers either used the MSXML.DOMDocument object to construct new documents or concatenated a string containing the new content and wrote that to disk. Those methods still work, but the XmlTextWriter is a better choice, because it simply writes text content, its fast (faster than using a DOM parser), and it improves on the string concatenation scheme by helping ensure that the output is well formed. Use an XmlTextWriter when you:

Need to write well-formed XML to disk or to a stream Want to make a modified copy of an existing XML document Need to create pretty indented output from XML data

Constructing a New XML Document


Heres an example. Suppose you have two strings, This is string 1 and This is string 2, and you want to write them to a new file as XML, where the root element is <strings> and each string is in a <string> element with an ID that matches the number at the end of the string. The output should look like this:
<?xml version=1.0 encoding=utf-8?> <strings> <string id=s1>This is string 1</string> <string id=s2>This is string 2</string> </strings>

Listing 4 shows how to use an XmlTextWriter to write the output.

Listing 4

Constructing a new XML document with the XmlTextWriter class (Form1.vb)

Private Sub btnXmlTextWriterNewDoc_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnXmlTextWriterNewDoc.Click filename for new document

116

General .NET Topics

Dim newFilename As String = _ Application.StartupPath & \newDocument.xml delete it if it already exists If File.Exists(newFilename) Then File.Delete(newFilename) End If create the string array Dim strings() As String = _ New String() {This is string 1, _ This is string 2} and the XmlTextWriter Dim xtw As New XmlTextWriter( _ File.OpenWrite(newFilename), _ System.Text.Encoding.UTF8) Dim aNumber As Integer Dim aString As String write the xml declaration xtw.WriteStartDocument() write the root element xtw.WriteStartElement(strings) For Each aString In strings create a <string> element xtw.WriteStartElement(string) get the number for the id aNumber = Integer.Parse( _ aString.Substring( _ aString.LastIndexOf( ) + 1)) write the id attribute xtw.WriteAttributeString( _ id, s & aNumber.ToString) write the content xtw.WriteString(aString) close the <string> element xtw.WriteEndElement() Next close the <strings> element xtw.WriteEndElement() end the document

Solution 9 A Plethora of XML Choices

117

xtw.WriteEndDocument() flush to disk xtw.Flush() close the writer xtw.Close() End Sub

Inserting New Elements into an Existing Document


Now suppose you had a long XML document containing many strings, and you wanted to append a new <string> element to the end of the list of <string> elements in the document. In other words, you want to copy everything in the file to a new file, and also create a new <string> element just before the closing </strings> element. You can do this using an XmlTextReader and an XmlTextWriter in tandem. Open the reader on the file to which you want to append text, and open the writer on a temporary file. Listing 5 shows the process.

Listing 5

Using XmlTextReader and XmlTextWriter to insert new content into an existing file (Form1.vb)

Private Sub btnXmlTextWriterAppend_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnXmlTextWriterAppend.Click open the newDocument.xml file if it exists, otherwise, create it. Dim aNumber As Integer Dim aString As String = This is string 3 Dim tempFilename As String Dim newFilename As String = Application.StartupPath & _ \newDocument.xml If Not File.Exists(newFilename) Then btnXmlTextWriterNewDoc_Click( _ Me.btnXmlTextWriterNewDoc, Nothing) End If open the newDocument.xml file with a new XmlTextReader Dim xtr As New XmlTextReader(newFilename) open a temporary file with a new XmlTextWriter tempFilename = Path.GetTempFileName Dim xtw As New XmlTextWriter( _ File.OpenWrite(tempFilename), _ System.Text.Encoding.UTF8) xtw.WriteStartDocument() xtr.MoveToContent()

118

General .NET Topics

Write the root xtw.WriteStartElement(xtr.LocalName) move reader to next node xtr.Read() Do While Not xtr.EOF If xtr.NodeType = XmlNodeType.EndElement _ AndAlso xtr.LocalName = strings Then xtw.WriteStartElement(string) aNumber = Integer.Parse( _ aString.Substring( _ aString.LastIndexOf( ) + 1)) xtw.WriteAttributeString(id, _ s & aNumber.ToString) xtw.WriteString(aString) xtw.WriteEndElement() write the </strings> element xtw.WriteNode(xtr, False) replace the preceding line with Exit Do to see the effect of the WriteEndDocument method Exit Do Else xtw.WriteNode(xtr, True) End If Loop xtr.Close() close all elements xtw.WriteEndDocument() xtw.Flush() xtw.Close() File.Delete(newFilename) File.Move(tempFilename, newFilename) show the results Dim sr As New StreamReader(newFilename) Me.txtResult.Text = sr.ReadToEnd sr.Close() End Sub

You can, of course, substitute any logic you wish to determine where to insert the new data. The preceding snippet searches for the closing </strings> element, but you can use similar code to search for any element or content in the file. One interesting point to note about the XmlTextWriter.WriteEndDocument method is that it closes all open nodes, so you could, for example, comment out the highlighted line in Listing 5 and substitute the Exit Do line without affecting the results.

Solution 9 A Plethora of XML Choices

119

Modifying Elements in an Existing Document


The process for altering or removing content in an existing document is similaryou open an XmlTextReader on the document and then read nodes, copying the content using an XmlTextWriter until you find the content you want to change or remove. At that point, you use the writer to write the altered content to the output stream. Its important to understand that you dont have to write to a file. The code in Listing 6 reads the employees.xml file, concatenating the <lastname> and <firstname> nodes to a single <name> element, and removing each employees home phone number and address. Instead of writing the results to a file, the code writes to a StringBuilder (sbResult) and then displays the results.

Listing 6

Concatenating elements using the XmlTextReader and XmlTextWriter classes (Form1.vb)

Private Sub btnXmlTextWriterRemove_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnXmlTextWriterRemove.Click This method reads the employees XML data and outputs a subset of that data, concatenating names and eliminating home phone numbers and addresses. Dim xtr As New XmlTextReader( _ File.OpenRead(empFilename)) Dim sb As StringBuilder Dim sbResult As New StringBuilder(40000) Dim xtw As New XmlTextWriter( _ New StringWriter(sbResult)) xtw.Formatting = Formatting.Indented xtw.Indentation = 3 Dim lastname As String Me.txtResult.Clear() Do While Not xtr.EOF xtr.Read() Select Case xtr.NodeType Case XmlNodeType.Element Select Case xtr.Name Case employee sb = New StringBuilder() xtw.WriteStartElement(xtr.Name) xtw.WriteAttributes(xtr, False) Case lastname sb.Append(xtr.ReadString()) Case firstname sb.Append(, & xtr.ReadString()) xtw.WriteElementString(name, _ sb.ToString)

120

General .NET Topics

Case home, address xtr.ReadOuterXml() dont write anything Case Else xtw.WriteNode(xtr, False) End Select Case Else xtw.WriteNode(xtr, False) End Select Loop xtr.Close() Me.txtResult.Text = sbResult.ToString xtw.Close() End Sub

As the code loops through the elements in the employees.xml file, it writes content to the sbResult StringBuilder using the XmlTextWriter. When it encounters an <employee> element, it writes the opening tag, creates a new StringBuilder object (sb), and continues reading nodes. When it encounters a <lastname> element, it appends the element content to the sb StringBuilder but doesnt write the element. When the code encounters a <firstname> element, it appends a comma and the element content to the sb StringBuilder, and then writes a <name> element containing the concatenated name. Finally, it also skips writing output for the <home> and <address> elements. For all other elements, the code uses the XmlTextWriter.WriteNode method to copy node content to the output stream. Note that the output is nicely indented. Thats entirely due to these two lines of code, which tell the XmlTextWriter to write indented lines and to use three spaces for each indentation level:
xtw.Formatting = Formatting.Indented xtw.Indentation = 3

The ability to pretty print XML documents is one of the best features of the .NET XML namespace.

Creating XSD Schema in Visual Studio


When reading and writing XML, you want to ensure that documents youre working with contain specific elements, attributes, and content types. For example, suppose youre getting XML input created by another programmer or organization. Youve worked out the format and content for this document, but when you actually receive a file, how do you know that it adheres to the agreed-upon format and content? Sure, you can scroll through the document, trapping unrecognized elements and checking values, but one of the main advantages of XML is that it makes that validation process completely automatic.

Solution 9 A Plethora of XML Choices

121

This solution is far too short to discuss schema in detail, but briefly, a schema is an XML document that describes the markup and content for some other XML document. Fortunately, you dont need to know much about schemas to create and use them in Visual Studio (VS). VS can create a basic schema for you from any well-formed XML document loaded into the VS editor. Unfortunately, it doesnt always make the best schema. Sometimes you have to fiddle with the element order a bit to make the schema work properly. Take a look at the employees.xsd file in the sample code. That file is an embedded resource, just like the employees.xml file.

Using the XmlValidatingReader Class


When you need to both read and validate content against a schema or DTD, you use the XmlValidatingReader class. To create an instance of this class, open a standard XmlTextReader on a file, and then use that as the argument to the XmlValidatingReader constructor. You use the XmlValidatingReader class in exactly the same situations where you would use an XmlTextReader, but you must validate the input against a schema or DTD. Listing 7 shows the process of validating an XML document against a schema.

Listing 7

Validating XML with an XmlValidatingReader (Form1.vb)

Private Sub btnXmlValidatingReader_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnXmlValidatingReader.Click create an XmlSchema object and load it with the employees.xsd file Dim schema As XmlSchema schema = XmlSchema.Read( _ File.OpenRead(empSchemaFilename), _ New ValidationEventHandler( _ AddressOf Me.ValidationFailed)) schema.Compile(AddressOf Me.ValidationFailed) create a new XmlValidatingReader Dim xvr As New XmlValidatingReader( _ New XmlTextReader(empFilename)) set the ValidationType to schema xvr.ValidationType = ValidationType.Schema add the employees.xsd schema to the collection xvr.Schemas.Add(schema) when validation errors occur, call this method

122

General .NET Topics

AddHandler xvr.ValidationEventHandler, _ AddressOf Me.ValidationFailed clear the results textbox Me.txtResult.Clear() read and validate all nodes While xvr.Read do nothing End While the ValidationEvent handler (ValidationFailed method) displays validation errors in the txtResults TextBox. If the TextBox is empty, validation succeeded. If Me.txtResult.Text = Then Me.txtResult.Text = The file validated properly Else Me.txtResult.Text = XML Validation Failed & _ System.Environment.NewLine & Me.txtResult.Text End If close the reader xvr.Close() End Sub display validation errors Public Sub ValidationFailed( _ ByVal sender As System.Object, _ ByVal e As System.Xml.Schema.ValidationEventArgs) Me.txtResult.AppendText(e.Severity.ToString & : _ & e.Message & System.Environment.NewLine & _ System.Environment.NewLine) End Sub

There are three points you should note in Listing 7. First, the code shows how to create an XmlSchema object by reading it from a file (you can also create an XmlSchemaCollection object and cache all your schemas in memory, perhaps at application startup). The XmlValidatingReader.Schemas property accepts either a single schema or a collection. Second, note that if youre going to use a schema or DTD for validation, you should set the ValidationType property to the correct value. You must add the schema or DTD you want to use before calling the XmlValidatingReader.Read method. Third, note that in this example, the reader simply reads all the nodesit doesnt do anything with them:
read and validate all nodes While xvr.Read

Solution 9 A Plethora of XML Choices

123

do nothing End While

Simply reading the nodes forces validation on each node; however, you could perform processing in the same way described in the XmlTextReader examples in the preceding sections.

Handling Validation Errors


If a validation error occurs, you should handle it via a public or shared ValidationEventHandler method, as shown here:
AddHandler xvr.ValidationEventHandler, _ AddressOf Me.ValidationFailed

The validation handler must match the ValidationEventHandler signature, which accepts an Object and a ValidationEventArgs instance as arguments. The ValidationFailed method in the sample code matches the ValidationEventHandler signature. When a validation error occurs, the method displays the error message in the txtResults TextBox. When you run this code against the employees.xml file, you should see the message The file validated properly in the txtResults TextBox. To see an error occur, try switching the <lastname> and <firstname> elements for the first employee in the file. In other words, switch
<employee id=e50> <lastname>Chen</lastname> <firstname>Kelly</firstname>

to
<employee id=e50> <firstname>Kelly</firstname> <lastname>Chen</lastname>

Save the changes, and then run the program again. This time, youll see an error appear when you click the XmlValidatingReader Example button. The ValidationEventArgs class exposes a Severity value (from the XmlSeverityType enumeration) containing either Error or Warning. The change you just introduced is an error. In fact, any change that causes validation to fail is an error. Warnings occur when, according to the documentation, [a] validation event occurred that is not an error but may be important enough to warn the user about. A warning is typically issued when there is no DTD, XML-Data Reduced (XDR) or XML Schema (XSD) to validate a particular element or attribute against. However, in testing this code, I was unable to force a warning by inserting elements or attributes that didnt exist in the schemasuch actions produced only errors. At this point, youve seen most of what you can do with an XmlTextReader, and its time to move on to another way of working with XML: the DOM.

124

General .NET Topics

Using the XmlDocument Class


Unless you need the best possible performance for single-pass documents, or you typically work with XML documents too large to fit into memory, youll probably find it easier to use the XmlDocument class to manipulate XML. The XmlDocument implements the World Wide Web Consortium (W3C) DOM model, which exposes methods and properties for working with an in-memory node tree. Its both slower and requires far more memory than the XmlTextReader/XmlTextWriter classes, because it instantiates an object for each node. However, working with an in-memory DOM has huge advantages. Use an XmlDocument when:

You want to maintain an XML document in memory, making repeated queries and modifications to the data or structure of the document. Performance and resource usage are not a factor, because its the easiest to use and the most flexible of the classes in the XML framework.

Consider what happens when you want to find the employee with the ID e10 (the last one in the file). Using an XmlTextReader, you would open the file and read through nodes until you found that particular employee. In contrast, using an XmlDocument object, you would load the document into memory, and then use an XPath query to find that employee, as shown in Listing 8. (XPath is a standard XML query language defined by the W3C.)

Listing 8

Finding a specific <employee> element in an XmlDocument and XPath (Form1.vb)

Private Sub btnDomXPath_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnDomXPath.Click Dim doc As New XmlDocument() Dim empNode As XmlNode doc.Load(empFilename) Dim xns As New XmlNamespaceManager(doc.NameTable) xns.AddNamespace(e, _ http://tempuri.org/employees.xsd) empNode = doc.SelectSingleNode _ (/e:departments/e:department/e:employees/ & _ e:employee[@id=e10], xns) If Not empNode Is Nothing Then Me.txtResult.Text = _ Found employee with id e10. & _ empNode.FirstChild.InnerText & , & _ empNode.FirstChild.NextSibling.InnerText Else Me.txtResult.Text = Could not find any & _ employee with id e10. End If End Sub

Solution 9 A Plethora of XML Choices

125

The code in Listing 8 creates an XmlDocument object, uses its Load method to load the file from disk, and then uses an XPath query to find the node. If thats all it does, then whats that XmlNamespaceManager? And whats a NameTable? Why does the query have all those e: characters in it? It turns out that XPath has no concept of a default namespace, so you have to supply one if the XML document has a namespace. The employees.xml file has a schema that applies to all the elements in the file. Its defined as the default namespacethat is, it has no namespace prefix, such as xmlns:emps. Heres a fragment of the employees.xml file:
<?xml version=1.0 encoding=UTF-8?> <departments xmlns=http://tempuri.org/employees.xsd> <department id=d3 name=IT expanded=False> <employees> <employee id=e50> <lastname>Chen</lastname> <firstname>Kelly</firstname> <address><![CDATA[37118 Second Hill Dr.]]></address> <email>kchen@company.com</email> <phones> <work>(253) 703-7277</work> <home>(253) 703-3168</home> <fax>(253) 703-5633</fax> </phones> <active>True</active> </employee> <!-- more employee elements here --> </employees> </department> <!-- more departments --> </departments>

Because the namespace is the default namespace, you should be able to select nodes using only their local names, not their qualified namesbut it turns out you cant. The qualified name of, for example, an employee element, is http://tempuri.org/employees.xsd:employee. Typing that isnt convenient, but theres no prefix for default namespaces. The answer is to create a prefix. To create a prefix, you create an XmlNamespaceManager, using the XmlDocument .NameTable property as the NameTable argument to the constructor. Next, you call the XmlNamespaceManagers AddNamespace method and add the default namespace from the XML document. The first argument to AddNamespace is a prefix, which can be anything you like. I used e here because its short. The second argument is the URI of

126

General .NET Topics

the schema, which should match the value of the targetNamespace attribute in the schema header.
xns.AddNamespace(e, _ http://tempuri.org/employees.xsd)

Now you can reference an employee node using the prefix and the name, separated by a colon; for example, e:employee. Finally, you can make the SelectSingleNode query, adding the prefix you defined to any element names in the query (thus the e:department/e:employees/... etc. in Listing 8) and passing the XmlNamespaceManager as the second argument. TIP
The documentation for the AddNamespace method says Use String.Empty to add a default namespace. Unfortunately, that doesnt work. Get used to it. If you want to use XPath queries with documents that are associated with one or more namespaces in .NET, you must create an XmlNamespaceManager, add the namespace, and then use qualified names to make your query.

To get back to the discussion about speed and efficiency, if you need to find only one employee, using an XmlTextReader is faster. But when you want to find several employees, loading the XmlDocument into memory and finding the employees using an XPath query should be faster, because you have to parse the document only once. Its worth a short test, though, to find out if thats true, and if so, how much faster the XmlDocument and XPath query are. The sample code contains two functions that loop through the process of finding a specified employee element a specified number of times. One function, timeXmlTextReader, times the operation using an XmlTextReader; the other, timeXmlDocument, times the same operation using an XmlDocument. Click the button titled XmlTextReader vs. XmlDocument to see what happens. Its interesting to experiment with the loop count value. You should expect the XmlTextReader to be faster when you set the loop count to a low number, but as the number of iterations increases, the advantage accrues to the XmlDocument. Thats exactly what happens. In addition, you should expect the timeXmlTextReader methods performance to be better when youre finding an employee at the beginning rather than the end of the file, because it will find the node sooner, and the code stops the read process as soon as the reader locates the specified employee node. Again, thats exactly what happens. The difference between finding the first employee (id=e50) and finding the last employee (id=e10) is dramatic. Note that it takes longer to find the last employee in both versions (using this syntax). NOTE
Because disk and object caching skews the results, its best to perform several runs and get average figures, stopping program execution between runs.

Solution 9 A Plethora of XML Choices

127

Using the XPathDocument Class


An even faster way to find data within XML files is to use the XPathDocument class rather than the XmlDocument class. An XPathDocument provides a read-only implementation of an XML document optimized for XPath queries and XSLT. The optimization effort pays big performance dividends. Use an XPathDocument for situations when you need to find data using XPath queries but dont need to update the document itself. The XPathDocument does not perform validation. The sample code contains a timeXPathDocument method (see the following code snippet) equivalent to the timing methods shown in section Using the XmlDocument Class, but using the XPathDocument class. To perform an XPath query, you create an XPathNavigator object, which knows how to select nodes, retrieve node and attribute values, and move through the node tree. You dont create an XPathNavigator object directly; you use the CreateNavigator method exposed by the XPathDocument class (and by several other classes in the XML namespace, including XmlNodes, XmlDocuments, XmlDataDocuments, and any class that implements the IXmlNavigable interface).
Private Function timeXPathDocument( _ ByVal empId As String, _ ByVal loops As Integer) Dim i As Integer Dim foundCount As Integer Dim xpdoc As New XPathDocument(empFilename) Dim nav As XPathNavigator = xpdoc.CreateNavigator For i = 1 To loops nav.Select( _ departments/department/employees/employee[@id= _ & empId & ]) foundCount += 1 Next Return foundCount End Function

Using an XPathDocument in this manner is about twice as fast as using an XmlDocument when youre looking for the first employee node in the file. However, the time required for the XmlDocument to run the SelectSingleNode statement increases as the node youre searching for is further down in the file. In contrast, the time required for the XPathDocument to execute the Select statement increases very little as the target node moves further down in the tree. But you can improve the speed even further by precompiling the XPath query used to search the document. Lets take a closer look.

128

General .NET Topics

Compiled XPath queries


You can precompile an XPath query to an instance of the XPathExpression class. For example, the following version of the loop uses a compiled query. For this example, simply compiling the query can increase the speed by a factor of 10 or more. Heres a version of the find-employee loop that uses a compiled XPathExpression:
Private Function timeXPathDocumentCompiled( _ ByVal empId As String, ByVal loops As Integer) Dim i As Integer Dim foundCount As Integer Dim xpdoc As New XPathDocument(empFilename) Dim nav As XPathNavigator = xpdoc.CreateNavigator Dim query As XPathExpression = nav.Compile( _ departments/department/employees/employee[@id= _ & empId & ]) For i = 1 To loops nav.Evaluate(query) foundCount += 1 Next Return foundCount End Function

As shown here, you create a compiled XPathExpression by calling the XPathNavigator .Compile method. You can use the XPathExpression by passing it to the XPathNavigator .Evaluate method, which then executes the compiled query. The Evaluate method returns an instance of the XPathNodeIterator class, which is optimized for iterating over a set of XML nodes. The click event handler for the button titled XPathDocument Compiled calls the timeXPathDocumentCompiled method. You may think, from the number on screen, that its not as fast as the XmlDocument, but if you look closely, youll see that this version loops not 1000, but 100000 times.

Using the XPathNodeIterator Class


The combination of the XPathNavigator and XPathNodeIterator is a fundamentally different method of looking at an XML document than youve seen so far. To move through an XPathDocument, you create an XPathNavigator and then use its various Move... methods and Select... methods to create an XPathNodeIterator. You can then use the iterator to iterate through the selected nodes. If you need to look at child nodes, you can create a new navigator by getting the value of the iterators Current property. The following example reads and displays the list of names in the employees.xml in lastname, firstname order:
Private Sub btnXPathNavigator_Click( _ ByVal sender As System.Object, _

Solution 9 A Plethora of XML Choices

129

ByVal e As System.EventArgs) Handles btnXPathNavigator.Click Dim Doc As XPathDocument = New _ XPathDocument(empFilename) Dim Nav As XPathNavigator = Doc.CreateNavigator() Dim ns As New XmlNamespaceManager(Nav.NameTable) ns.AddNamespace(e, http://tempuri.org/employees.xsd) Dim expr As XPathExpression expr = Nav.Compile( _ /e:departments/e:department/ & _ e:employees/e:employee) expr.SetContext(ns) Dim Iterator As XPathNodeIterator = Nav.Select(expr) Dim sb As New StringBuilder(5000) While Iterator.MoveNext() Dim nEmp As XPathNavigator = Iterator.Current() nEmp.MoveToFirstChild() sb.Append(nEmp.Value) nEmp.MoveToNext() sb.Append(, & nEmp.Value & _ System.Environment.NewLine) End While Me.txtResult.Text = sb.ToString End Sub

The XPathNodeIterator (iterator) created by selecting all the employee nodes isnt positioned on any node until you call its MoveNext method. The code then loops through the employee nodes. For each node, it creates a new XPathNavigator object (nEmp) and uses two of the Move... methods to move to the child <lastname> and <firstname> elements. Finally, the XPathNavigator.Value property returns the text value of an element or attribute.

Using the XslTransform Class


You can also use the XPathDocument class when you want to transform XML via XSLT. An XSLT stylesheet uses the W3C XSL language specification to transform some XML input to some form of output. Its common to use XSLT to transform XML to HTML, for example, or to transform one XML document into another, perhaps extracting a subset of the first document, altering document content, or merging documents. Although the syntax of XSLT is a little intimidating at first, creating simple valueextraction stylesheets isnt too difficult. For example, the stylesheet in Listing 9 extracts all the <lastname> and <firstname> elements from the list of employees and returns their values as an unsorted list of strings in lastname, firstname order.

130

General .NET Topics

Listing 9

This XSLT stylesheet displays a list of names extracted from the employees.xml file. (employees.xsl)

<?xml version=1.0 encoding=UTF-8 ?> <xsl:stylesheet version=1.0 xmlns:xsl=http://www.w3.org/1999/XSL/Transform xmlns:emps=http://tempuri.org/employees.xsd> <xsl:output method=text/> <xsl:template match=/> <xsl:apply-templates select=departments/department/employees/employee/> </xsl:template> <xsl:template match=employee> <!-- the following three lines should be on the same line in the XSL file --> <xsl:value-of select=lastname /> <xsl:text>, </xsl:text> <xsl:value-of select=firstname/>&#13; </xsl:template> </xsl:stylesheet>

The first three lines in Listing 9 are required. The fourth line declares the temporary namespace from the employees.xml file so that the style sheet will recognize the elements. The next line tells the XSLT processor to output text (you can also output HTML and XML):
<xsl:output method=text/>

The file contains two <xsl:template> elements. As the processor moves through the XML file, it looks for a template that matches the element being processed. The following template matches the root element of the file:
<xsl:template match=/> <xsl:apply-templates select=departments/department/employees/employee/> </xsl:template>

The <xsl:apply-templates> element causes the processor to process the child nodes of the current node, if any. At the root level, all other nodes in the document are children. The optional select attribute specifies which child nodes the processor should processin this case, the <employee> nodes. The processor selects the employee nodes and, because theres a template that matches them, processes each node. The code that follows is straightforward. The <xsl:value-of>

Solution 9 A Plethora of XML Choices

131

element copies the text value of the specified node to the output tree. The <xsl:text> element causes the processor to emit any text found between the starting and ending tags. The &#xD;&#xA; are the entity references for the carriage-return (ASCII 13) and line-feed (ASCII 10) characters.
<xsl:template match=employee> <!-- the following four lines should be on the same line in the XSL file --> <xsl:value-of select=lastname /> <xsl:text>, </xsl:text> <xsl:value-of select=firstname/> <xsl:text>&#xD;&#xA;</xsl:text> </xsl:template>

Strictly speaking, you dont need the entities in the highlighted line. The XSLT processor inserts carriage returns in the output in exactly the same place they appear in your style sheet (hence the comment in the preceding code). Therefore, another way to output the carriage return is to write
<xsl:text> </xsl:text>

Listing 10 contains another example that creates an HTML file containing a table of sorted names.

Listing 10

A style sheet that outputs a sorted list of names formatted in HTML

<?xml version=1.0 encoding=UTF-8 ?> <xsl:stylesheet version=1.0 xmlns:xsl=http://www.w3.org/1999/XSL/Transform xmlns:emps=http://tempuri.org/employees.xsd> <xsl:output method=html/> <xsl:template match=/> <HTML><HEAD><TITLE>Employee Table Example</TITLE></HEAD> <BODY> <TABLE border=1> <TR> <TD><b>Name</b></TD> <TD><b>Address</b></TD> <TD><b>Work Phone</b></TD> </TR> <xsl:apply-templates select=departments/department/employees/employee> <xsl:sort select= concat(lastname, , , firstname) /> </xsl:apply-templates> </TABLE></BODY></HTML> </xsl:template>

132

General .NET Topics

<xsl:template match=employee> <TR> <TD><xsl:value-of select= concat(lastname, , , firstname) /></TD> <TD><xsl:value-of select=address /></TD> <TD><xsl:value-of select=phones/work /></TD> </TR> </xsl:template> </xsl:stylesheet>

Compare Listing 10 with Listing 9. Both style sheets have the same header information, but Listing 10 explicitly tells the parser to output HTML. Both style sheets contain two templates: one for the root node, and one for employee nodes. This time, the <xsl:applytemplates> command in the root template has a child element:
<xsl:sort select= concat(lastname, , , firstname) />

That command sorts the employee nodes in lastname, firstname order. The style sheet then acts on the sorted list, rather than the document order of the nodes. By default, XSLT processors perform ascending sorts, but you can specify a descending sort, using the optional order attribute, using either ascending or descending values.

Using the XmlDataDocument Class


No discussion of XML in the .NET Framework would be complete without mentioning the XmlDataDocument class. This class is the DOM implementation of a DataSet. In other words, you can look at data as tables of rows and columns, where some tables are related to others, or you can look at the data as a hierarchical set of nodes. Thats easy to see by loading up a new DataSet, assigning it to an XmlDataDocument instance, and then manipulating the XML via the DOM or XPath statements just as youve already seen. Both the DataSet and the XmlDataDocument work with the same data; therefore, if you change the data in a DataSet, you also change the data in the XmlDataDocument. Similarly, if you alter node values in the XmlDataDocument, the values also change in the DataSet, in the corresponding rows and columns. One use for the XmlDataDocument is as an input to XSLT style sheets. The class implements the IXPathNavigable interface, which the XslTransform class accepts as input to several of the overloaded Transform methods. Another use for the XmlDataDocument is to take data that exists only in XML form and then create a DataSet from the XmlDataDocument so you can work with the data in relational mode. You can also use the XmlDataDocument class to read stored DataSets or to work with database data in DOM form. The code in Listing 11 shows you how to create an XmlDataDocument from a DataSet, work with the XML, and the reverse as wellhow to create a DataSet from an XmlDataDocument.

Solution 9 A Plethora of XML Choices

133

Listing 11

The XmlDataDocument class, another way to view a DataSet

Private Sub btnData_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnXmlDataDocument.Click Dim xdd As XmlDataDocument Set your own connection string for the pubs sample database and assign it to SqlConnection1 Dim conn As SqlConnection = Me.SqlConnection1 open the connection conn.Open() Create a new dataset Dim ds As DataSet = New DataSet() give it a name (sets document element name) ds.DataSetName = authors create a command object Dim cmd As SqlCommand = New SqlCommand() cmd.Connection = conn cmd.CommandText = SELECT * FROM Authors create an adapter Dim adapter As SqlDataAdapter = New SqlDataAdapter(cmd) create an authors table in the DataSet with data from the authors table adapter.Fill(ds, author) change the command query cmd.CommandText = SELECT titles.*, & _ titleauthor.au_id FROM TitleAuthor inner & _ join titles on titleauthor.title_id=titles.title_id use a new adapter to run the query adapter = New SqlDataAdapter(cmd) create a titles table in the DataSet adapter.Fill(ds, titles) clean up conn.Close() conn.Dispose() adapter.Dispose() add a parent-child relationship between the authors and titles tables using the au_id column

134

General .NET Topics

ds.Relations.Add(New DataRelation(author_title, _ ds.Tables(author).Columns(au_id), _ ds.Tables(titles).Columns(au_id))) This property controls whether the XML representation displays hierarchically ds.Relations(author_title).Nested = True create a new XmlDataDocument from the DataSet xdd = New XmlDataDocument(ds) You dont need the DataSet any more ds.Dispose() display the Xml from the XmlDataDocument Dim sb As New StringBuilder() xdd.Save(New StringWriter(sb)) Me.txtResult.Text = sb.ToString Use an XPath statement to find an author with a last name of Carson sb = New StringBuilder() use an XmlTextWriter to write the results in indented form Dim xw As XmlTextWriter = New XmlTextWriter( _ New StringWriter(sb)) xw.Formatting = Formatting.Indented xw.Indentation = 3 xw.IndentChar = c Dim N As XmlNode = xdd.SelectSingleNode( _ authors/author[contains(au_lname,Carson)]) N.WriteTo(xw) display the found author MessageBox.Show(sb.ToString, Found Author Carson) sb = New StringBuilder() sb.Append(Author Data in rows and columns & _ System.Environment.NewLine) get a new DataSet from the XmlDataDocument Dim ds2 As DataSet = xdd.DataSet() Dim aTable As DataTable Dim aRow As DataRow Dim aCol As DataColumn aTable = ds2.Tables(0) authors table For Each aRow In aTable.Rows

Solution 10 Where Should I Store That Data?

135

For Each aCol In aTable.Columns sb.Append(aRow(aCol).ToString & Chr(9)) Next sb.Append(System.Environment.NewLine) Next Me.txtResult.AppendText( _ System.Environment.NewLine & _ System.Environment.NewLine & sb.ToString) ds2.Dispose() End Sub

To sum up, the .NET Framework has few limitations for working with XML; instead, you have a plethora of choices. Its highly likely that one of the available choices will work for you. Youre not limited to any particular method for creating, parsing, altering, searching, or transforming XML. Usually, the correct choice is clear, but when several different classes or methods might do the job, experiment with them to see which is most efficient. When the built-in classes dont do exactly what you want, you can inherit from them and write custom classes. As youve seen, the choices you make can affect the efficiency of your code. Try to match the classes you use to the specific XML task youre attempting to accomplish.

SOLUTION

10
SOLUTION

Where Should I Store That Data?


PROBLEM

Ive seen code to store application initialization and personalization data in INI files, the Registry, configuration files, .NETs Isolated Storage, and database tables. All these options work, but the problem is, which should I use to store my user and application data?

The answer, as usual, is: it depends. It depends a little on what you want to store, a little on whether you need write access or just read access to the data, and a little on what you think the future of Windows computing (and computing in general) holds. Nonetheless, the forwardlooking choices are clear. Store your data in XML, and youll be able to access and modify it regardless of which platform or programming language youre using.

136

General .NET Topics

Applications need setup and configuration values; users need application customization values and custom data. What should you choose for your applications? Should you stick with the familiar, tried-and-true options, or experiment with some of the newer solutions? Does it make any difference which type you choose? These arent easy questions to answer. There are good reasons for sticking with older methods when migrating existing applications, but those reasons may not hold up when viewed from the perspective of modern application development trends. In this solution, Ill illustrate the various options you have for storing application configuration and user-specific data in a Windows Forms application. You can explore the advantages and disadvantages of each firsthand to help you decide which option is most appropriate for your particular application.

Available Storage Options


The problem of storing application configuration and user-specific data isnt new; programmers have struggled with it since the earliest applications. What is relatively new, though, is the range of choices that are available. Today, you have these options:

INI (initialization) files Windows Registry Custom file formats XML files Isolated Storage Web services Databases Application configuration files

You can use any of these options to store almost any type of data. For example, youre perfectly free to create customized binary file formats in .NET, either by writing the raw bytes yourself or by using serialization. You can store data in custom text file formats or in INI files, or you can create XML documents and store data and XML-serialized objects in them. You can read and write to the Registry, to Isolated Storage, and to database tables (the latter option is not discussed in any depth in this solution). You can create Web services that both accept and return data, providing developers with a simple method call that functions as a remote data-storage and -retrieval device.

Solution 10 Where Should I Store That Data?

137

The Sample Application


The sample application for this solution is a small Windows Forms application that stores a string to (or retrieves a string from):

An INI file The Windows Registry A custom file format (binary file) An XML file Isolated Storage A database table (requires SQL Server) A Web service

In addition, the application can read (but not write) data from its application configuration (.config) file, because the .NET Frameworks built-in configuration file methods treat application configuration files as if they were read-only. Ill show you how to read from and write to each type of data store using these methods, and discuss the advantages and disadvantages of each.

Using INI Files


Despite Microsofts attempts to insist that INI files are obsolete ever since Windows 95 first appeared, INI files are still ubiquitous in Windows and are even used heavily by some Microsoft applications (such as Visual Studio and Windows itself). INI files are lightweight, fast, and eminently suitable for storing string representations of shorter-length data. Windows has a set of API calls that you can use to create, read, and write INI files. Although you can define and use the API directly, I think its more convenient to write a wrapper class that gives you the same functionality. The IniWrapper.vb file in the sample code (available from the Sybex Web site at www.sybex.com) exposes an IniWrapper class that contains methods that wrap the most common Windows API INI calls. NOTE
See Solution 3 for more information about the INI file format and the IniWrapper class.

If you ever used API calls to read and write data to INI files, youll find this class easy to use. You can test the class using the sample form shown in Figure 1. The two sets of buttons on the form let you save or retrieve data you enter into the text box. Listing 1 contains the code that runs when you click the (Store to) INI File button on the form.

138

General .NET Topics

Listing 1

The Save INI File button code

Private Sub btnINISave_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnINISave.Click Dim s As String = Me.txtData.Text If s.Length > 0 Then Dim ini As New INIWrapper ini.WriteINIValue(Application.StartupPath _ & \solution10.ini, data, item, s) End If End Sub

The code in Listing 1 saves any data you enter into the text box in a file named solution10.ini in the same folder where the application started (usually the bin folder). The file has one section, [data], and one key, called item, associated with the data value. If the file doesnt exist, the INIWrapper automatically creates it. The (Retrieve from) INI File button code shown in Listing 2 retrieves the data from the INI file. FIGURE 1:
The sample application form

Solution 10 Where Should I Store That Data?

139

Listing 2

Retrieve INI File button code

Private Sub btnINIRetrieve_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnINIRetrieve.Click Dim ini As New INIWrapper Dim s As String = ini.GetINIValue _ (Application.StartupPath & \solution10.ini, _ data, item) If s.Length = 0 Then Me.txtData.Text = No data stored. Else Me.txtData.Text = s End If End Sub

Youll find that these two listings are simpler than the rest of the methods, because the API calls to read and write INI files dont throw an error if the specified file, section, or key doesnt exist. In that case, when youre writing data the API calls create the file, section, or key; when youre reading data, the API calls return an empty string. INI files have a few problems. First, theyre subject to tampering by users. The data (unless you encrypt it) is visible to anyone who can open the file with a text editor. Even when the information is encrypted, its possible for someone to delete or modify an INI file, so you shouldnt use these files to store critical information. The values have length restrictions, so INI files arent suitable for long data values.

Using the Windows Registry


Microsoft intended the Windows Registry to be a faster, more robust replacement for INI files. The Registry is a hierarchical, proprietary-format storage file. The file is critical. Windows wont function without it, which is good in one wayyou know the Registry will be available. However, knowing that a Registry file is available doesnt ensure that the values your application needs are available in that particular file. Registry access is fairly fast, especially if you need to read values multiple times, because Windows caches values in memory. Classic VB provided easy, built-in Registry access via the GetSetting, SaveSetting, and DeleteSetting functions. However, those functions could read, write, and delete only values stored under a specific key in the Registry. To solve the problem, numerous commercial and free wrapper classes used the underlying Windows

140

General .NET Topics

API Registry functions to give VB programmers full access to the Registry. Although the Microsoft.VisualBasic namespace contains the restricted registry functions that are compatible with classic VB, VB.NET provides full registry access by default, through the .NET Frameworks Registry class. The Registry methods are easy to use. The Registry uses the model of a hierarchical set of keys. You first create a Microsoft.Win32.RegistryKey object associated with a specific key, and then you can read, store, and delete values associated with that key. For example, Listing 3 reads or writes the value associated with the key HKEY_CURRENT_USER/Software/10MinSolutionsBook1/Solution10.

Listing 3

Code for reading and writing a Registry value

Private Sub btnRegistrySave_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnRegistrySave.Click Dim s As String = Me.txtData.Text Dim key As Microsoft.Win32.RegistryKey If s.Length > 0 Then Try key = Microsoft.Win32.Registry. _ CurrentUser.CreateSubKey _ (SOFTWARE\10MinuteSolutionsBook1\Solution10) key.SetValue(data, s) key.Close() Catch ex As Exception MessageBox.Show(ex.Message) End Try End If End Sub Private Sub btnRegistryRetrieve_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnRegistryRetrieve.Click Dim key As Microsoft.Win32.RegistryKey Try key = Microsoft.Win32.Registry. _ CurrentUser.OpenSubKey _ (SOFTWARE\10MinuteSolutionsBook1\Solution10, _ True) If key Is Nothing Then Me.txtData.Text = No data stored. Else Me.txtData.Text = key.GetValue(data, _ No data stored.)

Solution 10 Where Should I Store That Data?

141

key.Close() End If Catch ex As Exception MessageBox.Show(ex.Message) If Not key Is Nothing Then Try key.Close() Catch ex1 As Exception ignore End Try End If End Try End Sub

The first method in Listing 3 saves the value the user entered in the txtData text box to the Registry key HKEY_CURRENT_USER\SOFTWARE\10MinuteSolutionsBook1\Solution10. The second method retrieves the value from the Registry and places it into the text box. You arent limited to storing text values in the Registry; you can read and write numeric values and binary data as well. The values youre trying to read dont even have to be on the same machine. The OpenRemoteBaseKey method opens a base key (such as HKEY_CURRENT_ USER) on a specified machine. Administrators can associate access permissions with Registry keys, permitting or restricting key access by specific user or group accounts.

Using Custom Files


Using custom files is the oldest method of storing application data. While still in wide use, custom file formats have fallen out of favor lately, because theres no standard way to describe the layout and contents of a custom file. Many applications, such as Microsoft Office, are changing to use XML as a native file format instead, which increases interoperability and promotes data reuse. Nonetheless, some good reasons exist for using a custom file format, particularly when:

You know your application is the only one that needs the data. You need to store the data in a particular way. You dont want people to be able to see or retrieve the data using a text editor. You want the most compact or fastest possible access to the stored data.

Writing a custom file is easy: you decide how you want to store the data, and then open a file and write the data accordingly. The example in Listing 4 stores and retrieves the string value a user enters into the txtData text box in Unicode encoding in a file named solution10.bin located in the applications startup folder (usually the bin folder). The code first converts the string to a byte array. It writes the number of bytes in the array to the file, and then steps through the byte array backwards, writing the value of each byte xored with the ASCII value of the character z to the file.

142

General .NET Topics

Listing 4

Writing a custom file format

Private Sub btnCustomSave_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnCustomSave.Click Dim aFilename As String = _ Application.StartupPath & \solution10.bin Dim s As String = Me.txtData.Text If Len(s) > 0 Then if the file already exists, delete it If File.Exists(aFilename) Then File.Delete(aFilename) End If open the binary file for writing Dim bw As BinaryWriter = New BinaryWriter _ (New FileStream(aFilename, FileMode.CreateNew)) Dim b() As Byte = _ System.Text.Encoding.Unicode.GetBytes(s) write the number of bytes in the data bw.Write(b.Length) write the data bytes backward For i As Integer = b.Length - 1 To 0 Step -1 Dim aByte As Byte = b(i) Xor Asc(zc) bw.Write(aByte) Next bw.Close() End If End Sub

To retrieve the data, the code follows the reverse process (see Listing 5). First, it reads the integer value that specifies the number of bytes in the data, and then it reads that number of bytes, reverses them, loops through the reversed byte array (xoring the values with the ASCII value of z again to restore the original values), and then converts the result to a string, which it displays in the txtData text box.

Listing 5

Reading a custom file format

Private Sub btnCustomRetrieve_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnCustomRetrieve.Click

Solution 10 Where Should I Store That Data?

143

Dim aFilename As String = _ Application.StartupPath & \solution10.bin open the binary file for reading If File.Exists(aFilename) Then Dim br As BinaryReader = New BinaryReader _ (New FileStream(aFilename, FileMode.Open)) first byte contains the number of bytes Dim dataLength As Integer = br.ReadInt32 Dim b() As Byte = br.ReadBytes(dataLength) close the file br.Close() Array.Reverse(b) Dim fixByte(b.Length) As Byte For i As Integer = 0 To b.Length - 1 fixByte(i) = b(i) Xor Asc(zc) Next Dim s As String = System.Text.Encoding. _ Unicode.GetString(fixByte) Me.txtData.Text = s Else Me.txtData.Text = No data available. End If End Sub

While such a simple encryption scheme certainly wont deter a determined hacker, it will prevent most people from reading the data. If you look at the contents of the binary file in Notepad, youll see something relatively meaningless, like this:
zzz-zZzzzz0

Using XML Files


One of the best formats ever devised for storing data is XML. The reason has absolutely nothing to do with XML itself; its simply that the world, for once, came to an agreement about a common file format and a way to describe the contents of such a file. XML happens to be a text-based format, which means you can read it (a process typically called parsing) with any programming language that can open a file and read characters. In practice, XML data is readable with every modern programming language. Having a common file format provides huge advantages, because programmers can use common code to access data in any XML file. The .NET Framework contains an entire System.XML namespace. (See Solution 9 for more information about how to use XML in .NET.) In this solution, you use XML only to store the user-entered string. To do that, you construct an XML file using an XMLTextWriter

144

General .NET Topics

instance. Assuming the user entered the string XML Data into the text box, the resulting file would look like this:
<?xml version=1.0 encoding=Windows-1252 standalone=yes?> <TenMinSolution> <data>XML Data</data> </TenMinSolution>

To test it, enter a string into the text box and click the (Store to) XML File button. Listing 6 contains the code that runs when you click the button.

Listing 6

Saving XML with an XMLTextWriter

Private Sub btnXMLSave_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnXMLSave.Click Dim aFilename As String = _ Application.StartupPath & \solution10.xml Dim s As String = Me.txtData.Text If s.Length > 0 Then if the XML file exists, delete it If File.Exists(aFilename) Then File.Delete(aFilename) End If Dim xtw As New XmlTextWriter( _ New FileStream(aFilename, _ FileMode.CreateNew), _ System.Text.Encoding.Default) xtw.WriteStartDocument(True) xtw.WriteStartElement(TenMinSolution) xtw.WriteElementString(data, s) xtw.WriteEndDocument() xtw.Close() End If End Sub

Although you can write XML by simply outputting text, its easy to make a mistake and forget a quotation mark or a closing tag. Youre generally better off using the XmlTextWriter class, which provides nearly foolproof methods for writing valid XML. You create an XmlTextWriter by passing its constructor a Stream object. Listing 6 uses a FileStream so that the XmlTextWriter writes directly to disk. Reading XmlTextWriter code is usually straightforward. The WriteStartDocument method writes the standard XML header. The WriteStartElement method writes the opening tag for

Solution 10 Where Should I Store That Data?

145

an element. You provide the element name. The WriteElementString method writes the opening and closing tags plus the string data between them. Finally, the WriteEndDocument closes all the open tags, ending the document. To retrieve the data, use an XmlTextReader instance. The Click event handler for the Retrieve XML File button shown in Listing 7 provides an example.

Listing 7

Reading XML with an XmlTextReader

Private Sub btnXMLRetrieve_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnXMLRetrieve.Click Dim aFilename As String = _ Application.StartupPath & \solution10.xml If File.Exists(aFilename) Then Dim success As Boolean = False Dim xtr As New XmlTextReader( _ New StreamReader(New FileStream( _ aFilename, FileMode.Open))) While xtr.Read If xtr.NodeType = XmlNodeType.Element _ AndAlso xtr.LocalName = data Then Dim s As String = xtr.ReadString Me.txtData.Text = s xtr.Close() success = True End If End While If Not success Then Me.txtData.Text = No data available End If End If End Sub

Basically, the code creates an XmlTextReader and uses its Read method to read nodes from the XML file. After each read, the If block tests first to see whether the node read is an element (XmlNodeType.Element), and second to see if the elements local name (the name unencumbered with a namespace URI) is data. If so, it reads the text value of the <data> element and displays it in the text field. The code for reading and writing XML is considerably more complex than the code for reading and writing INI files; however, if you think youll ever need portability, or want to read the data with some other application, you may find that the extra work is well worthwhile.

146

General .NET Topics

Using Isolated Storage


Isolated Storage is a relatively new concept in Windows. The basic idea is that applications should have some place to store data so that the programmer doesnt need to know where that place is in advance. To a developer, Isolated Storage acts as the standard file system, except that you dont have to know its locationthe store might be local, but it might also be on a network drive. The Isolated Storage system stores data by assembly and by user, and optionally, by domain. The system uses evidence, such as the domain identity (the path to the application), the assembly strong name, the URL or the publishers public key, and the logged-on user, to create a unique storage area so that application programmers dont have to worry about overwriting data for one user or application with data from another user or application on the same machine. Administrators can configure the amount of space reserved for Isolated Storage. On Windows XP Pro, by default, the Isolated Storage area for a logged-on user resides in the Documents and Settings folder for that user:
C:\Documents and Settings\<username>\Local Settings\Application Data\IsolatedStorage\ The directory names themselves are random strings, such as agb1xp3f.njf\mcbirg70.4ef.

To store data in Isolated Storage, you use the IsolatedStorageFile classs static GetStore method. The method accepts an IsolatedStorageScope enumeration value. The IsolatedStorageScope values describe whether the store used should be specific to an assembly, an application domain, a certain user, or a users roaming profile. You can combine the values using a Boolean Or operation. The sample form contains an example. Clicking the (Save to) Isolated Storage button runs the Click event handler code shown in Listing 8.

Listing 8

Storing data in Isolated Storage

Private Sub btnIsolatedStorageSave_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnIsolatedStorageSave.Click get the data to write Dim s As String = Me.txtData.Text create a file name Dim isoFilename As String = TenMinuteSolutions.data If Len(s) > 0 Then Retrieve an IsolatedStorageFile for

Solution 10 Where Should I Store That Data?

147

the current Domain and Assembly. Dim isoFile As IsolatedStorageFile = _ IsolatedStorageFile.GetStore( _ IsolatedStorageScope.User _ Or IsolatedStorageScope.Assembly _ Or IsolatedStorageScope.Domain, _ Nothing, Nothing) create an isolated storage stream Dim isoStream As New IsolatedStorageFileStream( _ isoFilename, FileMode.Create, _ FileAccess.ReadWrite, FileShare.None) create a StreamWriter to write the data Dim writer As New StreamWriter(isoStream) writer.Write(s) clean up writer.Close() isoFile.Close() End If End Sub

The GetStore method returns an IsolatedStorageFile instance scoped to the logged-on user, the executing assembly, and the current domain. The two Nothing parameters at the end of that call are domainIdentity evidence and assemblyIdentity evidence, which can provide additional security evidence about the callers identity. Neither is required, so you can pass Nothing for either or both parameters if you dont have the evidence. You can then create an IsolatedStorageFileStream and access a file in the store in essentially the same way as you would obtain a stream and access any file in the standard file system. The code in Listing 8 writes the text data entered by the user in the txtData TextBox to a file named TenMinuteSolutions.data. Retrieving the data is similar. First, you use GetStore to obtain an IsolatedStorageFile instance, and then you create an IsolatedStorageFile instance to read the file. Listing 9 shows an example.

Listing 9

Reading an Isolated Storage file

Private Sub btnIsolatedStorageRetrieve_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnIsolatedStorageRetrieve.Click Dim isoFilename As String = TenMinuteSolutions.data Try

148

General .NET Topics

Dim isoFile As IsolatedStorageFile = _ IsolatedStorageFile.GetStore( _ IsolatedStorageScope.User _ Or IsolatedStorageScope.Assembly _ Or IsolatedStorageScope.Domain, Nothing, Nothing) create an isolated storage stream Dim isoStream As New IsolatedStorageFileStream( _ isoFilename, FileMode.Open, _ FileAccess.Read, FileShare.None) create a StreamReader to read the file data Dim reader As New StreamReader(isoStream) Dim s As String = reader.ReadToEnd clean up reader.Close() isoFile.Close() display the data Me.txtData.Text = s Catch fnf As FileNotFoundException no such file Me.txtData.Text = No data available. Catch ex As Exception any other error MessageBox.Show(ex.Message) End Try End Sub

Using a Web Service


A lot of people still think of Web services as something to be used only in Web applications, but theyre not. Instead, Web services are simply a standard protocol for exchanging data. As such, theyre perfect for storing and retrieving all types of data, because the programmer doesnt have to know anything about where or how the data is stored. The Web service hides that information and presents the developer with a standardized method for interacting with the data. Of course, you can expose a Web service via a Web interface, but you can use that same Web service to provide data to desktop applications. This method has the advantage of being available from anywhere. For example, you might use a Web service to store user preferences and data for a specific application. As long as users can connect to the Web service, their preferences and data are available from any machine anywhere. Thats a huge advantage in certain situations, particularly for mobile workers, who may need to access the same application from several machines.

Solution 10 Where Should I Store That Data?

149

The sample code for this solution has a separate project containing a simple Web service called TenMinuteSolutions. That Web service exposes a ReadWriteData object that knows how to read and write the user-entered data for the application. Although the sample reads and writes data to the Windows Registry, it could store the data using any other method discussed in this solution: in an INI file, a custom binary file, a database, or Isolated Storage. The Web service itself is simple. Listing 10 shows the code for the ReadWriteData class.

Listing 10

The ReadWriteData class

Public Class ReadWriteData Inherits System.Web.Services.WebService #Region Web Services Designer Generated Code generated code omitted <WebMethod()> _ Public Function GetData() As String Dim s As String Dim key As Microsoft.Win32.RegistryKey Try key = Microsoft.Win32.Registry.CurrentUser. _ OpenSubKey(SOFTWARE\ & _ 10MinuteSolutionsBook1\ & _ Solution10Web, True) If key Is Nothing Then s = No data stored. Else s = key.GetValue(data, No data stored.) key.Close() End If Catch ex As Exception Throw ex Finally If Not key Is Nothing Then key.Close() End If End Try Return s End Function <WebMethod()> _ Public Function SetData(ByVal s As String) As String Dim key As Microsoft.Win32.RegistryKey If s.Length > 0 Then Try key = Microsoft.Win32.Registry.CurrentUser. _ CreateSubKey(SOFTWARE\ & _ 10MinuteSolutionsBook1\Solution10Web)

150

General .NET Topics

key.SetValue(data, s) key.Close() Catch ex As Exception Throw ex Finally If Not key Is Nothing Then key.Close() End If End Try End If End Function End Class

The class has two public methods: GetData and SetData. The SetData method stores user data and the GetData method retrieves it. The code itself is essentially identical to that already discussed in the section Using the Windows Registry, so I wont explain it again. The important point is that all the developer has to do is create a reference to the Web service and call the methods. Therefore, the code that stores and retrieves the data from the main project is even simpler than that used for the INI file method. The Save Web Service button Click event fires the code in Listing 11, which stores the user-entered data.

Listing 11

Saving data via a Web service

Private Sub btnWebServiceSave_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnWebServiceSave.Click Dim s As String = Me.txtData.Text Dim rwd As New localhost.ReadWriteData If Len(s) > 0 Then rwd.SetData(s) End If End Sub

Retrieving the data is just as simple. The code in Listing 12 runs when you click the Retrieve Web Service button.

Listing 12

Retrieving data via a Web service

Private Sub btnWebServiceRetrieve_Click( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles btnWebServiceRetrieve.Click Dim s As String Dim rwd As New localhost.ReadWriteData

Solution 10 Where Should I Store That Data?

151

s = rwd.GetData() Me.txtData.Text = s End Sub

Although the sample is customized to store only one string, you can easily write a Web service that stores data based on a key, a username, an assembly, an application, or any combination you wish. Although the Web service method is much slower than storing data using local files or the local Registry, the potential benefits are great. You can centralize data storage for applications while simultaneously making it accessible from any machine or location and simplifying the codeand you can do all that without users or developers having to know where the data resides or how its stored. Thats hard to beat.

Using Application Configuration Files


.NET applications have a new method for storing application configuration settings: configuration files. Each Windows Forms application may have an XML-formatted configuration file. The configuration file should have the same name as the application except that it should end with .config, and it should reside in the same folder as the applications startup executable file. For example, if the main executable is myapp.exe, the configuration file must be myapp.exe.config. The Framework includes methods for reading configuration file data. When a properly named and placed configuration file exists, the Framework finds and reads it automatically when you call one of the methods. The Framework caches the data, so accesses after the first are fast. You can store any type of information in a configuration file, but the files are primarily intended for storing application-wide settings, not user data. Theres a drawback, though: as shipped, the Framework includes methods for reading values only from a configuration file. The Framework has no provision for modifying the files by writing to them at runtime; it treats the files as if they were read-only (even though theyre not). That limitation severely restricts the usefulness of configuration files and makes them essentially unsuitable for storing any application-generated data. Theyre obviously intended for use primarily by application developers and administrators to store data values known before the application starts. You add custom data to a configuration file by adding an <appSettings> tag and placing <add> tags as its children. Each <add> tag should contain a key attribute and a value attribute. Give each data value its own <add> tag with a unique (for this application) key. With Visual Studio, you can create an application configuration file by adding a new item to your project and selecting the application configuration file type. That adds a generically named App.config file to the project. For example, the sample project contains an App.config file that has a single custom application data value in it. Listing 13 shows the complete file.

152

General .NET Topics

Listing 13

An App.config file

<?xml version=1.0 encoding=utf-8 ?> <configuration> <appSettings> <add key=data value=10 Minute Solutions Book, Solution 10 /> </appSettings> </configuration>

When you compile the application in Visual Studio, it creates a copy of the App.config file with the correct name in the bin folder. For the sample application, named Solution10, Visual Studio creates the file Solution10.exe.config in the bin folder. If youre not using Visual Studio, youll have to create and save the configuration file yourself in the same folder where you compile the EXE file. Reading values from the file is straightforward. The System.Configuration namespace contains a ConfigurationSettings class. This class has a shared AppSettings property that returns a collection of custom settings (as read from the configuration file). If you need only read access to custom values known at design time or at install time, this is by far the simplest and most convenient method. The Read From Config File button Click event handler contains the code in Listing 14.

Listing 14

Reading an application configuration value

Private Sub btnConfig_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnConfig.Click Dim s As String = Configuration. _ ConfigurationSettings.AppSettings(data) Me.txtData.Text = s End Sub

Listing 14 is just as simple as the Web service code, but remember that it relies on a local file thats subject to tampering.

Guidelines: Which Method Is Best?


Unfortunately, there is no one best answer as to which method is best for you, but here are some guidelines: INI files If youre upgrading an older application that already uses INI files, consider sticking with that method to minimize the number of code changes.

Solution 10 Where Should I Store That Data?

153

Application configuration files If youre writing a new application that needs only local read access to simple text values, use an application configuration file. XML files If youre writing a new application that needs both read and write access to data, consider using a custom XML file. Registry The registry gives you fast-cached access to both string and binary data. Before selecting this method, you should be aware that for practical purposes its limited to a single machine; that its much more suitable for short, simple scalar values than for longer, more complex values; and that editing the Registry manually is both difficult and dangerous. One advantage of using the Registry is that you can set permissions for the keys and values, which can help ensure that unauthorized users cant access inappropriate values. Custom file formats If youre writing a new application that needs the fastest possible access to data, consider using the Registry (for simpler, shorter values) or custom binary files (for more complex, longer values). If you select this option, you should also consider writing and exposing code to export the data to XML so that you can access it from other applications. Isolated Storage If you need to store application data for individuals, consider using Isolated Storage. This option is slower but particularly effective when users have roaming profiles (the Isolated Storage location is on a network server). The Isolated Storage files themselves can be any format you wish, such as XML or custom binary files. Web services If you need to store application data and access it from any machine or location (both on your local network and via the Internet), consider creating a Web service to serve the data. Database tables No matter what type of application you have, storing the data in a database simplifies maintenance and data analysis. For example, if you have user preference data in database tables, its easy to back up the tables. In contrast, ensuring that people back up their local machines is not easy. You can also query the database to provide a breakdown about the features and settings people prefer. You can replace INI files, custom binary files, XML files, and the Registry with a database solution. You can also combine a Web service and a database solution, serving the data from the Web service as a custom object, as individual data values, or as an XML document.

154

General .NET Topics

SOLUTION

11
SOLUTION

Performing the Most-Requested Conversions in .NET


PROBLEM

Strongly typed programming systems such as .NET help reduce errors but they can also be extremely frustrating when I want to change one data type into another. Simple casting doesnt always help.

The .NET Framework supports many types of conversions and conversion helper classes. Often, you just have to know where to look.

Computers are stupidthey cant tell a number from a text character unless you help them. In the early days of computers, programmers understood that they were responsible for deciding how to make a computer look at the bytes. Today, with strongly typed systems such as .NET, the system enforces the byte interpretation; therefore, if you want to change the interpretation, you have to change the type. Fortunately, the .NET Framework supports many type conversions nativelyalthough theyre not always easy to find. This solution shows you how to perform the most-requested types of conversions.

Using the Convert Class


The Convert class, found in the System namespace (so you dont need an Imports statement to use it), handles many of the most common single-value conversions, such as Integer to Long. You dont have to create an instance of this class to use itall the methods are shared. Youll find Convert methods for almost every base type conversion; in fact, the documentation states that theres a conversion for every base type to every other base type. Thats true, but some conversions simply throw an InvalidCastException. Specifically, the documentation states that you cant convert from Char to Boolean, Single, Double, Decimal, or DateTime, or from those types to Char. In addition, you can only convert the DateTime type to String, and you can only convert to a DateTime from a string. For example, to convert an Int32 value to an Int64 (an expanding conversion), use the
Convert.ToInt64 method:
Dim I as integer = 1000 Dim i64 as Int64 = Convert.ToInt64(I)

You can perform contracting, or lossy, conversions as well. For example, if you convert a Double (2.1582093) to an Integer, youll truncate it to the value 2.

Solution 11 Performing the Most-Requested Conversions in .NET

155

You can also use the Convert class to convert from a DateTime to a String, or vice versa:
Dim myDateTime as DateTime = _ DateTime.Parse(02/03/2003 04:11:13 AM) Dim s as String = Convert.ToString(myDateTime)

Note that the preceding code also uses the Parse method, which is commonly available for structures and value types, to create the initial DateTime from a string. The Convert.ToString method shown converts the specified DateTime to a string value. If you cant find or remember the specific Convert method you need, the Convert.ChangeType method works for all the valid conversions. For example, you can change a Char to an Integer using this code:
create a character Dim c As Char = cc change it to an Integer value Convert.ChangeType(c, GetType(Integer)).ToString) the result is 99, the character code for c

Note that the ChangeType method returns an Object, not a type, as the more specific conversion methods do. To assign the results to an instance of the specified type, you must perform a cast. Click the Convert Class Conversions button on the sample form to test some of the available conversions. Listing 1 shows the code.

Listing 1

Convert class conversion samples

Private Sub btnConvertClass_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnConvertClass.Click Dim Dim Dim Dim Dim Dim Dim i As Integer = 100 s As String = Every Good Boy Does Fine L As Long = 1000000 D As Double = 1.1413 si As Single = 1.1413 sb As New StringBuilderEx c As Char = cc

sb.AppendLine(Integer to string: & _ Convert.ToString(i)) sb.AppendLine(Or just use ToString(): & i.ToString) sb.AppendLine(Integer to double: & _ Convert.ToDouble(i)) sb.AppendLine(Double to string: & Convert.ToString(D)) sb.AppendLine(Or just use ToString(): & D.ToString)

156

General .NET Topics

sb.AppendLine(Long to string: & Convert.ToString(L)) sb.AppendLine(Or just use ToString(): & L.ToString) sb.AppendLine(Lossy--Long to Integer: & _ Convert.ToInt32(L)) sb.AppendLine(Truncate using conversions: & _ Convert.ToInt32(D)) the following line doesnt compile, because ChangeType returns an Object, not an instance of the requested type. Dim anInt As Integer = Convert.ChangeType _ (c, GetType(Integer))

this compiles Dim anInt As Integer = CType(Convert.ChangeType _ (c, GetType(Integer)), Integer) display the results Me.txtResults.Text = sb.ToString End Sub

Figure 1 shows the results after we clicked the Convert Class Conversions button on the sample form. FIGURE 1:
Several class conversion results

Solution 11 Performing the Most-Requested Conversions in .NET

157

Byte-to-Char/Char-to-Byte
A character, in the old extended ASCII days, used to be nothing but a byte. You could choose the character set for the computer to use. Each character set contained 256 characters. The default Latin character used the lower 128 bytes for control characters, numeric characters, and the alphabet (both upper- and lowercase), and used the upper 128 bytes for extended characters, such as lines, small icons, and accented characters. For example, a lowercase a is character 97 in decimal (61 in hexadecimal). While the 256-character limitation was sufficient for many purposes, it proved to be far too small to support the number of characters needed to display type in any language, so the International Standards Organization (ISO) created Unicode, which provides 2 bytes of information for each character, raising the number of possible characters from 256 to 65,535. Even though the .NET Framework treats numbers and characters as completely different types, you can still convert between them. For example, you can create a character of any value you like. To do that, use one of the shared Convert class methodsin this case, ToChar, passing an integer whose value is the number value of the character you want. Starting with a, the method creates and appends all 26 characters to a StringBuilder and displays the results (see Listing 2). The results are shown in Figure 2. FIGURE 2:
The Char From Number button code creates characters from numeric values.

158

General .NET Topics

Listing 2

Creating characters from numeric values

Private Sub btnCharFromNumber_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnCharFromNumber.Click Print the lower-case alphabet. Dim sb As New StringBuilder(26) Dim i As Integer For i = 97 To 97 + 25 sb.Append(Convert.ToChar(i)) Next display the results Me.txtResults.Text = sb.ToString End Sub

A similar bit of code lets you convert a character to its equivalent Unicode integer value (see Listing 3).

Listing 3

Converting characters to their Unicode integer values

Private Sub btnNumberFromChar_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnNumberFromChar.Click Given a string, display the character codes for each character in the string create a StringBuilder to hold results Dim sb As New StringBuilder(1000) create a string Dim s As String = Every Good Boy Does Fine for each character in the string For i As Integer = 0 To s.Length - 1 print either (space) or the character itself Dim printChar As String If s.Chars(i) = Then printChar = (space) Else printChar = s.Chars(i) End If and its integer character value

Solution 11 Performing the Most-Requested Conversions in .NET

159

sb.Append(printChar & = & _ Convert.ToInt32(s.Chars(i)).ToString & _ System.Environment.NewLine) Next Me.txtResults.Text = sb.ToString End Sub

Note that the preceding examples show two more types of conversions. For value types, the ToString method returns a string representation of the value. In many cases, thats the only conversion you need to change a value type into a string. The method doesnt usually provide such easy results for reference types; however, for some common reference types, such as the StringBuilder class, its the preferred way to access the collected string data.

Strings to Char Arrays


Strings, at their core, are an array of characters. You have direct access to any given character in the array, as shown in Listing 3, which uses the String classs Chars method to obtain a character at a specified index position. But sometimes, you want to obtain a true array of type Char consisting of the characters in a string. In that case, use the String.ToCharArray method. Listing 4 shows the code.

Listing 4

Changing a string to an array of type Char

Private Sub btnStringToCharArray_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnStringToCharArray.Click create a StringBuilder to hold results Dim sb As New StringBuilder(1000) create a string Dim s As String = Every Good Boy Does Fine get the characters in the string as an array of type Char Dim chars() As Char = s.ToCharArray() display each Char For Each c As Char In chars sb.Append(c) Next display the results Me.txtResults.Text = sb.ToString End Sub

160

General .NET Topics

Strings to Byte Arrays


Its a little less intuitive to convert strings to byte arrays, but youll need to do so if you try to use the FileStream.Write method, which requires an array of bytes. One consideration is that the characters in a string are Unicode. Because those consist of 2 bytes, only the lowest 256 Unicode characters will convert properly to single-byte form. Unlike the String.ToCharArray you saw in the preceding section, theres no built-in String method to make the conversionand you wont find one in the Convert classs methods either. Instead, use the System.Text.Encoding namespace to convert strings to byte arrays. Youll have to select the type of encoding you want. For example, Listing 5 converts a string first to a byte array of ASCII values and then to a byte array of Unicode values.

Listing 5

Converting a string to a byte array

Private Sub btnStringToByteArray_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnStringToByteArray.Click create a StringBuilder to hold results Dim sb As New StringBuilder(1000) create a string Dim s As String = Every Good Boy Does Fine Dim bytes() As Byte = _ System.Text.Encoding.ASCII.GetBytes(s) For Each b As Byte In bytes sb.Append(b.ToString & System.Environment.NewLine) Next append some blank lines For i As Integer = 1 To 3 sb.Append(System.Environment.NewLine) Next now convert the string to Unicode bytes = Encoding.Unicode.GetBytes(s) For Each b As Byte In bytes sb.Append(b.ToString & ) Next Me.txtResults.Text = sb.ToString End Sub

Figure 3 shows the difference between the ASCII byte array content and the Unicode byte array content (note that every other byte contains a zero in the Unicode version).

Solution 11 Performing the Most-Requested Conversions in .NET

161

FIGURE 3:
ASCII vs. Unicode byte arrays

Byte Arrays to Strings


Use the GetString method in the appropriate Encoding class to get a string from an array of bytessimply reversing the process shown in the preceding section. Listing 6 shows a simple example.

Listing 6

Obtaining a string from an array of bytes

Private Sub btnByteArrayToString_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnByteArrayToString.Click create an array of bytes (ASCII character codes) Dim bytes() As Byte = {69, 118, 101, 114, 121, 32, _ 71, 111, 111, 100, 32, 66, 111, 121, 32, 68, _ 111, 101, 115, 32, 70, 105, 110, 101} Dim s As String = _ System.Text.Encoding.ASCII.GetString(bytes) Me.txtResults.Text = s Repeat, but with bytes that represent Unicode characters bytes = New Byte() {69, 0, 118, 0, 101, 0, 114, _ 0, 121, 0, 32, 0, 71, 0, 111, 0, 111, 0, 100, 0, _ 32, 0, 66, 0, 111, 0, 121, 0, 32, 0, 68, 0, _

162

General .NET Topics

111, 0, 101, 0, 115, 0, 32, 0, 70, 0, 105, 0, _ 110, 0, 101, 0} s = System.Text.Encoding.Unicode.GetString(bytes) Me.txtResults.AppendText(System.Environment.NewLine & _ System.Environment.NewLine & s) End Sub

Integer Arrays to Strings


Creating an array of type Integer from a string, or a string from an array of type Integer, is a two-step operation thats essentially identical to the example shown in Listing 1. First, you convert each Integer value to a Char and then append it to the string. Listing 7 uses a StringBuilder to collect the characters.

Listing 7

Converting an Integer array to a string

Private Sub btnIntArrayToString_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnIntArrayToString.Click create an Integer array Dim ints() As Integer = {69, 118, 101, 114, 121, _ 32, 71, 111, 111, 100, 32, 66, 111, 121, 32, _ 68, 111, 101, 115, 32, 70, 105, 110, 101} and a StringBuilder Dim sb As New StringBuilder convert each integer value to a Char and append it to the StringBuilder For Each i As Integer In ints sb.Append(Convert.ToChar(i)) Next Me.txtResults.Text = sb.ToString End Sub

Converting Between SqlDataTypes and Base Types


SQL Server has some special data types, because it stores some types of values differently than the .NET Framework. Therefore, youll find that you sometimes need to convert between the two systems. To use the SqlDataTypes, import the System.Data.SqlTypes namespace:
Imports System.Data.SqlTypes

For example, the .NET DateTime structure is not directly compatible with the SqlDateTime structure. You can create a SqlDateTime by simply assigning a .NET DateTime, but if

Solution 11 Performing the Most-Requested Conversions in .NET

163

you try the reverseassigning an SqlDateTime directly to a DateTime variableyoull get an exception.
Dim dt As DateTime = DateTime.Parse("02/03/2003 04:11:13 AM") Dim sdt As New SqlDateTime(dt) sb.AppendLine("SqlDateTime.ToString() = " & sdt.ToString) ' dt = sdt ' This line won't compile dt = sdt.Value ' This works

SQL Server supports a DBNull data type, which, although its an equivalent concept, is not the same as VB.NETs Nothing or C#s null. You can check whether a value contained in a DataReader or DataSet is a SQL Server null value by using the Convert.IsDBNull method. For example the following code fragment shows that DBNull is not the same as Nothing:
Debug.WriteLine(Is Nothing equal to DBNull? & _ Convert.IsDBNull(Nothing)) Prints False

Converting between Reference Types


Converting between reference types is a little more complicated; however, you can always convert anything to type Object, and you can assign an instance of any class to a variable of any type in the classs parent hierarchy, albeit at the price of losing access to some of the information. For example, suppose you have a base class called User. A User has two private string fieldslast and firstand three public properties: LastName, FirstName, and the read-only[Name] (the brackets are required because Name is a reserved word in VB.NET). Listing 8 shows the User class code.

Listing 8

The User class

Private Class User Private mLast As String Private mFirst As String Public Sub New() default constructor End Sub Public Sub New(ByVal last As String, _ ByVal first As String) mLast = last mFirst = first End Sub Public ReadOnly Property [Name]() As String Get Return mLast & , & mFirst End Get End Property Public Property LastName() As String

164

General .NET Topics

Get Return mLast End Get Set(ByVal Value As String) mLast = Value End Set End Property Public Property FirstName() As String Get Return mFirst End Get Set(ByVal Value As String) mFirst = Value End Set End Property End Class

Also suppose you have a class UserEx, which inherits from User. The UserEx class adds a
Title property. Listing 9 shows the UserEx class definition.

Listing 9

The UserEx class

Private Class UserEx Inherits User Private mTitle As String Public Sub New End Sub Public Sub New(ByVal last As String, _ ByVal first As String, ByVal title As String) MyBase.New(last, first) mTitle = title End Sub Public Property Title() As String Get Return mTitle End Get Set(ByVal Value As String) mTitle = Value End Set End Property End Class

You can freely assign an instance of a UserEx to a variable of type User, but you cant assign a User instance to a variable of type UserEx. In other words, this is not legal:
Dim u As User = New User(Doe, John) Dim ux As UserEx = u

Solution 11 Performing the Most-Requested Conversions in .NET

165

If you have an Option Strict On statement at the beginning of the module, youll get a compile warning when you attempt to make such an assignment. If you dont have Option Strict On, then the code compiles but throws a runtime exception. (This is a perfect illustration of why you should put Option Strict On at the beginning of every code file in VB.NET.) The reason the code throws an exception is that the Framework has no way to assign the UserExs Title property. However, the fact that it throws an exception is not intuitive, because a User object, being a member of the UserExs parent class, obviously contains at least some of the code members of UserEx. You might think that the Framework would simply assign the default value for the missing fields, but that isnt the way it works. Instead, you must write special code to make the conversion. You might think that there would be an interface for thisfor example, you might try capitalizing on the existence of the Convert.ChangeType method and write your own ChangeType method for the UserEx class that would change a User into a UserEx. While there is an interface called IConvertible that exposes the ChangeType method, that interface works only to change objects into one of the .NET Framework runtime types, which include only Boolean, SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, Double, Decimal, DateTime, Char, and String. You cant use the IConvertible interface to change your custom object type into a different value type. So, assuming youre willing to live with the loss of information inherent in changing a UserEx to a User, how can you assign the value? Theres a right way and a wrong way. The right way is to create a class that inherits from the System.Componentmodel.TypeConverter class. You override the CanConvertFrom and ConvertFrom methods to return an object converted from one type to another. In the sample code, the UserConverter class handles conversions from User to UserEx, assigning an empty string to the Title property (see Listing 10).

Listing 10

The UserConverter Class

Private Class UserConverter Inherits System.ComponentModel.TypeConverter Public Overloads Function CanConvertFrom( _ ByVal aType As Type) As Boolean If aType Is GetType(User) Then Return True Else Return MyBase.CanConvertFrom(aType) End If End Function Public Overloads Function ConvertFrom( _

166

General .NET Topics

ByVal anObject As Object) As Object If TypeOf anObject Is User Then Dim u As User = CType(anObject, User) Dim ux As New UserEx( _ u.LastName, u.FirstName, String.Empty) Return ux Else Return MyBase.ConvertFrom(anObject) End If End Function End Class

Although the TypeConverter class contains many overloadable methods, you have to overload only the ones you need. In this case, the CanConvertFrom method returns True only when the type to be converted is User. The ConvertFrom method simply creates a new UserEx, assigning the User.LastName and User.FirstName properties in the constructor. The method uses an empty string for the Title parameter. The first question that comes to mind is why you would use an inherited TypeConverter class rather than simply writing a simple method to make the conversion, such as the following:
Private Function ChangeUserToUserEx( _ ByVal aUser As User) As UserEx Return New UserEx(aUser.LastName, _ aUser.FirstName, String.Empty) End Function

The answer is a little unsatisfactory but will have to suffice: You should follow the Framework conventions where such conventions exist, and one such convention is to use a TypeConverter to convert between types. The code for the UserEx To User button in Listing 11 shows various possibilities for assigning a UserEx type to a User type, and vice versa.

Listing 11

Converting between reference types

Private Sub btnUserExToUser_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnUserExToUser.Click Dim ux As New UserEx(Doe, John, President) Dim u As User = ux Dim sb As New StringBuilderEx sb.AppendLine(A UserEx is a User: & _ TypeOf ux Is User) sb.AppendLine(This UserEx instance is the & _

Solution 11 Performing the Most-Requested Conversions in .NET

167

same as the User instance: & (ux Is u).ToString) Dim u1 As New User(Johnson, Elizabeth) With Option Strict on, this line wont compile--no conversion exists. With Option Strict off, the line causes a runtime error. Dim ux1 As UserEx = u1

the following line causes an error Dim ux1 As UserEx = CType( _ Convert.ChangeType(u1, GetType(UserEx)), UserEx) this converts a User into a UserEx using a class that inherits from TypeConverter Dim uc As New UserConverter Dim ux1 As UserEx = CType(uc.ConvertFrom(u1), UserEx) the following line returns false, so the conversion inside the If statement never happens. If uc.CanConvertFrom(GetType(String)) Then Dim ux2 As UserEx = _ CType(uc.ConvertFrom(Bob), UserEx) End If another way is to write a custom function ux1 = ChangeUserToUserEx(u1) sb.AppendLine(Changed a User into a UserEx. & _ It has a blank title & ux1.Title) Me.txtResults.Text = sb.ToString End Sub

Converting Strings to Objects


Although the ToString method, which returns a string representation of an object, is so ubiquitous in .NET that its one of the few methods of the base Object class, theres no easy equivalent FromString method, which would, one assumes, return an instance of the requested object. Its not that it isnt neededone oft-repeated question in the various .NET newsgroups asks how to create an instance of an object if you know its classname. For example, how can you create a Color instance representing the color green if you only have a string such as green? The TypeConverter class can help here, too. You can obtain a converter (assuming one is available) for a specified type or object by passing the type or object to the TypeDescriptor.GetConverter method. You can then use that converter to

168

General .NET Topics

create an instance of the object from some other representation. Listing 12 uses the TypeConverter returned from the TypeDescriptor.GetConverter method when you pass it a Color type, calling its ConvertFromString method to obtain a Color instance for the specified color. On the sample form, when you click the ComboBox and select a color name, the text in the TextBox changes color accordingly, using the code shown in Listing 12.

Listing 12

Creating a color instance from a string containing the color

Private Sub comboColor_SelectedIndexChanged( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles comboColor.SelectedIndexChanged If comboColor.SelectedIndex >= 0 Then Dim sColor As String = _ comboColor.SelectedItem.ToString Dim aColor As Color = _ CType(TypeDescriptor.GetConverter _ (GetType(Color)).ConvertFromString(sColor), Color) Me.txtColor.ForeColor = aColor you can accomplish the same thing using the Color.FromName static method Dim c As Color = Color.FromName(sColor) Me.txtColor.ForeColor = c End If End Sub

NOTE

In this particular case, the Color class has a built-in static method called Color.FromName that does the same thing, as shown in the commented-out code of Listing 12.

You can also create instances of types from any name using the methods in the Activator class, which is part of the System namespace and is thus always available. To create a Type instance, you need a Type, which you can obtain from a string containing a type name. Use the static Type.GetType method to obtain the Type instance. Pass the Type instance to the Activator.CreateInstance method. Note that it returns an object of type Object, not an instance of the requested type; therefore, you have to cast it to the correct type to use it. The sample form contains a ComboBox that lets you create an instance of the User or UserEx class discussed earlier in this solution. Listing 13 shows the code that runs when a user selects an item from the ComboBox.

Solution 11 Performing the Most-Requested Conversions in .NET

169

Listing 13

Creating an object instance from a type name

Private Sub comboTypes_SelectedIndexChanged( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles comboTypes.SelectedIndexChanged If comboTypes.SelectedIndex >= 0 Then Dim typeName As String = _ comboTypes.SelectedItem.ToString Dim t As Type = Type.GetType(typeName) If typeName = conversions.User Then Dim u As User = CType( _ Activator.CreateInstance(t), User) u.LastName = Johnson u.FirstName = Bob Me.txtResults.Text = u.ToString & : & u.Name ElseIf typeName = conversions.UserEx Then Dim ux As UserEx = _ CType(Activator.CreateInstance(t), UserEx) ux.LastName = Johnson ux.FirstName = Bob ux.Title = Programmer Me.txtResults.Text = ux.ToString & : & _ ux.Name & , & ux.Title Else Me.txtResults.Text = Unable to create & _ object of type & typeName & . End If End If End Sub

Rules for Conversions


As you can see, the .NET Framework contains numerous methods for changing from one type to another. The bottom-line rules for conversions are as follows:

If youre converting base types, look at the Convert class. If you cant find the conversion you need and youre converting from a string, look for a Parse, FromString, or similar method built into the class. Get a TypeConverter (using TypeDescriptor.GetConverter) and use TypeConverter methods such as CanConvertFrom or CanConvertTo to find out if the type converter supports the conversion you need. Use the TypeConverter.ConvertFromString method, when available, to convert strings to object instances. If the string contains a type name, use the Activator.CreateInstance method.

170

General .NET Topics

SOLUTION

12
SOLUTION

Building Custom Collections in .NET


The .NET collection classes are convenient, but how can I create a collection that only accepts a specific type of object, such as a collection of a custom class?
PROBLEM

Fortunately, .NET contains some useful base classes that you can capitalize on to build strongly typed custom collections.

The .NET Frameworkspecifically, the System.Collections namespaceexposes several ready-to-use collection classes. Two such immediately useful classes are the ArrayList and Dictionary classes. Unfortunately, when you begin working with them, youll quickly find that the collections are not type-safe; they store everything as type Object. Therefore, its easy to add any type to these collections, but to get the object back out in useful form, you must convert it from Object to the correct type. Thats annoying, but not sufficiently annoying to make it worth the bother of creating a new class. The real showstopper is that you (or more likely, someone else) can store any type of object in the collections. If you instead want a type-safe collectionone that restricts its contents to one object typeyou have to take the next step and create your own class.

Implementing Collections Using Containment


Suppose you have a Guitar class that youd like to expose to calling programs, letting them add Guitar instancesand only Guitar instancesto a GuitarCollection class. One way to take that step is to use containment to wrap an ArrayList in a custom class. Thats what you would have done in VB6, and that method still works. As long as you dont expose the underlying ArrayList directly, you can easily create a collection class that restricts additions to the collection to a specific type. For example, Listing 1 shows a partially implemented WrappedGuitarCollection wrapper class.

Listing 1

A partially implemented class using containment to wrap an ArrayList (WrappedGuitarCollection.vb)

Public Class WrappedGuitarCollection Private guitars As ArrayList Public Sub New()

Solution 12 Building Custom Collections in .NET

171

guitars = New ArrayList() End Sub Overridable Overloads ReadOnly Property Count() _ As Integer Get Return Me.guitars.Count End Get End Property Public Overridable Sub Add(ByVal aGuitar As Guitar) guitars.Add(aGuitar) End Sub Default Public Overridable Shadows Property Item( _ ByVal index As Integer) As Guitar Get If index >= 0 And index < guitars.Count Then Return CType(guitars(index), Guitar) Else Throw New IndexOutOfRangeException() End If End Get Set(ByVal Value As Guitar) guitars(index) = Value End Set End Property End Class

As you can see, the WrappedGuitarCollection class uses the private ArrayList variable guitars to hold the data. Because the class never directly exposes the ArrayList, the only way to add new items to the list is through the Add methodand that method accepts only instances of the Guitar class. The Item method retrieves the object at the specified index, casts it back to a Guitar, and returns it. In short, the class wraps an ArrayList to deliver a typed collection; however, as it stands, its not a very usable collection. To make it as user-friendly as the ArrayList or other .NET collections, youll need to implement properties such as Count and Capacity, and methods such as Remove, RemoveAt, Clear, and CopyTo, and of course GetEnumerator, which lets you use the For Each syntax to iterate through the collection. You may also need to implement Sort, Reverse, ToString, ToArray, and many other methods that the ArrayList class offers. But heres the important point: Whenever you want to expose any functionality from the wrapped ArrayList class, you must create a wrapper method in your containing class, because you cannot expose the wrapped ArrayList itself outside its containing class. If you do expose the wrapped ArrayList, you give users direct access to the private ArrayList memberand

172

General .NET Topics

when you do that, people can bypass your Add method and store whatever type they like in the underlying ArrayList. In other words, exposing the underlying ArrayList defeats the whole purpose of wrapping the ArrayList class to begin with. If creating a class wrapper is beginning to seem like more work than you want to do, youre in luck, because the containment work has already been done for you in some specialized classes in the System.Collections and System.Collections.Specialized namespaces. Using these classes, you can build a strongly typed collection class using inheritance and simplify your code.

Inheriting CollectionBase
The .NET Framework includes several abstract (marked with the MustInherit attribute) base classes that you can use to build strongly typed collections. The System.Collections namespace contains CollectionBase, ReadOnlyCollectionBase, and DictionaryBase classes. The System.Collections.Specialized namespace provides a NameObjectCollectionBase class that holds a sorted list of key-object pairs, as well as the CollectionsUtil class that you can use to build case-insensitive string collections. Which base class you use depends on your needs. If you want to provide access to the collection by index only, inherit from the CollectionBase class. If you need access to the collection by index or by key, inherit from the DictionaryBase class. If you want to sort the collection by the keys, use the NameObjectCollectionBase class. All these classes use containment themselves. The abstract CollectionBase and ReadOnlyCollectionBase classes wrap an ArrayList. The CollectionBase class is essentially the same idea you just saw but is a complete implementation. The DictionaryBase and NameObjectCollectionBase wrap the Hashtable class to provide key-object storage. By inheriting from one of these base classes, you gain all the advantages of the underlying class while minimizing the problems and the amount of code you have to write. Listing 2 shows the strongly typed GuitarCollection class recast as a class that inherits from CollectionBase.

Listing 2

The strongly typed GuitarCollection class inherits from CollectionBase. (GuitarCollection.vb)

Option Strict On Imports System.Collections Public Class GuitarCollection Inherits System.Collections.CollectionBase Public Overloads Function Add( _ ByVal aGuitar As Guitar) As Integer Return Me.List.Add(aGuitar)

Solution 12 Building Custom Collections in .NET

173

End Function Public Property Item(ByVal index As Integer) _ As Guitar Get Return DirectCast( _ Me.InnerList.Item(index), Guitar) End Get Set(ByVal Value As Guitar) Me.List.Item(index) = Value End Set End Property Public Sub Insert(ByVal index As Integer, _ ByVal value As Guitar) Me.List.Insert(index, value) End Sub Public Sub Remove(ByVal aGuitar As Guitar) Me.List.Remove(aGuitar) End Sub Public Function Contains(ByVal aGuitar As Guitar) _ As Boolean Return Me.List.Contains(aGuitar) End Function Public Sub CopyTo(ByVal arrayOfGuitars() As Guitar, _ ByVal DestinationStartindex As Integer) Me.List.CopyTo(arrayOfGuitars, DestinationStartindex) End Sub End Class

Because the GuitarCollection class inherits from CollectionBase, it has access to an inherited private List property, which returns an instance of a class that implements the IList interface. The CollectionBase class also exposes an InnerList property. Although both properties return an object that contains a list, theyre not the same type of object. The ShowListType method in Listing 3 shows how to investigate the difference between the List and InnerList properties.

Listing 3

The ShowListType method returns the type names of the List and InnerList properties. (GuitarCollection.vb)

Public Function ShowListType() As String Dim s As String = (InnerList is a & _ Me.InnerList.GetType.ToString & vbCrLf & _ List is a & CType(Me.List, Object). _ GetType.ToString)

174

General .NET Topics

s += vbCrLf & The InnerList and the List & property return the same object: & _ (Me.InnerList Is Me.List).ToString & vbCrLf s += The List property is Me: & _ (Me.List Is Me).ToString Return s End Function

You can add a button to the sample form and write a bit of code in the button click event handler to call the ShowListType method and display the results (see Listing 4).

Listing 4

Call the ShowListType method and display the results. (GuitarCollection.vb)

Private Sub btnShowListTypes_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnShowListTypes.Click Me.txtResult.Text = guitars.ShowListType End Sub

Rather surprisingly, heres what youll see in the txtResult TextBox when you click the
btnShowListTypes button:
InnerList is a System.Collections.ArrayList List is a Solution12.GuitarCollection The InnerList and the List property return the same object: False The List property is Me: True

The preceding code clearly shows that theres a difference between the two properties, and reveals a bit more about the CollectionBase class as well. The InnerList property exposes the underlying ArrayList, while the List property exposes an object that implements the IList interfacean object of the GuitarCollection class! Why does the class need a special property that refers to itself? The answer is that the class designers wanted to add events to the underlying ArrayList, but the ArrayList class doesnt fire any events. Therefore, the List property acts as a wrapper within a wrapper. Because the List instance manages access to the InnerList instance (the ArrayList), it can call methods both before and after modifying the contents of the InnerList. By default, the methods do nothing, but you can override them to take special actions. All the methods begin with On, and (except for OnValidate) occur in pairs, such as OnClear and OnClear Complete, and OnInsert and OnInsertComplete, and so forth. For example, the class calls the OnSet and OnSetComplete methods whenever an indexed value changes via the Item (Set) property. You can use the OnSet method to write a log entry

Solution 12 Building Custom Collections in .NET

175

describing the original Item value and the replacement value, and use the OnSetComplete method to write a second entry confirming the replacement. The sample application that accompanies this solution (downloadable from the Sybex Web site at www.sybex.com) shows when these events occur by writing messages to the Output window. To see it, add two public event declarations to the GuitarCollection class module:
Public Event AddingItem(ByVal msg As String) Public Event ItemAdded(ByVal msg As String)

Also, add the code in Listing 5 to override the OnInsert and OnInsertComplete methods.

Listing 5

Overriding the OnInsert and OnInsertComplete methods (GuitarCollection.vb)

Protected Overrides Sub OnInsert( _ ByVal index As Integer, _ ByVal Value As Object) RaiseEvent AddingItem(About to add a new guitar & _ to the GuitarCollection.) End Sub Protected Overrides Sub OnInsertComplete( _ ByVal index As Integer, _ ByVal Value As Object) RaiseEvent ItemAdded(Guitar added.) End Sub

Create two methods in Form1 to handle the events raised from the GuitarCollection:
Private Sub AddingItem(ByVal s As String) Debug.WriteLine(s) End Sub Private Sub ItemAdded(ByVal s As String) Debug.WriteLine(s) End Sub

Next, wire the events to the two methods you just added in the Form_Load event handler:
Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load AddHandler guitars.AddingItem, AddressOf Me.AddingItem AddHandler guitars.ItemAdded, AddressOf Me.ItemAdded End Sub

Finally, create a method in Form1.vb that populates the GuitarCollection (Listing 6). Each time the method adds an item to the collection, you will see two messages appear in the output window: one just before the collection adds an item and one just after.

176

General .NET Topics

Listing 6

Populating the GuitarCollection (Form1.vb)

Private Sub btnTestGuitarCollection_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestGuitarCollection.Click Dim aGuitar As Guitar Dim i As Integer Dim sb As New StringBuilder() For i = 1 To 100 aGuitar = New Guitar(Gibson, A100) guitars.Add(aGuitar) sb.Append(aGuitar.Manufacturer & Model #: & _ aGuitar.Model & vbCrLf) Next Me.txtResult.Text = sb.ToString() End Sub

The other methods for handling pre- and post-list modification events work similarly. The CollectionBase class calls the OnValidate method, like the OnInsert method, just before the class adds an item to the underlying ArrayList. It calls the OnValidate method before calling OnInsert. There is a default implementation for this method that prevents you from adding or removing null values from the list, so if you override OnValidate, remember to call the base class implementation. This is important because you automatically lose the built-in non-null protection that the OnValidate method provides if you override it incorrectly. Heres an example. Override the OnValidate method in the GuitarCollection class so that it raises the ItemAdded event, just like the OnInsert method:
Protected Overrides Sub OnValidate( _ ByVal Value As Object) RaiseEvent ItemAdded(OnValidate Fired.) MyBase.OnValidate(Value) End Sub

For example, if you alter the For loop in Listing 6 that adds Guitar instances to the collection so that it attempts to add a null value (Nothing in VB.NET) on the 50th iteration, the GuitarCollection class should throw an error on the MyBase.OnValidate(Value) line:
Private Sub btnTestGuitarCollection_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestGuitarCollection.Click Dim aGuitar As Guitar Dim i As Integer

Solution 12 Building Custom Collections in .NET

177

Dim sb As New StringBuilder() For i = 1 To 100 aGuitar = New Guitar(Gibson, A100) If i = 50 Then guitars.Add(Nothing) Else guitars.Add(aGuitar) End If sb.Append(aGuitar.Manufacturer & Model #: & _ aGuitar.Model & vbCrLf) Next Me.txtResult.Text = sb.ToString() For i = 1 To 50 guitars.Item(i) = aGuitar Next End Sub

The preceding code does throw an error in the OnValidate method. Comment out or remove the MyBase.Validate line in the OnValidate method, and then run the project again:
Protected Overrides Sub OnValidate( _ ByVal Value As Object) RaiseEvent ItemAdded(OnValidate Fired.) MyBase.OnValidate(Value) End Sub

This time, adding the null value doesnt cause an erroryouve lost that protection of the base classs implementation. Unless you want to take different actions when inserting or changing items in the collection, use the OnValidate method to check items rather than the OnInsert or OnSet method. The CollectionBase class calls OnValidate both when you change an item via the Item (Set) property, and when you insert one via the Insert or Add methods.

Inheriting from DictionaryBase


Creating a keyed custom collection class that inherits from the DictionaryBase class requires almost exactly the same process as building a custom collection class that inherits from CollectionBase. Youll encounter a few differences, though. One difference is that the DictionaryBase class exposes a Dictionary property rather than a List property, and an InnerHashtable property rather than an InnerList property. However, the properties have the same purpose. Another slight difference is that the DictionaryBase class implements a default CopyTo method that copies the values (not the keys) in the underlying Hashtable to a one-dimensional array, starting at a specified offset (index) in the target array; therefore, unless you need to override

178

General .NET Topics

the default version for some reason, you dont have to implement it in your derived class. Finally, the DictionaryBase class has a default Contains implementation that accepts a string key and returns a Boolean value that signifies whether the Dictionary contains an object with the specified key. Listing 7 shows an example that uses a combination of a model number and a serial number to create a unique key for a GuitarDictionary class. The class overrides the OnValidate method to ensure that any Guitar instances added to the class have both a model number and a serial number.

Listing 7

The GuitarDictionary class (GuitarDictionary.vb)

Option Strict On Imports System.Collections Public Class GuitarDictionary Inherits DictionaryBase Public Event Validating(ByVal msg As String) Public Event Validated(ByVal msg As String) Public Sub Add(ByVal aGuitar As Guitar) Me.Dictionary.Add(aGuitar.Model & _ aGuitar.SerialNumber, aGuitar) End Sub Public Sub Remove(ByVal key As String) Me.Dictionary.Remove(key) End Sub Default Public Property Item(ByVal key As String) _ As Guitar Get Return CType(Me.Dictionary.Item(key), Guitar) End Get Set(ByVal Value As Guitar) Me.Dictionary.Item(key) = Value End Set End Property Protected Overrides Sub OnValidate( _ ByVal key As Object, _ ByVal value As Object) RaiseEvent Validating( _ Testing Model and SerialNumber) Dim aGuitar As Guitar = CType(value, Guitar) If aGuitar.Model Is Nothing OrElse _ aGuitar.SerialNumber Is Nothing Then Throw New ApplicationException( _ Guitars added to this collection may not & _ have empty Model and SerialNumber properties.)

Solution 12 Building Custom Collections in .NET

179

Return End If If aGuitar.Model Is String.Empty OrElse _ aGuitar.SerialNumber Is String.Empty Then Throw New ApplicationException( _ Guitars added to this collection may not & _ have empty Model and SerialNumber properties.) Return End If RaiseEvent Validated(Guitar passed Validation) End Sub End Class

The event handler for the Test GuitarDictionary button on the sample form fills the GuitarDictionary with Guitar instances. The following code forces an error on the 50th item:
Private Sub btnTestGuitarDictionary_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestGuitarDictionary.Click Dim aGuitar As Guitar Dim i As Integer Dim sb As New StringBuilder() For i = 1 To 100 aGuitar = New Guitar(Gibson, A100) aGuitar.SerialNumber = i.ToString guitarsDictionary.Add(aGuitar) sb.Append(aGuitar.Manufacturer & Model #: & _ aGuitar.Model & SerialNumber: & _ aGuitar.SerialNumber & vbCrLf) Next Me.txtResult.Text = sb.ToString() End Sub

To test the OnValidate method in the GuitarCollection class, introduce an error into the btnTestGuitarDictionary_Click event code shown in the preceding code snippetfor example, dont set the Guitarl.SerialNumber property before calling the GuitarDictionary.Add method.

Build a Custom Value-Type Collection


The CollectionBase and DictionaryBase classes are as easy to use as base classes, but they are still wrappers for an underlying collection, which imposes a severe limitation: The underlying collection classes store all values as Object instances. Whats the problem with that? If you use them to create a strongly typed collection of a value type, such as Int32 or Double, the code will box or unbox every value as you store or retrieve it from the collection.

180

General .NET Topics

In iterations across large lists where speed is an issue, the boxing/unboxing operation may cause an unacceptable penalty. Generics, due (for C# at least) in the next major release of the .NET Framework, may solve the problem, but until then, youre faced with using arrays for value types or with creating your own collection class from scratch, without relying on any existing collection type. If you choose to write a collection class, you should also implement the methods in the IList (or IDictionary) interface; however, you cant implement the interfaces directly, because they both have a default Item property that returns an Object. Using the Item property would cause the values to box and unbox, which would defeat the whole purpose behind building the collection. Fortunately, its not that difficult to create a typed collection class based on arrays; however, you cant create a generic collection class based on arrays, because you have to define the type of data the class will hold ahead of time. Therefore, you cant create a GenericValueTypeCollection class; you must create a specific class for each value type you want to add to the collection, such as a ValueTypeIntCollection or a ValueTypeDoubleCollection. Listing 8 contains the code for a ValueTypeCollection class that implements an Integer collectionnamed (not surprisingly) IntegerCollection. The class has many of the same methods as the ArrayList, such as Add, Remove, Insert, and CopyTo. You may be surprised to find that for most real-world lists, despite the boxing/unboxing problem, theres essentially no speed difference between using this class and using an ArrayList. As the number of items increases, the speed advantage shifts toward this custom class. You wont see these advantages unless the number of items in the collection becomes fairly large. The biggest advantage of these typed custom collections isnt speedits convenience. Youll find it easier to use collections than to code arrays directly. With collections, you dont have to worry about array dimensions or casting. NOTE
This is not production code, and I have made no attempt to optimize the class, but it can serve as a basic example for such value-type classes.

Listing 8

A custom Integer value-type collection class (ValueTypeCollection.vb)

Option Strict On Imports System.Collections Public Class IntegerCollection Implements IEnumerable Private Private Private Private mInitialSize As Integer = 15 ints() As Integer mMaxSize As Integer = 15 mCount As Integer = 0

Solution 12 Building Custom Collections in .NET

181

Public Sub New() ReDim ints(mInitialSize - 1) End Sub Private Sub ExpandIfNeededBy( _ Optional ByVal aNumber As Integer = 0) If aNumber > 0 Then If (Count + aNumber) >= ints.Length Then Me.Resize(Count + aNumber) End If Else If (Count) >= ints.Length Then Me.Resize(Count + 1) End If End If End Sub Private Sub Resize(ByVal minSize As Integer) Do While ints.Length < minSize Dim newSize As Integer = ints.Length + _ (ints.Length \ 2) If newSize < Me.InitialSize Then newSize = Me.InitialSize End If SetLength(newSize) Loop End Sub Private Sub SetLength(ByVal newSize As Integer) If ints.Length < newSize Then ReDim Preserve ints(newSize) End If End Sub Private Function checkIndex( _ ByVal index As Integer) As Boolean If index >= 0 And index < mCount Then Return True End If Throw New _ IndexOutOfRangeException( _ ValueTypeCollection: & _ Index out of range.) End Function Default Public Overridable Property Item( _ ByVal index As Integer) As Integer Get If checkIndex(index) Then Return ints(index) End If

182

General .NET Topics

End Get Set(ByVal Value As Integer) ints(index) = Value End Set End Property Public Function GetEnumerator() As IEnumerator _ Implements IEnumerable.GetEnumerator Return New IntegerEnumerator(ints, mCount - 1) End Function Public Overridable ReadOnly Property IsSynchronized() _ As Boolean Get Return ints.IsSynchronized always false End Get End Property Public Overridable ReadOnly Property SyncRoot() _ As Object Get Return ints.SyncRoot End Get End Property Public Overridable Sub CopyTo( _ ByVal anArray As Array, ByVal index As Integer) ints.CopyTo(anArray, index) End Sub Public Property InitialSize() As Integer Get Return mInitialSize End Get Set(ByVal Value As Integer) mInitialSize = Value force the array to be at least this big If ints.Length < mInitialSize Then Me.SetLength(mInitialSize) End If End Set End Property Public ReadOnly Property Count() As Integer Get Return mCount End Get End Property Public Sub Clear()

Solution 12 Building Custom Collections in .NET

183

ReDim ints(-1) ReDim ints(Me.InitialSize) mCount = 0 End Sub Public Sub Add(ByVal anInt As Integer) ExpandIfNeededBy(1) ints(Count) = anInt mCount += 1 End Sub Public Sub RemoveAt(ByVal index As Integer) If checkIndex(index) Then copy from index + 1 to the current count to the array itself starting at index Array.Copy(ints, index + 1, ints, index, _ (Count - index) - 1) mCount = mCount - 1 set the old last item to 0 ints(mCount) = 0 End If End Sub Public Sub Remove(ByVal aValue As Integer) Dim i As Integer For i = ints.Length - 1 To 0 Step -1 If ints(i) = aValue Then Me.RemoveAt(i) End If Next End Sub Public Sub Insert(ByVal index As Integer, _ ByVal value As Integer) If checkIndex(index) Then ExpandIfNeededBy(1) ints(index) = value End If End Sub The IntegerCollection returns an instance of this class, IntegerEnumerator when a user calls GetEnumerator or uses a For Each block to iterate across the IntegerCollection. Private Class IntegerEnumerator Implements IEnumerator Private ints() As Integer Private mIndex As Integer = -1 Private mMaxIndex As Integer

184

General .NET Topics

Friend Sub New(ByVal anArray As Integer(), _ ByVal maxIndex As System.Int32) ints = anArray If maxIndex > anArray.Length - 1 Then maxIndex = anArray.Length - 1 Else mMaxIndex = maxIndex End If End Sub Public ReadOnly Property Current() As Object _ Implements IEnumerator.Current Get Return ints(mIndex) End Get End Property Public Function MoveNext() As Boolean _ Implements IEnumerator.MoveNext mIndex += 1 If mIndex > mMaxIndex Then Return False Else Return True End If End Function Public Sub Reset() Implements IEnumerator.Reset mIndex = -1 End Sub End Class End Class

The sample project includes some code for testing the IntegerCollection class. The code adds one million integer values to the collection, loops through them using For Each, and removes the first 50 items from the start of the list (which is relatively slow, because as implemented, the collection closes the gap by copying all the items after the removed item one position higher in the collection). Finally, it removes the remaining items, one at a time, by removing the items from the end of the listwhich is a relatively fast operation. The code displays the time, in milliseconds, for each of the four operations (see Listing 9).

Solution 12 Building Custom Collections in .NET

185

Listing 9

Testing the IntegerCollection (Form1.vb)

Private Sub btnTestIntCollection_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestIntCollection.Click Dim ic As New IntegerCollection() ic.InitialSize = 1000000 Dim i As Integer Dim j As Integer Dim mark As Integer Me.Cursor = Cursors.WaitCursor txtResult.Clear() store the current TickCount mark = System.Environment.TickCount add one million integers to the collection AppendResultLine(Adding 1,000,000 integers & _ to collection.) For i = 1 To 1000000 ic.Add(i) Next show the elapsed time in milliseconds AppendResultLine(Elapsed time: & _ (System.Environment.TickCount - mark).ToString _ & ms) AppendResultLine(Loop through collection & _ using For Each) mark = System.Environment.TickCount For Each i In ic j = i Next AppendResultLine(Elapsed time: & _ (System.Environment.TickCount - mark) _ .ToString & ms) AppendResultLine(Remove first 50 items) mark = System.Environment.TickCount For i = 1 To 50 ic.Remove(i) Next AppendResultLine(Elapsed time: & _ (System.Environment.TickCount - mark) _ .ToString & ms)

186

General .NET Topics

AppendResultLine(Remove all items, one at a time.) mark = System.Environment.TickCount Do While ic.Count > 0 ic.RemoveAt(ic.Count - 1) Loop AppendResultLine(Elapsed time: & _ (System.Environment.TickCount - mark) _ .ToString & ms) Me.Cursor = Cursors.Default End Sub Private Sub AppendResultLine(ByVal s As String) txtResult.AppendText(s & System.Environment.NewLine) End Sub

If you test this code against identical operations using an ArrayList, youll find that the ArrayList is essentially identical in speed except for the part that removes all the items from the end of the list, one at a time. For that operation, the IntegerCollection wins, hands down. So, despite all that you might read about the penalties involved in boxing and unboxing value types, in reality, the penalties are insignificant unless you need to work with lists containing well over 1,000,000 items.

SOLUTION

13

Launching and Monitoring External Programs from VB.NET Applications


PROBLEM Its not at all obvious how to launch external programs when programming in .NET, nor is it obvious how to control them, monitor them to see when the external application shuts down, send data, or read exit codes. SOLUTION

You no longer need to use the Win32 API or the VB Shell function to launch external applications. Instead, use the System.Diagnostics.Process class built into the .NET framework to simplify your code. The Process class contains methods and properties that let you gain precise control of launched applications.

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

187

Although .NET makes some things more complicated, launching external programs is not one of them. In classic VB, you could use the Shell function to launch an application. When you passed an executable filename, VB launched the application. When you passed a data filename, VB opened the data file in its associated application. You could also control the window style of the launched application with an optional windowstyle parameter. For example, in VB6, the following line would launch the default text editor (usually Notepad) and open the file c:\somepath\somefile.txt :
returnID = Shell(c:\somepath\somefile.txt, _ vbNormalFocus)

The Shell function still exists in VB.NET through the Microsoft.VisualBasic.Compatibility namespace, and it has been improved a bit, but its often not the best way to launch programs in the .NET Framework. In earlier VB versions, the Shell function had some serious limitations, one of which was that it launched programs asynchronously; after launching a program, your program code would continue to run. So you couldnt use it directly to launch a program and wait for it to exit before continuing to process code in your own program. For that, you had to fall back on the Windows API, which required an understanding of window handles, process IDs, enumerating top-level windows, and so on. Later versions of VB fixed that problem. For a more complete explanation, see the Microsoft Knowledge Base topics http://support.microsoft.com/support/kb/articles/Q96/8/44.asp, Q96844 - HOWTO: Determine When a Shelled Process Has Terminated (16-Bit) and http://support .microsoft.com/support/kb/articles/Q129/7/96.asp, Q129796 - HOWTO: Use a 32Bit App to Determine When a Shelled Process Ends.

Simple Is as Simple Does


C++ gave you a lot more control, but even simple operations werent simple. For example, heres a listing from the Windows Shell API documentation titled A Simple Example of How to Use the Shell API:
#include <shlobj.h> #include <shlwapi.h> main() { LPMALLOC pMalloc; LPITEMIDLIST pidlWinFiles = NULL; LPITEMIDLIST pidlItems = NULL; IShellFolder *psfWinFiles = NULL; IShellFolder *psfDeskTop = NULL; LPENUMIDLIST ppenum = NULL; STRRET strDispName; TCHAR pszParseName[MAX_PATH];

188

General .NET Topics

ULONG celtFetched; SHELLEXECUTEINFO ShExecInfo; HRESULT hr; BOOL fBitmap = FALSE; hr = SHGetMalloc(&pMalloc); hr = SHGetFolderLocation(NULL, CSIDL_WINDOWS, NULL, NULL, &pidlWinFiles); hr = SHGetDesktopFolder(&psfDeskTop); hr = psfDeskTop->BindToObject(pidlWinFiles, NULL, IID_IShellFolder, (LPVOID *) &psfWinFiles); hr = psfDeskTop->Release(); hr = psfWinFiles->EnumObjects(NULL,SHCONTF_FOLDERS | SHCONTF_NONFOLDERS, &ppenum); while( hr = ppenum->Next(1,&pidlItems, &celtFetched) == S_OK && (celtFetched) == 1) { psfWinFiles->GetDisplayNameOf(pidlItems, SHGDN_FORPARSING, &strDispName); StrRetToBuf(&strDispName, pidlItems, pszParseName, MAX_PATH); pMalloc->Free(pidlItems); if(StrCmpI(PathFindExtension(pszParseName), TEXT( .bmp)) == 0) { fBitmap = TRUE; break; } } ppenum->Release(); if(fBitmap) { ShExecInfo.cbSize = sizeof(SHELLEXECUTEINFO); ShExecInfo.fMask = NULL; ShExecInfo.hwnd = NULL; ShExecInfo.lpVerb = NULL; ShExecInfo.lpFile = pszParseName; ShExecInfo.lpParameters = NULL; ShExecInfo.lpDirectory = NULL; ShExecInfo.nShow = SW_MAXIMIZE; ShExecInfo.hInstApp = NULL;

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

189

ShellExecuteEx(&ShExecInfo); } pMalloc->Free(pidlWinFiles); pMalloc->Release(); psfWinFiles->Release(); return 0; }

The explanation of this code is: The application first retrieves the PIDL of the Windows directory, and enumerates its contents until it finds the first .bmp file. Unlike the earlier example, IShellFolder::GetDisplayNameOf is used to retrieve the files parsing name instead of its display name. Because this is a file system folder, the parsing name is a fully qualified path, which is what is needed for ShellExecuteEx. Once the first .bmp file has been located, appropriate values are assigned to the members of a SHELLEXECUTEINFO structure. The lpFile member is set to the parsing name of the file, and the lpVerb member to NULL, to begin the default operation. In this case, the default operation is open. The structure is then passed to ShellExecuteEx, which launches the default handler for bitmap files, typically MSPaint.exe, to open the file. After the function returns, the PIDLs are freed and the Windows folders IShellFolder interface is released. With .NET, things truly have become much simpler. For example, Listing 1 shows the equivalent of the preceding C++ code to launch the first .bmp file in the Windows folder with the users default handler for bitmap files.

Listing 1

Opening the first BMP file in the Windows folder (frmProcessExamples)

Private Sub btnOpenBMP_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnOpenBMP.Click Find the first Windows BMP file Dim files As FileInfo() get the System path Dim systemPath As String = _ Environment.GetFolderPath( _ Environment.SpecialFolder.System) create a DirectoryInfo object for the Windows folder Dim dInfo As DirectoryInfo = _

190

General .NET Topics

New DirectoryInfo(systemPath).Parent get the list of .bmp files in the Windows folder files = dInfo.GetFiles(*.bmp) check length, then start first entry using the default bmp handler program If files.Length > 0 Then Process.Start(files(0).FullName) End If End Sub

The code gets the path to the System folder, and then creates a DirectoryInfo object on that folders parent folder. Next it calls the DirectoryInfo.Files method with the optional filter string argument, which returns a list of files matching the filter. Finally, it uses the Process.Start method to launch the first entry in the list in the users default bitmap handler application.

Introducing the Process Class


You can see that at the simplest level you can launch a new process with the shared Process .Start method, passing it either the name of an executable file or a filename with an extension associated with an executable application. For example, the following code launches the c:\somepath\somefile.txt process:
System.Diagnostics.Process.Start( _ c:\somepath\somefile.txt)

The Start method has an overloaded version that returns a Process object, so you can obtain a reference to the launched process and use it for various purposes:
Dim myProcess As Process = System.Diagnostics.Process.Start (c:\somepath\somefile.txt) MessageBox.Show(myProcess.ProcessName)

At first glance, compared to the classic VB Shell function or the ShellExecute API, you seem to have lost the ability to control the window stylebut you havent. Another overloaded Process.Start method accepts a ProcessStartInfo object parameter rather than a simple string. To use it, first create a ProcessStartInfo object, and then set process initialization values. Two overloaded methods let you set either just a filename or a filename and a set of command-line parameters. The ProcessStartInfo object also has a WindowStyle property, which consists of values from the System.Diagnostics.Process.WindowStyle enumeration. So you can call the Process.Start method and pass a ProcessStartInfo object to control the launched windows style, as shown here:
Dim psInfo As New _ System.Diagnostics.ProcessStartInfo _ (c:\somepath\somefile.txt)

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

191

psInfo.WindowStyle = _ System.Diagnostics.ProcessWindowStyle.Normal Dim myProcess As Process = _ System.Diagnostics.Process.Start(psInfo)

Because the Process class exposes a StartInfo property thats a ProcessStartInfo object, another way to accomplish the same result is to create a Process object and set its StartInfoproperty. When you use a pre-created Process object, you can simply call that instances Start method rather than using the Process classs shared Start method:
Dim myProcess As System.Diagnostics.Process = _ new System.Diagnostics.Process() myProcess.StartInfo.FileName = _ c:\somepath\somefile.txt myProcess.StartInfo.WindowStyle = _ System.Diagnostics.ProcessWindowStyle.Normal myProcess.Start

Setting Process Parameters at Design Time


The .NET Framework ships with a Process component that encapsulates all this code at design time. You can find it in the Components area of the Toolbox. To use it in Visual Studio, drag a Process component onto your form, expand the StartInfo property in the Properties window, and set the StartInfo values to your liking (see Figure 1). FIGURE 1:
A Process component on a Windows Form

192

General .NET Topics

Monitoring Launched Processes


So far, the launched processes youve seen behave in an asynchronous manner, just like the classic VB Shell function. In other words, after launching the process, code in the launching program continues to execute. You need some way to monitor the launched process and find out when it exitsor sometimes, whether its still running. Depending on your application, you may need to approach the problem in any of several different ways:

You want to launch the process, halting your program until it exits. You want to launch the process, monitor it, and do something only when it ends, letting your program run normally in the meantime. You want to launch the process, give it some input, let it process the input, and then force it to exit. You want to launch the process and do something only as long as the launched process is running, or is running without problems. If the process exits or stalls, you want to take some action. You want to launch the process and give it some specific input, and/or retrieve the output for further processing. For example, you might want to launch a command window, programmatically type something into the window, and then retrieve and process the output.

Launching a Process and Waiting Until It Exits


The simplest way to wait for a launched process to end is to call the Process.WaitForExit method. That causes the launching process to stop executing until the launched process exits. Unfortunately, when you use this approach directly from a Windows Form, it also causes the form to stop responding to system events, such as Paint. So you wouldnt normally want to use the WaitForExit method to launch an external program from a Button (although its perfectly appropriate to use the WaitForExit method to launch a second process from an application that has no visible user interface, such as calling a console application from the server in an ASP.NET application). The sample form has a button called Launch and WaitForExit (see Figure 2) that lets you see what happens when you use this method from a form. Listing 2 shows the code for the Launch and WaitForExit button click handler.

Listing 2

Launching a process and waiting until it exits (frmProcessExamples)

Private Sub btnWaitForExit_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnWaitForExit.Click

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

193

create a new process Dim myProcess As Process = _ System.Diagnostics.Process.Start(sample.txt) wait until it exits myProcess.WaitForExit() display results MessageBox.Show(Notepad was closed at: & _ myProcess.ExitTime & . & _ System.Environment.NewLine & Exit Code: & _ myProcess.ExitCode) myProcess.Close() End Sub

The example in Listing 2 illustrates an interesting point. Even though the launched process has closed, you still have the ability to access the Process object in code; however, at that point, most of the Process properties are unavailable, because the process itself no longer exists. You can still read the ExitCode and ExitTime properties, which return Integer and DateTime values. DOS commands set an exit code that lets you know whether errors occurred, and .NET applications can set the value by using the return value of the main method. By default, the value is zero. For DOS commands, a non-zero ExitCode value indicates that either an error occurred or the command process was closed abnormally. NOTE
When you use the Process.Start method from a process instance, you should also call Process.Close after the process exits, to free the memory associated with the Process object.

FIGURE 2:
Sample form for experimenting with the Process class

194

General .NET Topics

Launching Invisible Processes and Redirecting Output


You dont have to launch a process in a visible window; sometimes you just want to run a process and retrieve the output. The code shown in Listing 3 changes the current directory to the System folder, and then runs a DOS dir command with the file specification *.com, which returns a directory listing of the files in that folder with a .com extension. On Windows XP, the command shell interpreter recognizes the && operator as a command separator, so you can place multiple commands on a single line. The >> operator redirects output into a named file. In this case, the code pipes the dir command results into the file dirOutput.txt in the path designated by the Application.StartupPath property. You can test the code in the sample form by clicking the Text ExitCode button.

Listing 3

Launching an invisible process and redirecting its output (frmProcessExamples)

Private Sub btnTestExitCode_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestExitCode.Click Dim myProcess As Process = New Process() Dim s As String Dim outfile As String = Application.StartupPath & _ \dirOutput.txt get the System path Dim sysFolder As String = _ System.Environment.GetFolderPath _ (Environment.SpecialFolder.System) set the file name and the command line args myProcess.StartInfo.FileName = cmd.exe myProcess.StartInfo.Arguments = /C cd & _ sysFolder & && dir *.com >> & Chr(34) & _ outfile & Chr(34) & && exit start the process in a hidden window myProcess.StartInfo.WindowStyle = _ ProcessWindowStyle.Hidden myProcess.StartInfo.CreateNoWindow = True myProcess.Start() if the process doesnt complete within 1 second, kill it myProcess.WaitForExit(1000) If Not myProcess.HasExited Then myProcess.Kill() End If

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

195

display exit time and exit code MessageBox.Show(The dir command window was & _ closed at: & myProcess.ExitTime & . & _ System.Environment.NewLine & Exit Code: & _ myProcess.ExitCode) myProcess.Close() End Sub

If the dir command is successful, the preceding code returns an ExitCode value of zero (0). To see an example of a non-zero ExitCode, append an X or some other character to the System folder path to make it invalid. That causes an error, and the ExitCode value will be different. Because a process with an error could potentially run forever, the code uses an overloaded WaitForExit method that accepts a number of milliseconds to wait before returning control to the launching program. The preceding code waits for one second before ending the launched process by calling the Kill method, which forces the process to exit. Check for the existence of the dirOutput.txt file in your applications startup (bin) directory to see the results.

Detecting When a Process Exits


In VB6, you could call the Win32 APIs GetModuleUsage() function to determine when the process ended. The .NET equivalent is to loop repeatedly after launching the process, checking the Process.HasExited property and calling the Application.DoEvents method to handle other events in your application until the process ends:
Do While Not myProcess.HasExited Application.DoEvents Loop

But the Process class gives you a cleaner way to determine when the process exitsthe process can raise an Exited event. To make this happen, you need to set the Process.EnableRaisingEvents property to True (by default, the property is False), and create an event handler. For example, the Test Exited Event button handler code assigns the ProcessExited event handler to the EnableRaisingEvents property (see Listing 4).

Listing 4

Testing the Process.Exited event (frmProcessExamples)

Private Sub btnTestExitedEvent_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestExitedEvent.Click Dim myProcess As Process = New Process() myProcess.StartInfo.FileName = sample.txt myProcess.EnableRaisingEvents = True

196

General .NET Topics

AddHandler myProcess.Exited, _ AddressOf Me.ProcessExited myProcess.Start() End Sub Friend Sub ProcessExited(ByVal sender As Object, _ ByVal e As System.EventArgs) Dim myProcess As Process = DirectCast(sender, Process) If Not myProcess Is Nothing Then MessageBox.Show(The process exited, raising & _ the Exited event at: & myProcess.ExitTime & _ . & System.Environment.NewLine & Exit Code: & _ myProcess.ExitCode) End If myProcess.Close() End Sub

One potential problem with both these methods is that if the launched process hangs or never exits, your application is stuck. One solution is to add a timer that fires periodically and checks to see if the launched application is still responding.

Controlling Process IO
Sometimes you want to go beyond a simple command line and send more complex input directly to a launched process. Similarly, piping the output to a file, as in the preceding example, is not always the best option. It many cases, its much more efficient to pipe the output directly back to your program. For programs that use StdIn, StdOut, and StdErr, such as console applications, you can override the defaults and provide a StreamWriter to write input and StreamReaders to read the StdOut and StdErr outputs. To do that, when you launch the process, you set the ProcessStartInfo objectsRedirect StandardInput, RedirectStandardOutput, and RedirectStandardError properties to True. Then, after launching the process, use the Process objects StandardInput, StandardOutput, and StandardError properties to assign the IO streams to the StreamReader and StreamWriter objects. One caveat: By default, the framework uses the Win32 ShellExecute function internally to launch processes (thats how it can automatically launch the appropriate applicationbased on the file association). But when you want to reassign the IO streams, you must set the ProcessStartInfo.UseShellExecute property to False before starting the process. Note that when you do that, either you must specify the full path to the file, or the file must reside in the environment path or in some other folder where Windows searches for files.

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

197

For example, the code in Listing 5 creates an invisible process window, retrieves a directory listing of the .com files in the System folder, and then displays the results in a MessageBox.

Listing 5

An example of redirecting process IO (frmProcessExamples)

Private Sub btnTestRedirectIO_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestRedirectIO.Click Dim myProcess As Process = New Process() Dim s As String retrieve the System path Dim sysFolder As String = _ System.Environment.GetFolderPath _ (Environment.SpecialFolder.System) set StartInfo properties myProcess.StartInfo.FileName = cmd.exe myProcess.StartInfo.UseShellExecute = False myProcess.StartInfo.CreateNoWindow = True myProcess.StartInfo.RedirectStandardInput = True myProcess.StartInfo.RedirectStandardOutput = True myProcess.StartInfo.RedirectStandardError = True myProcess.Start() set StandardInput to a stream Dim sIn As StreamWriter = myProcess.StandardInput sIn.AutoFlush = True set StandardOutput and StandardError to streams Dim sOut As StreamReader = myProcess.StandardOutput Dim sErr As StreamReader = myProcess.StandardError write the command to the StandardInput stream sIn.Write(dir & sysFolder & \*.com & System.Environment.NewLine) sIn.Write(exit & System.Environment.NewLine) read the output from the output stream s = sOut.ReadToEnd() If Not myProcess.HasExited Then myProcess.Kill() End If display window closed message MessageBox.Show(The dir command window was & _ closed at: & myProcess.ExitTime & . & _

198

General .NET Topics

System.Environment.NewLine & Exit Code: & _ myProcess.ExitCode) clean up sIn.Close() sOut.Close() sErr.Close() myProcess.Close() display results MessageBox.Show(s) End Sub

For programs that dont use StdIn, you can use the SendKeys method to input keystrokes. For example, the event handler for the Test SendKeys button click event launches Notepad and uses SendKeys to write some text (see Listing 6).

Listing 6

Using SendKeys to send keystrokes to a launched process (frmProcessExamples)

Private Sub btnTestSendkeys_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnTestSendkeys.Click create and initialize a process Dim myProcess As Process = New Process() myProcess.StartInfo.FileName = notepad myProcess.StartInfo.WindowStyle = _ ProcessWindowStyle.Normal set up Exited event handler myProcess.EnableRaisingEvents = True AddHandler myProcess.Exited, AddressOf Me.SendKeysTestExited set up a timer Timer1.Enabled = True Timer1.Interval = 3000 Timer1.Start() start the process Me.LaunchedProcess = myProcess myProcess.Start() wait until window is ready to process input myProcess.WaitForInputIdle(1000) If myProcess.Responding Then System.Windows.Forms.SendKeys.SendWait _ (This text was entered using the & _

Solution 13 Launching and Monitoring External Programs from VB.NET Applications

199

System.Windows.Forms.SendKeys method.) Else myProcess.Kill() End If End Sub Friend Sub SendKeysTestExited( _ ByVal sender As Object, _ ByVal e As System.EventArgs) Dim myProcess As Process = DirectCast(sender, Process) Timer1.Stop() If Not myProcess Is Nothing Then MessageBox.Show(Notepad was closed, raising & _ the Exited event at: & myProcess.ExitTime & _ . & System.Environment.NewLine & _ Exit Code: & myProcess.ExitCode) End If myProcess.Close() End Sub

You can send any keystroke using the SendKeys method, including the Alt, Ctrl, and Shift keys, which require special symbols; therefore, you can use SendKeysto save or load files, exit, or perform other menu-driven commands. However, the SendKeys method sends keys to the active window only (the one that has the focus), so it can cause problems if an application loses the focus during the process. Check the documentation topic Sendkeys class for more information. Be sure to allow the launched process enough time to create its main window and display before sending keystrokes. The Process.WaitForInputIdle method causes the launching application to wait until the launched process is in an idle state, waiting for user input. The parameter is an Integer timeout value, in milliseconds. In Listing 6, the launching program waits up to one second for the text editor to be ready for input. If by that time the launched program is not ready, the code tests to see if its responding before continuing; otherwise, it kills the process. For some systems or applications, you would want to set the parameter to a higher value, because not all processes launch as quickly as Notepad. If you omit the timeout parameter and the launched process never enters an idle state, your program could wait forever. To sum up, although the Shell function is still available via the Microsoft.VisualBasic namespace, the System.Diagnostics.Process class gives you more control over launching and controlling processes, detecting when they exit, and getting an exit code. By redirecting StdIn, StdOut, and StdErr, you can send and receive data from applications. Using the SendKeys method, you can send input to applications that dont use StdIn, and you can force them to save data to files, where you can later read the saved data from the launching application.

200

General .NET Topics

SOLUTION

14
SOLUTION

Build a Touch Utility with .NET


PROBLEM

Windows, even after all this time, is lacking some common utilities, such as a touch utility to alter file dates and times.

Build this console utility to safely alter file dates and times within your applications and simplify maintenance by delivering your executables and associated files with consistent file dates and times.

If youve ever been involved in maintaining applications in a large organization or maintaining a commercial application, youll appreciate how simple it is to determine whether people have the correct file versions if you set the file dates of applications and supporting files for each version to a consistent, easy-to-see date. Even if you arent building applications in such an environment, youve probably seen commercial programs that use this techniqueand youve probably wished you had a convenient way to set file dates and times. For example, rather than require people to confirm file sizes and dates that may range widely, its easier if all the version 1 files have the same date and time, all the version 2 files have a different date and time, and so forth. Utilities that change file dates and times are called touch utilities.

Altering File Dates and Times


In classic VB, building such a utility involved calling the Windows API, but VB.NETs new FileInfo class gives you direct access to all the dates and times associated with a file. Windows Explorer and other file-viewing applications usually show you only one date/time associated with a file, but Windows keeps two others that are less widely visible. Altogether, three dates are associated with every file: Last write time Last access time File creation time The last time the file was changed The last time the file was accessed The date/time the file was created

At the most basic level, you can perform touch operations easily by creating a new FileInfo object and setting its properties. The simplest way to use the FileInfo object is to import the System.IO namespace into your code:
Imports System.IO

Solution 14 Build a Touch Utility with .NET

201

After doing that, you can use a FileInfo object to alter dates. For example, to set all the dates for the file c:\junk.txt to the current date and time, you could write
At top of code file Imports System.IO within a class or module Dim fi As new FileInfo(c:\junk.txt) Dim aDate As New DateTime(Date.Now) fi.CreationTime = aDate fi.LastAccessTime = aDate fi.LastWriteTime = aDate

That approach is useful on an individual file basis, but in order for it to be widely useful, you must create a utility that lets you do several things:

Set any date field of any individual file in a directory to a specified date and time. Set any date field of any file in a directory that meets a wildcard specification to a specified date and time. Control whether the operation includes subdirectories.

Projects in the Sample Code


Because .NET lets you create console applications, you can create the utility and call it from a command prompt. The sample code for this solution consists of two separate projects: TouchUtility A class library project that contains the code to alter file dates and times

TouchConsole A console application project that lets you specify a path with an optional file specification or a specific file and a set of modifications you want to perform via the command line

The TouchUtility Project


The TouchUtility class is the heart of the project, and it does the real work. The console application wraps the class to provide a command-line interface that supplies file specifications and options to the TouchUtility class. To begin, create a new Class Library project. Name the project TouchUtility. Delete the class file that VS.NET creates by default, and then add a new class named Touch. The class has three public methods:

An overloaded touchFile method that accepts a string filename or FileInfo object, a date, and options An overloaded touchFiles method that accepts varying numbers of parameters specifying the file or path, the file specification, the date, and options, or a command line containing a text representation of the parameters A HelpString method that returns the syntax for using the Touch class from a command prompt

202

General .NET Topics

Because there are three different datesspecifically, the three time stamps defined in the FileInfo class (creation date, last access, and last write)the class also exposes a public enumeration called TouchUtilityOptions that contains a set of flag valuesvalues you can combine to apply more than one value to a single setting. To use an enumeration as a set of flags, add <FlagsAttribute> to the Enum definition:
<FlagsAttribute()> _ Public Enum TouchUtilityOptions As Integer SetCreationTime = 1 SetLastAccessTime = 2 SetLastWriteTime = 4 SetAllFileDateTimes = 8 IncludeSubdirectories = 16 End Enum

NOTE

You can shorten the attribute name to <Flags>.

The touchFile method changes the dates for a single file as specified via the options parameter, which contains one or more of the flag settings from the TouchUtilityOptions enumeration:
Public Overloads Function touchFile( _ ByVal aFile As String, _ ByVal aDate As String, _ ByVal options As TouchUtilityOptions) As Boolean Try Dim fi As FileInfo = New FileInfo(aFile) Dim dt As DateTime = DateTime.Parse(aDate) touchFile = setFileDates(fi, dt, options) Catch ex As Exception Throw ex End Try End Function

An otherwise identical overloaded touchFile method accepts a FileInfo object and a DateTime object rather than the two string arguments in the preceding code. To change dates and times for more than a single file, you use the overloaded touchFiles method. All the various overloaded touchFiles methods eventually call a private method named _touchFiles (see Listing 1) that obtains the set of filenames matching the specified parameters and then loops through them, setting the appropriate file dates/times. The simplest overloaded public version requires only a DirectoryInfo object as an argument:
Public Overloads Function touchFiles(ByVal aDir As _ DirectoryInfo) As ArrayList Try Return Me.touchFiles(aDir, *.*, Date.Now, _

Solution 14 Build a Touch Utility with .NET

203

TouchUtilityOptions.SetAllFileDateTimes) Catch ex As Exception Throw ex End Try End Function

When you pass a directory to this touchFiles method, the TouchUtility sets all the dates associated with the files in the specified directory to the current date and time. It does not act on files in subdirectories. Overloaded versions let you be more specific. For example, heres the most complex version:
Public Overloads Function touchFiles(ByVal aDir As _ DirectoryInfo, ByVal fileSpec As String, ByVal _ aDate As DateTime, ByVal options As _ TouchUtilityOptions) As ArrayList Dim alteredFiles As New ArrayList() Return _touchFiles(aDir, fileSpec, aDate, _ options, alteredFiles) End Function

When you use this version, you must specify the directory, a file specification, and a date. The options parameter contains the flags that control which file dates/times to set and whether to alter the files in subdirectories. The private _touchFiles method (see Listing 1) retrieves the matching file set and calls the private setFileDates method to change the files. The method retrieves the files matching the file specification and calls itself recursively as needed to obtain matching files in subdirectories if you specify the TouchUtilityOption.IncludeSubdirectories flag.

Listing 1

The _touchFiles method (TouchUtility.Touch.vb)

Private Function _touchFiles(ByVal directory As _ DirectoryInfo, ByVal filespec As String, ByVal _ aDate As DateTime, ByVal options As _ TouchUtilityOptions, ByVal alteredFiles As _ ArrayList) As ArrayList Dim files As FileInfo() Dim aFile As FileInfo Dim dirs As DirectoryInfo() Dim di As DirectoryInfo Try If (CInt(options) And _ options.IncludeSubdirectories) > 0 Then dirs = directory.GetDirectories() If Not dirs Is Nothing Then For Each di In dirs alteredFiles.AddRange(_touchFiles _

204

General .NET Topics

(di, filespec, aDate, options, _ alteredFiles)) Next End If End If files = directory.GetFiles(filespec) If Not files Is Nothing Then For Each aFile In files If setFileDates(aFile, aDate, _ options) Then alteredFiles.Add(aFile.FullName) End If Next End If Catch ex As Exception Throw ex End Try Return alteredFiles End Function

The private setFileDates method (see Listing 2) accepts a FileInfo object, a DateInfo object, and the set of options. It uses the bit flags in the options parameter to determine which file dates to alter.

Listing 2

The setFileDates method (TouchUtility.Touch.vb)

Private Function setFileDates(ByVal aFile As _ FileInfo, ByVal aDate As DateTime, _ ByVal options As TouchUtilityOptions) As Boolean Try If (options And _ TouchUtilityOptions.SetAllFileDateTimes) > 0 _ Or (options And _ TouchUtilityOptions.SetCreationTime) > 0 Then aFile.CreationTime = aDate End If If (options And _ TouchUtilityOptions.SetAllFileDateTimes) > 0 _ Or (options And _ TouchUtilityOptions.SetLastAccessTime) > 0 _ Then aFile.LastAccessTime = aDate End If If (options And _ TouchUtilityOptions.SetAllFileDateTimes) > 0 _ Or (options And _ TouchUtilityOptions.SetLastWriteTime) > 0 Then aFile.LastWriteTime = aDate

Solution 14 Build a Touch Utility with .NET

205

End If Return True Catch ex As Exception Throw ex Return False End Try End Function

Calling the Touch Utility from a Console Application


The TouchConsole is a small executable console application that wraps the TouchUtility class. The most interesting (and most complex) of its methods is the overloaded touchFiles method. The method accepts a command-line string that must contain a file or directory name and a date but may also contain characters that denote the available options. The options correspond to the TouchUtilityOptions enumeration. The application parses the command-line string and passes the arguments to the overloaded touchFiles method in the TouchUtility class: s w a c Includes subdirectories Sets the last write date Sets the last access date Sets the creation date

Figure 1 shows an example. FIGURE 1:


Sample command line for the TouchConsole utility

206

General .NET Topics

Unlike many command-line applications, the arguments for the TouchConsole are not case sensitive, but they are position sensitive. The command line must contain either a directory (with or without a file specification) or a specific filename, followed by a date that may include a time, followed by a list of options. For example, the following command line specifies a file, a date, and an option indicating that the utility should alter only the last write date:
c:\junk.txt 01/02/2002 12:00:00 AM -w

In contrast, the following command line alters the last write and last accessed dates of all the .doc files in the c:\temp directory and its subdirectories:
c:\temp\*.doc 01/02/2002 12:00:00 AM -wsa

The options themselves may or may not be separated by a hyphen (-) or forward slash (/). For example, the following command line is equivalent to the preceding version:
c:\temp\*.doc 01/02/2002 12:00:00 AM w s a

Because classic VB was unable to create console applications, command-line parsing was an unusual requirement, but .NET brings the need for command-line parsing to the forefront. Quite frankly, creating the command-line parser for this solution took far more time than creating the Touch utility itself and entailed writing very specific code that would be difficult to modify. Im not particularly happy with the results, because the code is extremely specific to this particular application. Nevertheless, parsing command lines is a common task, so you shouldnt have to write application-specific code to deal with it. Ill address the commandline parsing problem in Solution 15. The overloaded parseFiles(commandLine as String) method functions as the commandline parser in this project. This method accepts a string argument containing the command line. It first splits the command-line argument into a string array (sArgs) and then checks each item in the array, building an ArrayList (args) of valid arguments. Next, it cycles through the items in the ArrayList and creates the objects and values it needs as arguments for one of the other touchFiles methods (see Listing 3). Finally, it calls the most appropriate touchFiles method to alter the files.

Listing 3

The overloaded touchFiles method and the getArgs method (TouchUtility.Touch.vb)

Public Overloads Function touchFiles(ByVal commandLine _ As String) As String Dim args As ArrayList Dim arg As String = Dim path As DirectoryInfo Dim file As FileInfo Dim aDate As DateTime Dim isPath As Boolean = False Dim isFile As Boolean = False

Solution 14 Build a Touch Utility with .NET

207

Dim isFileSpec As Boolean = False Dim filespec As String Dim filename As String Dim options As Integer Dim sb As StringBuilder Dim alteredFiles As ArrayList Dim i As Integer args = getArgs(commandLine) For i = 0 To args.Count - 1 arg = CStr(args(i)).ToLower Select Case i Case 0 is there a filespec? If arg.IndexOf(*) > 0 Or _ arg.IndexOf(?) > 0 Then isFileSpec = True the portion after the last slash is the filespec filespec = arg.Substring _ (arg.LastIndexOf(\) + 1) path = New DirectoryInfo(arg.Substring(0, _ arg.LastIndexOf(\))) If Not path.Exists Then Throw New Exception _ (The specified path _ & or file is invalid. & NewLine & _ HelpString()) End If Else is it a file or path? file = New FileInfo(arg) If file.Exists Then If (file.Attributes And _ FileAttributes.Directory) <> 0 Then isPath = True path = New DirectoryInfo(arg) Else isFile = True filename = arg End If Else is it a path? path = New DirectoryInfo(arg) If Not path.Exists Then Throw New Exception _ (The specified path _ & or file is invalid. & _ NewLine & HelpString()) Else isPath = True End If

208

General .NET Topics

End If End If Case 1 if you got here, this is a valid date aDate = Date.Parse(arg) Case Else Select Case arg Case c options += _ TouchUtilityOptions.SetCreationTime Case w options += _ TouchUtilityOptions.SetLastWriteTime Case a options += _ TouchUtilityOptions.SetLastAccessTime Case s If isPath Or isFileSpec Then options += _ TouchUtilityOptions. _ IncludeSubdirectories End If Case Else Throw New Exception _ (Unrecognized command. & _ NewLine & HelpString()) End Select End Select Next If isPath = False And isFile = False And _ isFileSpec = False Then Return HelpString() End If If options = 0 Then options = TouchUtilityOptions.SetAllFileDateTimes End If If aDate = Date.MinValue Then aDate = Date.Now End If sb = New StringBuilder() If isPath Then alteredFiles = Me.touchFiles(path, *.*, aDate, _ CType(options, TouchUtilityOptions)) ElseIf isFileSpec Then alteredFiles = Me.touchFiles(path, filespec, _ aDate, CType(options, TouchUtilityOptions)) ElseIf isFile Then If Me.touchFile(file.FullName, aDate.ToString, _ CType(options, TouchUtilityOptions)) Then alteredFiles = New ArrayList() alteredFiles.Add(file.FullName) End If

Solution 14 Build a Touch Utility with .NET

209

End If For i = alteredFiles.Count - 1 To 0 Step -1 sb.Append(CStr(alteredFiles(i)) & NewLine) Next Return sb.ToString End Function Private Function getArgs(ByVal commandLine As String) _ As ArrayList Dim sargs() As String = Split(commandLine) Dim args As ArrayList = New ArrayList() Dim arg As String = Dim aChar As String Dim aDate As DateTime Dim aTime As DateTime Dim i As Integer Dim j As Integer Dim sTime As String Dim goodOpts As String = cwas If sargs.Length = 1 Then If sargs(0) = ? Then Throw New Exception(NewLine & HelpString()) End If End If If sargs.Length < 3 Then Throw New Exception(Invalid command. & NewLine _ & HelpString()) End If For i = 0 To sargs.Length - 1 arg = sargs(i).Trim If i = 0 Then path/file/filespec args.Add(arg) ElseIf i = 1 Then date Try aDate = New Date().Parse(arg) args.Add(aDate) Catch ex As Exception not a date Throw New Exception _ (The specified date is invalid. _ & NewLine & HelpString()) End Try ElseIf i = 2 Then might be a time, might be options Try aTime = New Date().Parse(arg) if that worked, set the date to the date and the time aDate = Date.Parse _ (aDate.ToShortDateString() & _

210

General .NET Topics

& aTime.ToLongTimeString()) if you got here, args(1) is already _ a valid date args(1) = aDate Catch ex As Exception not a time For j = 0 To arg.Length - 1 aChar = arg.Substring(j, 1).ToLower If goodOpts.IndexOf(aChar) > 0 Then args.Add(aChar) End If Next End Try ElseIf i >= 3 Then is it AM or PM? If arg.ToUpper = AM Or arg.ToUpper = PM Then Try strip the existing AM or PM from the time sTime = aDate.ToShortTimeString.Substring _ (0, aDate.ToShortTimeString.Length - 2) parse the date with the specified AM or PM string appended aDate = aDate.Parse( _ aDate.ToShortDateString & _ & sTime & & arg) if you got here, args(1) is already a valid date args(1) = aDate Catch Throw New Exception(Invalid date/time.) End Try Else For j = 0 To arg.Length - 1 aChar = arg.Substring(j, 1).ToLower If goodOpts.IndexOf(aChar) > 0 Then args.Add(aChar) End If Next End If End If Next Return args End Function

The overloaded version of touchFiles in Listing 3 uses a StringBuilder to reverse the ArrayList returned by the other touchFiles versions and returns the resulting string. That makes it very easy to call the TouchUtility from a console application and display the results. The TouchConsole sample project is a simple wrapper for the TouchUtility and illustrates how easy it is to use the TouchUtility class from a console application. Listing 4 shows the TouchConsole class code in its entirety.

Solution 14 Build a Touch Utility with .NET

211

Listing 4

The TouchUtility module code (TouchConsole.Module1.vb)

Imports TouchUtility Module TouchCommand Sub Main() Dim s As String Dim result As String Dim touch As New TouchUtility.Touch() s = Command() Use the following line to simplfy debugging. s = c:\temp\junk.txt 04/02/2002 03:14:14 PM Alternatively, you can set Command Line arguments from(the) Project Properties-->Configuration dialog If Trim$(s) = ? Then Console.Out.Write(touch.HelpString) Else Try result = touch.touchFiles(s) If result.Length > 0 Then Console.Out.WriteLine _ (Files Altered: & _ System.Environment.NewLine & result) Else Console.Out.WriteLine _ (No files altered) End If Catch ex As Exception Console.Out.WriteLine(ex.Message) End Try use the following line to prevent the console window from disappearing during development Console.ReadLine() End If End Sub End Module

Building a Touch GUI


Using the TouchUtility class from a Windows Forms application is even easier, because you can use the UI controls to reduce or eliminate the possibility of user input errors. Basically, you require users to select a file or folder, a file specification, and a date and time, and to check the file date/time modifications they want to make.

212

General .NET Topics

SOLUTION

15
SOLUTION

Parse and Validate Command-Line Parameters with VB.NET


PROBLEM

Now that I can build console applications with VB.NET, I also need to parse and validate command-line arguments.

Use this CommandLineParser assembly to define and validate commandline arguments and minimize the need to write custom parsing code.

While writing Solution 14, which describes a console application that sets file dates and times, I struggled with the problem of parsing and validating the command-line arguments that controlled the Touch utilitys actions. The utility had to be able to parse a command line containing filenames and specifications, dates, and application options. I ended up writing some custom code for that solution, even though it was obvious that parsing command lines is a generic operation. I did promise to address the problem generically, so thats what this solution doesit describe a set of classes for parsing and validating console application command lines.

Investigating Command Lines


The command line is the portion of the line you write in a console application that follows the name of the executable application. For example, in the TouchConsole application described in Solution 14, you can specify a single file, a folder, and a set of arguments for setting various dates, so the command line might look like this:
touch c:\temp\*.doc 01/02/2002 12:00:00 AM -w -s -a

The command line consists of the text that follows the name of the executable (touch in the preceding command line) and specifies that the Touch utility should act on files in the c:\temp folder that have a .doc extension as well as all its subfolders (-s), setting the files LastWriteDate (-w), and LastAccessDate (-a) to the date/time 01/02/2002 12:00:00 AM. As you begin to look more closely at command lines, youll find that they consist of one or more parts, often (but not always) separated by spaces. These parts are called command-line parameters. Some parameters are commonly called flags or options. Ill use the term flags in this solution to clearly differentiate flag-type entries from optional entriesparameters that the user may or may not enter. Flags are usually short parameters, one or two characters long,

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

213

prefaced with a hyphen or a forward slash. Not all flags are short, though; for example, one flag for the Windows ipconfig command accepts the argument /flushdns. Although application documentation usually lists flags individually, its a common convention that users can enter simple flags sequentially after a single hyphen or slash. For example, you could write the previous command line with the -w, -s, and -a options following a single hyphen:
c:\temp\*.doc 01/02/2002 12:00:00 AM -wsa

To complicate matters, youll often find flag parameters that take associated values; in other words, the parameter actually consists of two related values, usually a flag followed by a string. For example, many applications that process data let you specify an output file with the -o flag followed by the output filename:
-o c:\temp\somefile.txt

Its fairly easy to parse command lines where the parameters are all flags, or are all required, and where the program forces the user to enter command-line arguments in a specific order. Matching known flags in specific sequences is simple, as is the process of matching known parameters that must appear in a particular position. Such programs put the burden on the user to conform to the program. However, less draconian applications tend to work the other way aroundthey let users enter command-line parameters in any reasonable order simplifying input at the cost of more parsing logic. As applications grow more complex, they tend to accumulate parameters. The more complex the program, the more likely it is to have a large number of command-line options. As the number of options grows, the parsing process becomes commensurately more difficult. For example, a utility that copies files might let you enter the source and destination filenames in either order by using an option flag to identify which is which. In the following example, the -s identifies the source file while the -d identifies the destination. The program accepts command-line parameters that specify both, either, or none of the flags and adjusts its action accordingly. When the command line contains no flags, it assumes that the first filename is the source file and the second is the destination.
Examples: both flags are present -s c:\somefile.txt -d c:\myfiles\somefile.txt only the destination flag specified. the program assumes that the second filename is the source file. -d c:\myfiles\somefile.txt c:\somefile.txt assume source then destination c:\somefile.txt c:\myfiles\somefile.txt

214

General .NET Topics

You Cant Just Split Command Lines


In VB.NET, you can obtain the command line passed to VB.NET via the Command function. Many developers immediately assume that you can parse a command line by simply splitting the command line wherever spaces occur by using the String.Split method. That works fine for dates and times because the spaces delimit the date, time, and A.M./P.M. specifier. But you run into problems when you need to parse long filenames or other command parameters that might already contain spaces, and when youre parsing concatenated flags, which arent delimited by spaces. Instead, a command-line parser must be able to recognize quoted stringsincluding any embedded spacesas a single parameter, and be able to recognize flags even when theyre not entered separately:
assume source then destination note the spaces in the quoted filenames c:\some name.txt c:\myfiles\some name.txt

For some console applications, you may not know in advance exactly what information the user will enter, but you can enforce rules of nearly any complexity by using regular expressions.

Generic Parsing Guidelines


Now that you know the types of parameters that command lines contain, here are some generic guidelines for parsing command lines:

Command lines consist of zero or more entries separated by spaces. Entries with embedded spaces appear between double quotes. Some entries need to be strongly typedconverted to dates or times, or treated as filenamesbefore validation. Some entries are flags or options. They always begin with a hyphen or slash but can be combined behind one hyphen, without spaces. Flags may consist of multiple characters, such as -flag. Some entries are free-form text but must match a pattern, such as a file specification. Some entries are required, and some are optional. Some must follow or precede specific types of entries, such as a flag/value combination. Some entries must appear in a specific position; for others, the position doesnt matter. Building on these guidelines, a command-line parser must be able to: Split command lines into their component parts and recognize quoted strings. Differentiate flag entries from other entries and recognize flags even when they arent delimited by white space.

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

215

Enforce position, both absolute (the entry must appear at a specific index) and relative to some other entry (for example, an entry must follow -f or must appear before or after a date entry). Validate entered (and missing) parameters by checking that all required parameters exist, that entries with specific positional requirements are in the correct positions, that they follow or precede other entries as specified, and that each entry matches its specified type (date, filename), pattern (regular expression, file specification), or value.

Because users often make mistakes, a generic parser should also let developers handle errors. Mistakes may consist of missing data, where the user did not enter a required value; invalid data, such as an invalid file path or a malformed date; or extra data, such as unrecognized flag values. Developers can choose to ignore extra values in otherwise valid command lines or treat them as errors. The parser should return information for all three types of mistakes. Finally, the parser should not restrict developers; it should be flexible enough to perform only required tasks, such as simply splitting the command line into tokens and returning them, unaltered, so that developers can apply custom validation rules. While a parser that implements these rules may not be sufficient for every command line, it can probably meet most needs. More important, it should give you a good base for writing a more generalized version.

How the Command-Line Parser Works


The sample parser in this solution meets these guidelines. It consists of several classes in a CommandLineParse namespace. The CommandLineParser class (see Listing 2 at the end of this solution) controls the sequence of actions involved in parsing a command line and serves as a repository for both matched and unmatched parameters and for error messages. To set up the CommandLineParser, you populate its CommandLineEntryCollection, which is a strongly typed collection that holds the CommandLineEntry objects you define for your command-line parameters (see Listing 3). You create CommandLineEntry objects (see Listing 4) by calling the parsers CreateEntry method (see Listing 2). You must pass a CommandTypeEnum value to the function that specifies the type of data, and optionally, the value expected for each CommandTypeEntrya value from the CommandTypeEnum enumeration, shown here:
Public Enum CommandTypeEnum a file specification, such as *.txt Filespec = 1 a short date string, e.g. 08/12/2002 ShortDateString = 2

216

General .NET Topics

a long date string, e.g. 08/12/2002 12:00:01 AM LongDateString = 3 any string value Value = 4 text validated with a regular expression RegExpression = 5 a value treated as an single or multiple character option that must be preceded by / or - Flag = 6 a file that must already exist ExistingFile = 7 End Enum

These seven CommandTypeEnum values serve to make the CommandLineParser both useful and flexible. Its useful because it can recognize and validate common input parameter types, such as files and dates, thus eliminating most common command lineparsing code. Its flexible because you can use the Value type for any arbitrary value, or the RegExpression type to validate complex entries. To perform the parse, the CommandLineParser creates a Tokenizer class that splits the command line into its component parts. The Tokenizer returns a Tokens collection containing the individual parameters. The parser then passes the Tokens collection and its CommandLineEntryCollection to a TokenAssigner, which tries to assign each individual token to a matching CommandLineEntry object by looping each objects Value property through the collection setting. Setting the Value property causes the CommandLineEntry object to perform a first-level validation of the tokens by checking the type and settings for that particular CommandLineEntry object against the characteristics of the token. The CommandLineEntry objects reject tokens if they dont match the settings for that particular CommandLineEntry. The TokenAssigner returns an UnmatchedTokensCollection object that contains all the command-line parameters for which the TokenAssigner could not find a matching CommandLineEntry object. The TokenAssigner can return unmatched tokens even after a successful parse (see Figure 1). WARNING Dont confuse unmatched tokens with unmatched CommandLineEntry items. A successful parse does not necessarily populate every defined CommandLineEntry, because some entries may have their Required property set to False, and the command line may not contain a matching token for those entries.

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

217

FIGURE 1:
Overview of the parse operation

Calling Code Unmatched Tokens Command Line (String) CommandLineEntries

CommandLineParser

Errors

Tokenizer

TokenAssigner

Tokens

If the TokenAssigner completes without errors, the parser calls a FinalValidation method, which loops through the populated CommandLineEntryCollection performing a second-level validation that enforces the Required, RequiredPosition, MustFollow/MustPrecede, and MustAppearBefore/MustAppearAfter property settings. The FinalValidation method sets the parsers IsValid property that lets the developer know if the parse was successful. As implemented in the sample code, the parser ignores extra parameters entered by the user, but you can access them through the UnmatchedEntries property and treat them appropriately for your application.

Setting Up the Sample Parser


The sample project CommandLineParserTest included with the sample code (downloadable from Sybex at www.sybex.com) shows how to use the parser. First, add a reference to the CommandLineParse namespace to your test application and then create a CommandLineParser instance:
At the top of the file Imports CommandLineParse Dim parser As CommandLineParser parser = New CommandLineParser()

218

General .NET Topics

Next, populate the parsers CommandLineItemCollection by creating CommandLineItems and setting their properties. The sample CommandLineParserTest project uses the Setup CommandLineEntries method to populate the collection (see Listing 1). As you can see in the listing, the method creates two CommandLineItems. The first specifies a required Flag type entry (-f), and the second specifies a required ExistingFile type entry. For example, the command line -f c:\junk.txt would parse successfully as long as the c:\junk.txt file exists. In contrast, the parser would fail if the user enters a filename that doesnt exist, fails to enter both parameters, or enters them out of order.

Listing 1

The SetupCommandLineEntries method sets up a list of valid command-line items. (CommandLineParserTest, Module1.vb)

Sub SetupCommandLineEntries(ByVal parser _ As CommandLineParser) Dim anEntry As CommandLineEntry parser.Errors.Clear() parser.Entries.Clear() create a flag type entry that accepts a -f (file) flag, (meaning the next parameter is a file name), and is required anEntry = parser.CreateEntry _ (CommandLineParse.CommandTypeEnum.Flag, f) anEntry.Required = True parser.Entries.Add(anEntry) store the new Entry in a local reference for use with the next CommandLineEntrys MustFollow property. Dim fileEntry As CommandLineEntry fileEntry = anEntry now create am ExistingFile type entry that must follow the -f flag. anEntry = parser.CreateEntry _ (CommandTypeEnum.ExistingFile) anEntry.MustFollowEntry = fileEntry anEntry.Required = True parser.Entries.Add(anEntry) End Sub

In this scenario, the user must enter both the flag (-f) and an existing filename. The ExistingFile entry must follow the flag entry. After setting up the CommandLineItemCollection, you call the CommandLineParser.Parse method to initiate the parse and validation (see Listing 2).

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

219

The Parse method returns a Boolean value with the overall result of the parse operation:
If parser.Parse() Then Console.WriteLine(Successful parse) Console.WriteLine() Else Console.WriteLine(Parse failed) For Each sErr In parser.Errors Console.WriteLine(Reason: & sErr) Next Console.WriteLine() End If

Figure 2 shows the sample CommandLineParserTest application results after a successful request using the CommandLineItems described in the preceding code snippet and the command line -f c:\temp\junk.txt. In contrast, if you give the parser an invalid command line, such as -f c:\BadFile.txt, where the file doesnt exist (see Figure 3), or any other invalid parameters, such as -x c:\temp\junk.txt (see Figure 4), the Parse method returns False, and you can see the errors that accumulated during the parse operation as well as any unmatched parameters in the sample applications display. To use the parser, add a reference to the CommandLineParse namespace to your project, create a CommandLineParser instance, and then create CommandLineEntry instances for each possible entry type you want users to be able to enter on the command line. For each entry, you must minimally specify the CommandTypeEnum and pass a reference to the parser. FIGURE 2:
Successful results after entering the command line -f

c:\temp\junk.txt

220

General .NET Topics

FIGURE 3:
Results after a failed parse: a missing file

FIGURE 4:
Results after a failed parse: an invalid command-line flag

Extending the Sample Parser


You could alter or extend the sample parser in several ways. For example, you could define individual CommandLineEntry subclasses for each type and eliminate the long Case structure in the ValidateValue method. You could create Exception classes inherited from ApplicationException to simplify the process of checking the errors. That would also make it easier to remove the error messages from the code and put them in localizable resource files. You could add a Number type that would convert the string entries to a designated numeric type and format, using a Min and Max property to verify that the entry lies within a specific range. The Min and Max properties would be useful for date types as well.

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

221

Finally, as implemented, the parser doesnt fail immediately when it encounters a condition that causes an overall parse failure; instead, it simply adds error messages to the Errors collection. While that causes the parser to be slower when an error occurs early in the command line, it also gives developers the greatest possible amount of information about what the parser is doing. In addition, the sample code is not highly optimizedyou can probably find numerous ways to make it faster.

Listing 2

The CommandLineParser class (CommandLineParse.CommandLineParser.vb)

Public Class CommandLineParser Private mCommandLine As String = String.Empty Private mEntries As CommandLineEntryCollection Private mBadTokens As UnmatchedTokens = _ New UnmatchedTokens() Private mIsValid As Boolean = False Private mErrors As CommandLineParserErrors = _ New CommandLineParserErrors() Public Sub New() allow creation with no parameters mEntries = New CommandLineEntryCollection(Me) End Sub Public Sub New(ByVal commandline As String) create parser with existing command line mEntries = New CommandLineEntryCollection(Me) Me.CommandLine = commandline End Sub Public Property Entries() As _ CommandLineEntryCollection Get Return mEntries End Get Set(ByVal Value As CommandLineEntryCollection) mEntries = Value End Set End Property Public Function CreateEntry(ByVal EntryType As _ CommandTypeEnum) As CommandLineEntry Return New CommandLineEntry(EntryType, Me) End Function Public Function CreateEntry(ByVal EntryType As _ CommandTypeEnum, ByVal aCompareValue As String) _ As CommandLineEntry Return New CommandLineEntry(EntryType, Me,

222

General .NET Topics

aCompareValue) End Function Public ReadOnly Property Errors() As _ CommandLineParserErrors Get Return mErrors End Get End Property Public ReadOnly Property IsValid() As Boolean Get Return mIsValid End Get End Property Friend Sub SetValid(ByVal b As Boolean) mIsValid = b End Sub Public Property CommandLine() As String Get Return mCommandLine End Get Set(ByVal Value As String) mCommandLine = Value End Set End Property Public ReadOnly Property UnmatchedTokens() _ As UnmatchedTokens Get Return mBadTokens End Get End Property Private Sub SetUnmatchedTokens(ByVal badtokens _ As UnmatchedTokens) implemented as a method rather than a property because of the lack of scope differential in set/get properties mBadTokens = badtokens End Sub Private Function getFirstIndexOfCommandType( _ ByVal aCommandType As CommandTypeEnum) _ As Integer Dim anEntry As CommandLineEntry Dim i As Integer For i = 0 To mEntries.Count - 1

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

223

anEntry = mEntries.Item(i) If anEntry.CommandType = aCommandType Then Return i End If Next End Function Private Function getLastIndexOfCommandType( _ ByVal aCommandType As CommandTypeEnum) _ As Integer Dim anEntry As CommandLineEntry Dim i As Integer For i = mEntries.Count - 1 To 0 Step -1 anEntry = mEntries.Item(i) If anEntry.CommandType = aCommandType Then Return i End If Next End Function Public Sub PerformFinalValidation() Dim lastEntry As CommandLineEntry Dim nextEntry As CommandLineEntry Dim anEntry As CommandLineEntry Dim i As Integer = 0 Try For Each anEntry In mEntries i += 1 If i < mEntries.Count - 1 Then nextEntry = mEntries.Item(i + 1) End If If anEntry.Required And _ (anEntry.HasValue = False) Then anEntry.setValid(False) Me.Errors.Add(A required entry ( & _ mEntries.IndexOf(anEntry) & , & _ anEntry.CommandType.ToString & _ ) has no matching value.) Me.SetValid(False) End If If anEntry.RequiredPosition > 0 Then If anEntry.RequiredPosition <> i Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ with the RequiredPosition & _ property & anEntry.RequiredPosition.ToString _ & is not in the correct position.) Me.SetValid(False) End If

224

General .NET Topics

End If If anEntry.MustFollow > 0 And _ (Not lastEntry Is Nothing) Then If anEntry.MustFollow <> _ lastEntry.CommandType Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustFollow, does not & _ follow the correct type.) Me.SetValid(False) End If End If If Not anEntry.MustFollowEntry Is _ Nothing And (Not lastEntry Is _ Nothing) Then If Not anEntry.MustFollowEntry Is _ lastEntry Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustFollowEntry, does & _ not follow the specified entry.) Me.SetValid(False) End If End If If anEntry.MustPrecede > 0 And _ (Not nextEntry Is Nothing) Then If anEntry.MustPrecede <> _ nextEntry.CommandType Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustPrecede, does not & _ precede the correct type.) Me.SetValid(False) End If End If If Not anEntry.MustPrecedeEntry Is _ Nothing And (Not nextEntry Is _ Nothing) Then If Not anEntry.MustPrecedeEntry _ Is nextEntry Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustPrecedeEntry, does & _ not precede the specified entry.) Me.SetValid(False) End If End If

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

225

If anEntry.MustAppearAfter > 0 Then If mEntries.IndexOf(anEntry) < _ getFirstIndexOfCommandType _ (anEntry.MustAppearAfter) Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustAppearAfter, does & _ not appear after the specified & _ type.) Me.SetValid(False) End If End If If anEntry.MustAppearBefore > 0 Then If mEntries.IndexOf(anEntry) > _ getLastIndexOfCommandType _ (anEntry.MustAppearAfter) Then anEntry.setValid(False) Me.Errors.Add(The entry ( & _ mEntries.IndexOf(anEntry) & _ marked MustAppearBefore, does & _ not appear before the & _ specified type.) Me.SetValid(False) End If End If lastEntry = anEntry Next Catch ex As Exception Me.SetValid(False) Throw New ApplicationException(ex.Message + _ Error in PerformFinalValidation.) End Try the parse is valid if no errors have occurred If Me.Errors.Count = 0 Then Me.SetValid(True) End If End Sub Public Function Parse() As Boolean Dim tk As New Tokenizer() Dim tkAssigner As New TokenAssigner(Me) Dim tokens As TokenCollection Dim anEntry As CommandLineEntry If Me.CommandLine Is String.Empty Then If mEntries Is Nothing Then SetValid(True) Else For Each anEntry In Me.Entries If anEntry.Required = True Then Me.Errors.Add(The parse failed _ & because the command line is _

226

General .NET Topics

& an empty string, but required _ & entries were set.) SetValid(False) Exit For End If Next if you get here, there are no required entries SetValid(True) End If Else Try obtain a set of tokens by parsing the command line tokens = tk.Tokenize(Me.CommandLine) AssignTokens returns an UnmatchedTokens collection assigned to the local mBadTokens field via the SetUnmatchedTokens(method) SetUnmatchedTokens(tkAssigner.AssignTokens( _ tokens, Me.Entries)) check to ensure items are in their proper order and position, if set Me.PerformFinalValidation() Catch ex As Exception Me.Errors.Add(ex.Message) Throw ex End Try End If return the final result Return Me.IsValid End Function End Class

Listing 3

The CommandLineEntryCollection class (CommandLineParse.CommandLineEntryCollection.vb)

Option Strict On Imports System.Collections Public Class CommandLineEntryCollection Inherits CollectionBase Private mEntries As New ArrayList() Private mParser As CommandLineParser Friend Sub New(ByVal aParser As CommandLineParser) mParser = aParser

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

227

End Sub Public Function Add(ByVal anEntry As CommandLineEntry) _ As CommandLineEntry Me.List.Add(anEntry) Return anEntry End Function Public Property Parser() As CommandLineParser Get Return mParser End Get Set(ByVal Value As CommandLineParser) mParser = Value End Set End Property Public Sub Remove(ByVal anEntry As CommandLineEntry) Me.List.Remove(anEntry) End Sub Public ReadOnly Property Item( _ ByVal index As Integer) As CommandLineEntry Get Return DirectCast(Me.List.Item(index), _ CommandLineEntry) End Get End Property Public ReadOnly Property IndexOf( _ ByVal anEntry As CommandLineEntry) As Integer Get Return Me.List.IndexOf(anEntry) End Get End Property Public ReadOnly Property UnassignedEntries() _ As CommandLineEntryCollection Get Dim anEntry As CommandLineEntry Dim unassigned As New _ CommandLineEntryCollection(mParser) For Each anEntry In Me.List If anEntry.HasValue = False Then unassigned.Add(anEntry) End If Next Return unassigned End Get End Property End Class

228

General .NET Topics

Listing 4

Define valid command-line parameters with the CommandLineEntry class. (CommandLineParse.CommandLineEntry.vb)

Option Strict On Imports System.IO Imports System.Text Imports System.Text.RegularExpressions Public Class CommandLineEntry Private mRequired As Boolean = False Private mCommandType As CommandTypeEnum Private mRequiredPosition As Integer Private mMustFollow As CommandTypeEnum Private mMustFollowEntry As CommandLineEntry Private mMustPrecede As CommandTypeEnum Private mMustPrecedeEntry As CommandLineEntry Private mMustAppearAfter As CommandTypeEnum Private mMustAppearBefore As CommandTypeEnum Private mValue As String = String.Empty Private mIsValid As Boolean = False Private mIsCaseSensitive As Boolean = False Private mCompareValue As String = String.Empty Private mParser As CommandLineParser Public Sub New(ByVal aCommandType As CommandTypeEnum, _ ByVal parser As CommandLineParser) Me.CommandType = aCommandType mParser = parser End Sub Public Sub New(ByVal aCommandType As CommandTypeEnum, _ ByVal parser As CommandLineParser, _ ByVal aCompareValue As String) Me.CommandType = aCommandType Me.CompareValue = aCompareValue mParser = parser End Sub Public Sub New(ByVal aCommandType As CommandTypeEnum, _ ByVal parser As CommandLineParser, _ ByVal aCompareValue _ As String, ByVal aValue As String) Me.CommandType = aCommandType Me.CompareValue = aCompareValue Me.Value = aValue mParser = parser End Sub Public Property Required() As Boolean Get Return Me.mRequired End Get Set(ByVal Value As Boolean) Me.mRequired = Value End Set

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

229

End Property Public Property CommandType() As CommandTypeEnum Get Return Me.mCommandType End Get Set(ByVal Value As CommandTypeEnum) Me.mCommandType = Value End Set End Property Public Property MustFollow() As CommandTypeEnum Get Return Me.mMustFollow End Get Set(ByVal Value As CommandTypeEnum) Me.mMustFollow = Value End Set End Property Public Property MustFollowEntry() As CommandLineEntry Get Return Me.mMustFollowEntry End Get Set(ByVal Value As CommandLineEntry) Me.mMustFollowEntry = Value End Set End Property Public Property IsCaseSensitive() As Boolean Get Return Me.mIsCaseSensitive End Get Set(ByVal Value As Boolean) Me.mIsCaseSensitive = Value End Set End Property Public Property MustPrecede() As CommandTypeEnum Get Return Me.mMustPrecede End Get Set(ByVal Value As CommandTypeEnum) Me.mMustPrecede = Value End Set End Property Public Property MustPrecedeEntry() As CommandLineEntry Get Return Me.mMustPrecedeEntry End Get Set(ByVal Value As CommandLineEntry) Me.mMustPrecedeEntry = Value End Set End Property Public Property MustAppearAfter() As CommandTypeEnum Get

230

General .NET Topics

Return Me.mMustAppearAfter End Get Set(ByVal Value As CommandTypeEnum) Me.mMustAppearAfter = Value End Set End Property Public Property MustAppearBefore() As CommandTypeEnum Get Return Me.mMustAppearBefore End Get Set(ByVal Value As CommandTypeEnum) Me.mMustAppearBefore = Value End Set End Property Public Property RequiredPosition() As Integer Get Return Me.mRequiredPosition End Get Set(ByVal Value As Integer) Me.mRequiredPosition = Value End Set End Property Public ReadOnly Property HasValue() As Boolean Get Return Not (mValue Is String.Empty) End Get End Property Public Property [Value]() As String Get Return mValue End Get Set(ByVal Value As String) If Not validateValue(Value) Then Dim msg As String msg = The value & Value & _ is not valid for the command type & _ Me.CommandType.ToString() If Not Me.CompareValue Is String.Empty Then msg += and the Compare Value & _ Me.CompareValue & . End If Throw New ApplicationException(msg) Else mValue = Value End If End Set End Property Public Property CompareValue() As String Get Return mCompareValue End Get

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

231

Set(ByVal Value As String) mCompareValue = Value End Set End Property Private Function validateValue(ByVal aValue As String) _ As Boolean Select Case Me.CommandType Case CommandTypeEnum.ExistingFile Dim fi As New FileInfo(aValue) If fi.Exists Then If Me.CompareValue = String.Empty Then setValid(True) Return True Else If Me.CompareValue.ToLower <> _ aValue.ToLower Then setValid(False) Return False Else setValid(True) Return True End If End If Else setValid(False) Return False End If Case CommandTypeEnum.Filespec the parameter must be a well-formed file name Dim fsv As New FileSpecValidator() Try If fsv.Validate(aValue, Me.CompareValue, _ Me.IsCaseSensitive) Then setValid(True) Return True Else setValid(False) Return False End If Catch ex As Exception mParser.Errors.Add _ (FileSpec validation failed. & _ ex.Message) If Not ex.InnerException Is Nothing Then mParser.Errors.Add _ (ex.InnerException.Message) End If setValid(False) Return False End Try

232

General .NET Topics

Case CommandTypeEnum.Flag If String.Compare(Me.CompareValue, aValue, _ Me.IsCaseSensitive) = 0 Then setValid(True) Return True Else setValid(False) Return False End If Case CommandTypeEnum.LongDateString Try Dim aDate As DateTime = _ Date.ParseExact(aValue, _ System.Globalization.DateTimeFormatInfo. _ CurrentInfo.LongDatePattern, _ System.Globalization.DateTimeFormatInfo. _ CurrentInfo) setValid(True) Return True Catch ex As Exception setValid(False) Return False End Try Case CommandTypeEnum.RegExpression Try Dim regOptions As RegexOptions If Me.IsCaseSensitive Then regOptions = RegexOptions.IgnoreCase Else regOptions = RegexOptions.None End If Try If Regex.IsMatch(aValue, _ Me.CompareValue, regOptions) Then setValid(True) Return True End If Catch ex As Exception invalid value setValid(False) Return False End Try Catch ex As Exception invalid reg exp Throw ex End Try Case CommandTypeEnum.ShortDateString Try Dim aDate As DateTime = _ Date.ParseExact(aValue, _ System.Globalization.DateTimeFormatInfo. _

Solution 15 Parse and Validate Command-Line Parameters with VB.NET

233

CurrentInfo.ShortDatePattern, _ System.Globalization. _ DateTimeFormatInfo.CurrentInfo) setValid(True) Return True Catch ex As Exception invalid date setValid(False) Return False End Try Case CommandTypeEnum.Value make sure theres a value If aValue.Length > 0 Then if theres a comparevalue, compare it to the value If Not Me.CompareValue Is String.Empty Then If Me.CompareValue.ToLower = _ aValue.ToLower Then setValid(True) Return True End If Else theres no compareValue, set valid for any value except an empty string setValid(True) Return True End If Else setValid(False) Return False End If End Select End Function Public ReadOnly Property IsValid() As Boolean Get Return mIsValid End Get End Property Friend Sub setValid(ByVal b As Boolean) mIsValid = b End Sub Public Overrides Function ToString() As String Dim s As String Dim sb As New StringBuilder(1000) Dim newLine As String = System.Environment.NewLine sb.Append(CommandLineEntry & newLine) sb.Append(CommandType: & System.Enum.GetName(Me.CommandType.GetType, _ Me.CommandType) & newLine) sb.Append(CompareValue: ) If Me.CompareValue Is String.Empty Then

234

General .NET Topics

sb.Append(Not Set & newLine) Else sb.Append(Me.CompareValue & newLine) End If sb.Append(Value: ) If Me.HasValue = False Then sb.Append(Not Set & newLine) Else sb.Append(Me.Value & newLine) End If even more info, if you want it sb.Append(IsCaseSensitive: & _ Me.IsCaseSensitive.ToString & newLine) sb.Append(Required: & _ Me.Required.ToString & newLine) sb.Append(RequiredPosition: & _ Me.RequiredPosition.ToString & newLine) sb.Append(Valid: & Me.IsValid & newLine) Return sb.ToString End Function End Class

SOLUTION

16
SOLUTION

Monitor Data and Files with a Windows Service


PROBLEM

I need to monitor a folder and process files whenever theyre created or modified. How can I ensure that the application I create for this purpose is always running?

Using standard applications to monitor file changes is not ideal, precisely because its difficult to know that theyre running. Instead, use a Windows service and .NETs new FileSystemWatcher component to build a robust solution.

Large organizations frequently download flat files to branch locations every night. The branch locations read these files and use them to update local databases with the data. The files dont appear all at once, nor is the time the download completes totally predictable; therefore, the branch locations use applications to monitor the target location. These applications loop continuously, waiting for files to appear. They then process the files themselves, or fire other applications to process the data.

Solution 16 Monitor Data and Files with a Windows Service

235

A similar situation occurs when remote (often-disconnected) salespeople create offline orders and then use a Web application to upload the order files to a central location. These order files can appear at any time but must be processed immediately. Developers write monitoring applications to watch for and process the order files. Many of these monitoring applications arent Windows services; therefore, administrators must use the AT command to schedule and launch the applications, put the applications in a Startup group, or rely on human beings to ensure that theyre running. None of these are good options, but Windows has a built-in application type that meets these needsa Windows service. Before .NET, you had to use C or C++ to build true Windows services, but now you can build Windows services in any .NET language. In addition, .NET ships with an interesting new component, the FileSystemWatcher, which wraps some Windows API functionality to provide an easy-to-use component that can monitor a directory or hierarchical set of directories for changes. All by itself, this component provides developers with the ability to replace those (probably thousands upon thousands) of little applications that simply spin in the background, monitoring a directory with a more robust and efficient solution. Together, a Windows service and the FileSystemWatcher provide a potent new way to monitor directories and take action when specific file events occur. In this solution, youll build two projects that work together to show how you can monitor a folder and process files to provide a constantly updated report.

Building a Windows Service


Visual Studio .NET has a Windows Service project type and provides a utility that installs and uninstalls service applications. It also offers a ServiceController component that lets you control a service. When you select the Windows Service project type, Visual Studio creates a class (named Service1 by default) that inherits from System.ServiceProcess.ServiceBase, a class that provides the basic Windows service functionality. All you need to add is code for handling special needs when your service starts, stops, pauses, or resumes, and the custom code for performing your services tasks. The service in our sample application, called Solution16Service, monitors a specific folder for two types of file events: new files (when a files Created timestamp changes) and altered files (when the LastWrite timestamp changes). Whenever the service detects a new or altered file, it: 1. Attempts to open the file. It does this because files are created and then written to; in other words, just because a file appears doesnt mean its complete. Files uploaded

236

General .NET Topics

via the Internet, in particular, sometimes take a considerable length of time to complete. The system writes data to the file until the file is complete, and during that time, you cant open the file with an exclusive lock. You can use that fact to perform a check for each event. When you can acquire an exclusive lock, that means the file is complete. 2. Moves the file to another folder for processing, renaming it if necessary to avoid overwriting any existing file with the same name. 3. Writes a log entry describing the move. 4. Opens the moved file and counts the words, keeping a total for each unique word in every uploaded file. The service can provide this information at any time for use in a word-frequency report. You can control which folder the service monitors by altering its application configuration file, shown in Listing 1.

Listing 1

The Solution16Services application configuration file (App.config)

<?xml version=1.0 encoding=utf-8 ?> <configuration> <appSettings> <add key=folder value=drive:\pathOfFolderToMonitor/> </appSettings> </configuration>

NOTE

Change the value attribute of the <add> tag to a valid drive and path on your machine.

The services configuration file has a single <add> element in the <appSettings> section whose value attribute contains the complete path to the directory the service should monitor. When you test the application, replace the generic drive:\pathOfFolderToMonitor value with a valid path. The service reads the configuration file on startup to discover which path to monitor, which lets administrators control the monitored folder by changing the path in the services configuration file. It uses a FileSystemWatcher instance (the fsw variable in Listing 2) to perform the monitoring task. To find out which directory to watch, it overrides the ServiceBase.OnStart method, as shown in Listing 2. NOTE
The args() parameter may contain startup parameters sent by the system, but these parameters are not used in the Solution16Service.

Solution 16 Monitor Data and Files with a Windows Service

237

Listing 2

The overridden OnStart method (Solution16Service.vb)

Protected Overrides Sub OnStart(ByVal args() As String) Me.LogMessage(Solution16Service starting) Me.WriteConfigPath() raise changed events for created or changed files fsw.NotifyFilter = NotifyFilters.LastWrite Or _ NotifyFilters.CreationTime Me.ReadAppConfig(True) End Sub

The OnStart method calls the WriteConfigPath method (see Listing 3) to write the services configuration file path to the Registry, and then calls the ReadAppConfig method to set the FileSystemWatchers folder (see Listing 4). It passes a Boolean True value to indicate that the service is just starting.

Listing 3

The WriteConfigPath method (Solution16Service.vb)

Private Sub WriteConfigPath() Try writes the path to the configuration file for this service to the registry Dim key As Microsoft.Win32.RegistryKey key = Microsoft.Win32.Registry. _ LocalMachine.CreateSubKey(SOFTWARE\ & _ TenMinuteSolutions\Solution16Service) If key Is Nothing Then Me.LogMessage(Could not open registry key) End If key.SetValue(ServiceConfigurationPath, _ [Assembly].GetExecutingAssembly. _ Location & .config) key.Close() Catch ex As Exception Me.LogMessage(Unable to write the service & _ path file to the Registry. & ex.Message) End Try End Sub

Listing 4

The ReadAppConfig method (Solution16Service.vb)

Private Sub ReadAppConfig(ByVal starting As Boolean) stop the watcher fsw.EnableRaisingEvents = False get the folder to watch

238

General .NET Topics

Dim aPath As String When service first starts If starting Then read application configuration file using the read-only configuration methods aPath = Configuration.ConfigurationSettings. _ AppSettings.Get(folder) Else every time other than first start read the application config file using XML Dim doc As New XmlDocument Me.LogMessage([Assembly].GetExecutingAssembly. _ Location) Try load the config file doc.Load([Assembly].GetExecutingAssembly. _ Location & .config) get the <add> node with the key=folder attribute Dim n As XmlElement = CType( _ doc.SelectSingleNode( _ configuration/appSettings/add[@key=folder]), _ XmlElement) If Not n Is Nothing Then aPath = n.GetAttribute(value) End If Catch ex As Exception Me.LogMessage(ex.Message) Return End Try End If If aPath Is Nothing OrElse aPath Is String.Empty Then Me.LogMessage(Could not read the path from & _ the configuration file.) Return End If if the path doesnt exist, create it If Not Directory.Exists(aPath) Then Directory.CreateDirectory(aPath) End If set the watcher path fsw.Path = aPath create the moved directory, if necessary If Not Directory.Exists(fsw.Path & \moved) Then

Solution 16 Monitor Data and Files with a Windows Service

239

Directory.CreateDirectory(fsw.Path & \moved) End If start the watcher fsw.EnableRaisingEvents = True Me.LogMessage(fsw.path= & fsw.Path & & _ fsw.watching events = & fsw.EnableRaisingEvents) End Sub

The method first stops the FileSystemWatcher from raising any new events by setting its
EnableRaisingEvents method to False. Then it reads the path from the configuration file.

The method contains two ways to read the configuration file. Which one runs depends on the value of the Boolean starting parameter. When the service first starts (True), you can read the path that will be monitored from the configuration file using the built-in read-only ConfigurationSettings.AppSettings.Get method. Then, the ReadAppConfig method reads the path from the configuration file; creates the path if it doesnt already exist; sets the FileSystemWatcher.Path property to the specified folder; and if necessary, creates a subdirectory in that folder named moved, which will receive files posted by users. The second condition occurs when the application configuration file has been changed after the service has already started. In that case, other code in the service calls the ReadAppConfig method with a starting parameter value of False. This logic branch is required because you cant use the ConfigurationSettings.AppSettings.Get method to detect values changed in a configuration file while an application is running. This is because the application caches any values already read; therefore, reading a value from the configuration file using the ConfigurationSettings.AppSettings.Get method doesnt actually read the file. Instead, the method returns the original cached value, not the changed value. To work around this problem, you can read the file into an XmlDocument object and use an XPath query to find the appropriate node value. Youll see more about how the configuration file is altered later in this solution. After running the OnStart method, the service is ready to watch for file changes in the specified directory. It will raise an event only when a files Created or LastWrite timestamp changes. Next, you need the code to process the files. NOTE
Bear in mind that it doesnt really matter, for the purposes of this solution, exactly what the code does; its included primarily to help you understand how and when the sample application processes the files so that you can substitute your own processing for the example code.

240

General .NET Topics

Every time the FileSystemWatcher detects a change, it fires a Changed event, which includes a FileSystemEventArgs parameter. You can use the FileSystemEventArgs object to discern exactly what event occurred, but for this solution, it doesnt matterthe code performs the same sequence of actions no matter which file change type caused the event. Listing 5 shows the code.

Listing 5

The FileSystemWatcher.Changed event handler (Solution16Service.vb)

Private Sub fsw_Changed(ByVal sender As Object, _ ByVal e As System.IO.FileSystemEventArgs) _ Handles fsw.Changed Dim moveFile As Boolean = False Me.LogMessage(Got file changed event for file _ & e.FullPath) Dim span As New TimeSpan(TimeSpan.TicksPerMinute) Try try to open the file exclusively if the file upload isnt complete, youll get an error Dim fs As FileStream = New FileStream( _ e.FullPath, FileMode.Open, FileAccess.Read, _ FileShare.None) fs.Close() you can move this file moveFile = True Catch ex As Exception Me.LogMessage(ex.GetType.ToString) You cant use the file until its free. End Try can you move this file? If moveFile Then Me.LogMessage(Moving file & _ Path.GetFileName(e.FullPath)) Try create a new file name Dim newFilename As String = fsw.Path & _ \moved\ & Path.GetFileName(e.FullPath) Dim i As Integer = 1 keep incrementing a number and appending it to the filename if the target file already exists Do While File.Exists(newFilename) newFilename = fsw.Path & \moved\ & _ Path.GetFileNameWithoutExtension( _ e.FullPath) & i.ToString & _

Solution 16 Monitor Data and Files with a Windows Service

241

Path.GetExtension(e.FullPath) i += 1 Loop move the file File.Move(e.FullPath, newFilename) move was successful, analyze the file Me.AnalyzeWords(newFilename) Catch ex As Exception some error occurred; log the exeception not much you can do here except write an application log entry Me.LogMessage(ex.GetType.ToString) End Try End If End Sub

The code in Listing 5 tries to open the file that changed. If it can open the file, then the file is complete. If the Changed event handler cannot open the file, it assumes that some other process has the file open. That condition occurs during a file upload, when the file has been created but is not complete. When the file is complete, the service can open it without an error, so it immediately closes the file, moves it, and calls the AnalyzeWords method, which counts the words in the file for the report. Listing 6 shows the AnalyzeWords method, along with the ReadLine method it calls, which reads the next line from a StreamReader.

Listing 6

The AnalyzeWords and ReadLine methods (Solution16Service.vb)

Private Sub AnalyzeWords(ByVal aFilename As String) open the file Dim sr As New StreamReader(New FileStream( _ aFilename, FileMode.Open, FileAccess.Read, _ FileShare.ReadWrite)) read a line Do While sr.Peek <> -1 Dim aLine As String = ReadLine(sr) If Not aLine Is Nothing Then Dim tokens As String() = aLine.Split( c) If Not tokens Is Nothing Then For Each aWord As String In tokens If Words.ContainsKey(aWord) Then Words(aWord) = CType(Words(aWord), _ Integer) + 1 Else Words.Add(aWord, 1) End If

242

General .NET Topics

Next End If End If Loop End Sub Private Function ReadLine(ByVal sr As StreamReader) _ As String Dim s As String = sr.ReadLine Dim tmp As String Do While sr.Peek <> -1 AndAlso _ s.Chars(s.Length - 1) = -c s = s & sr.ReadLine Loop Return s End Function Private Sub AnalyzeWords(ByVal aFilename As String) open the file Dim sr As New StreamReader(New FileStream( _ aFilename, FileMode.Open, FileAccess.Read, _ FileShare.ReadWrite)) while not at end of stream Do While sr.Peek <> -1 read a line Dim aLine As String = ReadLine(sr) did you read some text? If Not aLine Is Nothing Then split the line on the space character Dim tokens As String() = aLine.Split( c) if there were any words in the line If Not tokens Is Nothing Then count the words For Each aWord As String In tokens do you have this word? If Words.ContainsKey(aWord) Then increment the count Words(aWord) = CType(Words(aWord), _ Integer) + 1 Else add the word and set count to 1 Words.Add(aWord, 1) End If Next End If End If Loop

Solution 16 Monitor Data and Files with a Windows Service

243

End Sub read a line from a StreamReader Private Function ReadLine(ByVal sr As StreamReader) _ As String Dim s As String = sr.ReadLine Dim tmp As String read while not at end of stream and while the current line ends in a hyphen Do While sr.Peek <> -1 AndAlso _ s.Chars(s.Length - 1) = -c s = s & sr.ReadLine Loop return the line read Return s End Function

The end result of the AnalyzeWords method is to fill a Hashtable (the class-level Words variable) with the count of each unique word encountered in every file posted to the folder.

Installing a Windows Service


Unlike with most .NET applications, you have to perform a series of special actions to install the service; this process registers the service with the Services application. Follow these steps to create an installable service: 1. In the integrated development environment (IDE), display your service in design mode by double-clicking on the service module in the Solution Explorer. In design mode, your service should look like Figure 1. FIGURE 1:
A Windows service in design mode

244

General .NET Topics

FIGURE 2:
The ProcessInstaller in design mode

2. With the service in design mode, youll see a link at the bottom of the Properties window called Add Installer. Click the link to add a ProjectInstaller module to your solution. The ProjectInstaller has two components: a ServiceProcessInstaller and a ServiceInstaller (see Figure 2). Theyll be named ServiceProcessInstaller1 and ServiceInstaller1 by default. Accept the default names. 3. Click on the ServiceProcessInstaller1 component and set the Account property value in the Properties window to LocalSystem. The Account property controls the account under which the service runs. 4. Click on the ServiceInstaller1 component and set both the ServiceName and the DisplayName property values to Solution16Service. These properties control the name of the service and the name displayed in the Services application, respectively. 5. You can change the StartType property value if you wish, but for this solution, leave it set to Manual, which means the service does not start automatically when Windows starts. Other possible values are Automatic, which means the service does start when Windows starts, and Disabled, which means the service wont start at all. NOTE
Compile the service before you continue. Fix any errors that occur.

The .NET Framework ships with a console application, called InstallUtil.exe, that installs services. By default, youll find it in the C:\WINDOWS\Microsoft.NET\Framework\ <version number> folder. To install the service, open a command window, change to the directory containing the installUtil.exe application (or open a Visual Studio .NET 2003 command prompt), and then run installUtil, providing the full path and filename of the service assembly as a parameterwhich, after you compile the service, is the Solution16Service.exe file in your

Solution 16 Monitor Data and Files with a Windows Service

245

projects bin folder. For example, if your project folder is c:\myProjects\ Solution16Service, youd use the following command (all on one line):
installUtil c:\myProjects\Solution16Service\bin\Solution16Service.exe

If the installation succeeds, youll see output similar to that shown in Figure 3; otherwise, youll see error messages. WARNING If you have more than one copy of the .NET Framework installed, be sure to run the correct version of the installUtil.exe application.

After the service installs, open its application configuration file (Solution16Service.exe .config) and set the value attribute to control which folder the service should monitor. Modify the <add key=folder value=your path here /> tag to set the folder. Save your changes. Finally, open your Administrative Tools and launch the Services application. If everything worked properly, the Solution16Service item will appear in the services list (see Figure 4). Select the service and click the Start link to start the service. FIGURE 3:
Sample output from the

installUtil.exe
utility

246

General .NET Topics

FIGURE 4:
The Solution16Service item appears in the services list.

The service logs status and error messages to the Application event log. You can view the event log to ensure that the service started properly and that the FileSystemWatcher is monitoring the proper folder. Services inherit a Boolean AutoLog property, which, when True, causes the service to automatically log messages to the Application log on start, stop, pause, continue, or when the service executes a custom command. This service logs additional information using an EventLog component via the LogMessage method, shown in Listing 7.

Listing 7

The LogMessage method (Solution16Service.vb)

Private Sub LogMessage(ByVal msg As String) write a string to the application event log Me.EventLog1.WriteEntry(msg) End Sub

If you look at the Application event log after starting the service, youll see several messages that can help you know that the service is executing properly.

Communicating with a Running Service


As you saw in the preceding section, you can combine a service and a FileSystemWatcher to monitor a folder for files and process them whenever a new file is posted to that folder. However, the Words variable itself is an in-memory class variable; in other words, only the service has access to it. Thats fine if you simply want to log actions, but in this case, you want to be able to create a report based on the data in the Words Hashtableand you want to cause the

Solution 16 Monitor Data and Files with a Windows Service

247

service to create or refresh that report on demand. In addition, its convenient to change the directory the service monitors without going through the rather involved process of shutting down the service, manually editing the path in the services application configuration file, and then restarting the service. Finally, you want to have the capability of clearing the report at any time. Therefore, you need to be able to control the service from some other application. Services can receive custom messages. Each message consists of a single integer, restricted to a value between 128 and 256 (values below 128 are reserved by Windows), which the service must translate into an appropriate action. Whenever the service receives a custom message, it fires the OnCustomCommand event. The Solution16Service responds to three different messages, shown in Table 1.
TA B L E 1 : The Three Solution16Service Actions

Action
CHANGE_WATCHED_FOLDER CREATE_REPORT CLEAR_REPORT

Value
150 160 170

Description
Causes the service to reread the application configuration file Causes the service to write a text file containing the current words and word counts Clears the Words Hashtable (thus clearing the report)

The OnCustomCommand event for the Solution16Service uses a Select Case structure to handle each message (see Listing 8).

Listing 8

The OnCustomCommand event handler (Solution16Service.vb)

Protected Overrides Sub OnCustomCommand( _ ByVal command As Integer) Me.LogMessage(Received Custom Command & _ command.ToString) Select Case command Case 150 reread app.config file Me.ReadAppConfig(False) Case 160 write report file If Not writingReportFile Then Me.WriteReportFile() End If Case 170 clear the Words collection Words.Clear() End Select Me.LogMessage(Completed Custom Command & _ command.ToString) End Sub

248

General .NET Topics

Youve seen the ReadAppConfig method (Listing 4), and clearing the Words Hashtable needs no explanation. When the service receives the CREATE_REPORT (160) message, it calls the WriteReportFile method, which writes the data stored in the Hashtable to a disk file (see Listing 9).

Listing 9

The WriteReportFile method, which writes a report file (Solution16Service.vb)

Public Sub WriteReportFile() open a file writingReportFile = True Me.LogMessage(Writing report) Dim reportFilename As String = fsw.Path & _ \moved\report.txt Dim sw As StreamWriter = New StreamWriter( _ New FileStream(reportFilename, FileMode.Create, _ FileAccess.Write, FileShare.None)) If Words.Count = 0 Then sw.WriteLine(No words available.) Else For Each aWord As String In Words.Keys sw.WriteLine(aWord & = & CType(Words(aWord), _ Integer).ToString) Next End If sw.Close() writingReportFile = False End Sub

The application that sent the CREATE_REPORT method can then display the report.

Interacting with a Service


The sample code contains a second project, named Solution16, which consists of a single form that lets you set the directory monitored by the Solution16Service, copy text files to the monitored directory (simulating a file upload), create a report, or clear the report data. The Solution16 project performs these actions by communicating with the running service via a ServiceController component. Each ServiceController instance allows you to control a single service, letting you start, pause, stop, or send custom commands to the associated Windows service from another application. The sample form uses a ServiceController to control the Solution16Service. You can drag a ServiceController component from the ToolBox to your form. When you do that in design mode, a ServiceController component icon appears in the nonvisual component area. Set the ServiceController.ServiceName property to the name of the service you want to controlin this case, Service16Service.

Solution 16 Monitor Data and Files with a Windows Service

249

After doing that, you can issue standard stop, start, pause, and resume commands from code. For example, the Start method attempts to start the service and the Stop method attempts to stop it. For this solution though, the built-in commands alone dont suffice because theyre generic. Fortunately, you can also send custom commands to a service. However, as youve already noticed, the only thing you can send is an integer number. The ServiceController.ExecuteCommand takes one integer parameter (whose value must be between 128 and 256) and corresponds to one of the custom message values understood by the service. For example, you can change the directory the service monitors by clicking the Browse button on the form and selecting a folder. When you do that, the button Click event handler code modifies the services application configuration file, which it finds by reading the Registry key written by the service on startup with the WriteConfigPath method. After modifying the configuration file, it sends the CHANGE_WATCHED_FOLDER (150) message to the service using the ServiceController.ExecuteCommand method (see Listing 10).

Listing 10

Changing the monitored directory (Form1.vb)

Private Sub btnBrowse_Click( _ ByVal sender As System.Object, _ ByVal e As System.EventArgs) _ Handles btnBrowse.Click FolderBrowser.ShowNewFolderButton = True If FolderBrowser.ShowDialog() = DialogResult.OK Then Me.lblPath.Text = FolderBrowser.SelectedPath If lblPath.Text <> String.Empty And _ lblPath.Text <> Select a folder... Then create an XmlDocument object Dim doc As New XmlDocument read the service config file Dim configFilePath As String read the path of the service config file from the registry Dim key As Microsoft.Win32.RegistryKey = _ Microsoft.Win32.Registry.LocalMachine. _ OpenSubKey(Software\TenMinuteSolutions\ & _ Solution16Service, False) If Not key Is Nothing Then configFilePath = CType(key.GetValue( _ ServiceConfigurationPath), String) key.Close() Else MessageBox.Show(Unable to read the & _

250

General .NET Topics

service configuration path from & _ the registry key HKEY_LOCAL_MACHINE\ & _ Software\TenMinuteSolutions\ & _ Solution16Service.) Return End If If File.Exists(configFilePath) Then load the config file into the XmlDocument doc.Load(configFilePath) get the <add> node with the key=folder attribute Dim n As XmlElement = CType(doc.SelectSingleNode( _ configuration/appSettings/add & _ [@key=folder]), XmlElement) If Not n Is Nothing Then set the attribute to the text in lblPath n.SetAttribute(value, lblPath.Text) save the change doc.Save(configFilePath) Me.ServiceController1.ExecuteCommand( _ CHANGE_WATCHED_FOLDER) Me.EnableButtons(True) End If Else MessageBox.Show(The specified & _ configuration file does not exist.) End If End If End If End Sub

NOTE

You must set the monitored path before you can test the other features of the Solution16 application.

Figure 5 shows the form after we clicked the Browse button and elected to monitor a
C:\Solution16 folder.

Processing Text Files


Every time you add a text file to the monitored folder, the service moves it and counts the occurrences of each word. You can use the Add Text File button to copy a text file to the monitored folder. The sample code contains a solution16SampleTextFile.txt file that contains the sentence Solution 16 is a program to count word occurrences in files. Click the button once, select that file, and then click the Show Report button. Youll see the report in Figure 6.

Solution 16 Monitor Data and Files with a Windows Service

251

Try clicking the button multiple times, adding the sample text file again, and then clicking the Show Report button. Youll see that the word counts increase every time you add the file. The Show Report button calls the ServiceController.ExecuteCommand method, passing a CREATE_REPORT parameter, which causes the service to run the WriteReportFile method (see Listing 9), writing a report file. The Show Report button Click event handler opens the report file and displays the results in the multi-line TextBox on the form. FIGURE 5:
Setting the folder you want to monitor

FIGURE 6:
The report after you process one text file

252

General .NET Topics

To sum up, the combination of a FileSystemWatcher and a Windows service lets you create robust and powerful solutions. You can use a ServiceController to communicate with a running service to provide administrative or user control over service actions. Finally, this particular service not only watches for changes but also processes files itself. That combination makes the code easier to understand, but its not a very scalable solution. It would be too easy to overwhelm the service by copying several text files or even a few very large text files into the monitored folder. Instead, you should try to keep the service code as lean and responsive as possible. A more scalable version would let the service perform only watching duties, delegating the file processing by launching instances of other applications as needed.

ASP.NET Solutions

SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION

17 18 19 20 21

Creating Custom Configuration Settings in ASP.NET Internationalize Your ASP.NET Applications (Part 1 of 2) Internationalize Your ASP.NET Applications (Part 2 of 2) Managing Focus in Web Forms Missing MessageBoxes in ASP.NET?

254

ASP.NET Solutions

SOLUTION

17
SOLUTION

Creating Custom Configuration Settings in ASP.NET


PROBLEM

I want to store information in the web.config file, but the built-in configuration tags dont meet my needs.

You dont have to limit yourself to the simple key-value settings available by default through the <appSettings> section in the web.config file. Instead, create custom configuration handlers to manage information however you like.

ASP.NET Web applications have a built-in way to access simple name-value configuration datathe web.config file. The file has a number of sections that let you control various settings for your Web applications, and even let you add custom settings. Unfortunately, the built-in custom settings capability is fairly limited. Heres how it works: In the web.config file, you can create an <appSettings> section that lets you store simple name-value pairs. For example, create a new ASP.NET Web application project and add the following <appSettings> tag as a child of the <configuration> element in your web.config file:
<configuration> <appSettings> <add key=SomeKey value=SomeValue/> <add key=AnotherKey value=AnotherValue/> </appSettings> </configuration>

The section contains two child <add> tags that define the key-value pairs. You can retrieve the values via the built-in ConfigurationSettings property of the Page object. To begin, create a new Web Form in your project, name it customItems.aspx, and add this code to the Page_Load event:
Dim aKey As String Response.Write(<h3>AppSettings</h3>) For Each aKey In ConfigurationSettings.AppSettings.Keys Response.Output.WriteLine(aKey & = & _ ConfigurationSettings.AppSettings.Item(aKey)) Next

NOTE

See the section Using the Response.Output.WriteLine Method at the end of this solution for a way to simplify the process of writing lines to the Response object.

Solution 17 Creating Custom Configuration Settings in ASP.NET

255

FIGURE 1:
The relationship between configuration file sections, tags, and handlers
Sections define tag names and tag handlers. Configuration configSections

Section

Section

Tag

Tag

Tag

Tag

Compile and run the customItems.aspx Web Form. Youll see the <appSettings> tag values. The loop retrieves all the <add> tags from the <appSettings> section, and displays the key and value attribute values. This simple key-value mechanism is perfect for many common needs, such as storing database connection strings at application scope, but its not robust enough for more complex data. Fortunately, Microsoft also built in a mechanism for creating custom configuration data. Rather than reading a hard-coded list of tags recognized only via code within a specific application, the ASP.NET Framework reads one or more <configSections> sections, which define the tag names the Framework should expect to find in the remainder of the file and also define a class type and location for handling the type of content associated with that particular tag (see Figure 1).

Configuration Sections
As the ASP.NET engine parses the configuration file, it builds a list of the possible tags by reading the <configSections> elements <section> tags, each of which contains a name and a type. These tags define the names of the tags expected in the remainder of the document as well as a handler for each particular tag. A little experimentation shows you how this works. In the web.config file for your project, add a new tag just before the ending </configuration> tag in the file:
<configuration> <!-- the existing web.config settings here -->

256

ASP.NET Solutions

<customItems> </customItems> </configuration>

Save the web.config file and run the project. Youll get an Unrecognized configuration in section customItems error. The error occurs because no section handler is defined for the <customItems> tag. But if you look at the entire web.config file, youll see that no handlers are defined for any of the tags, which leads to the question: Where are the handlers defined? NOTE
If youre performing the steps as you read this solution, remove the <customItems> tag from your web.config file before you continue.

It turns out that there are two configuration files for every Web application: a root machine.config file, stored in a system folder, and the web.config file in your applications root folder. You can find the machine.config file in the $System$\Microsoft.NET\Framework\ <version>\CONFIG folder, where <version> corresponds to the version of the framework installed and active on your server. The configuration settings in the machine.config file apply to all .NET applications on your server unless overridden by more localized settings. If you look at the machine.config file, youll see a <configSections> tag containing a set of <section> tagsand those tags define the handlers for the default tags you see in the web.config file. To make the process even simpler to understand, you can further group the <section> tags inside <sectionGroup> tags, each of which defines a set of handlers for related section tags. I brought up the machine.config file because you have two ways to add custom tags. You can use one of the default System handler types to parse the content of your custom tags, or you can create your own handler.

Using System Handlers for Custom Tags


Using one of the System handlers is convenient when you dont care about the attribute names for your custom tagsbut using System handlers can be somewhat limiting. When you use a System handler, you must use the attribute names that your chosen System handler understands rather than custom names that you define. For example, if youre satisfied with using the <add key=SomeKey value=SomeValue/> syntax, you can use the System.Configuration.NameValueHandler class to read your custom tags. Heres how to do that generically so that the new custom tag applies to all Web applications running on your server: 1. Find the machine.config file on your server and open it in the VS.NET editor. In the <configSections> section, add the following custom <section> tag, which defines a handler for a <customSystemItems> tag:
<section name=customSystemItems type = System.Configuration.NameValueSectionHandler,

Solution 17 Creating Custom Configuration Settings in ASP.NET

257

type=System.Configuration.NameValueSectionHandler, System, Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089/>

NOTE

The Version and PublicKeyToken values may differ on your version of the .NET Framework. To find the appropriate values for your system, copy them from an existing <section> element.

2. Test the new <customSystemItems> tag by placing it in your web.config file, just before the closing </configuration> tag; for example:
<configuration> <!-- the existing web.config settings here --> <customSystemItems> <add key=SomeKey value=SomeValue /> </customSystemItems> </configuration>

3. Save your changes to the web.config file. In your customItems.aspx Web Form, add the highlighted code in Listing 1 to the Page_Load event.

Listing 1

Page_Load event code (customItems.aspx)

Dim aKey As String Response.Write(<h3>AppSettings</h3>) For Each aKey In ConfigurationSettings.AppSettings.Keys Response.Output.WriteLine(aKey & = & _ ConfigurationSettings.AppSettings.Item(aKey)) Next <font color=green> Response.Write(<h3>CustomSystemItems</h3>) For Each aKey In CType(ConfigurationSettings.GetConfig _ (customSystemItems), _ System.Collections.Specialized.NameValueCollection).Keys Response.Output.WriteLine(aKey & = & _ ConfigurationSettings.AppSettings.Item(aKey)) Next </font>

4. Now compile and run the Web Form again. This time, youll see the CustomSystemItem header followed by the line SomeKey=SomeValue, which corresponds to the single <add> child element you added to the <customSystemItem> element. Modifying the machine.config file allows you to use the defined custom tag in any Web application running on your serverbut you may not always want the handler to apply to all applications. If this is the case, you can add a <configSections> tag and the <section> tag to your web.config file instead. To test this, first remove the <section> tag you defined earlier from your machine.config file, and save the changes. Next, add a <configSections>

258

ASP.NET Solutions

tag immediately following the opening <configuration> tag in your web.config file, and place the <section> tag inside that. For example:
<configuration> <configSections> <section name=customSystemItems type = System.Configuration.NameValueSectionHandler, type=System.Configuration.NameValueSectionHandler, System, Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089/> </configSections> <!--remainder of web.config file here --> </configuration>

5. So that you can see a change, append one more <add> tag to the <customSystemItems> section of your web.config file:
<configuration> <!-- the existing web.config settings here --> <customSystemItems> <add key=SomeKey value=SomeValue /> <add key=AnotherKey value=AnotherValue /> </customSystemItems> </configuration>

Save the changes to your web.config file and run the customItems.aspx Web Form again. You will see two values now rather than one. You dont need to recompile your application to perform the test; ASP.NET applies configuration changes immediately. You can define any number of custom tags in this manner; however, using the generic
<add> tags and the key and value attribute names isnt particularly intuitive. For maintain-

ability, its more effective to create custom tags with custom handlers so you can control both the tag and attribute names.

Defining Custom Handlers for Custom Tags


Suppose you want to define a list of articles, each of which has a title, a URL, and zero or more authors. A tag structure such as the following is much easier to maintain than the generic <add key=article title value=articleURL/>:
<articlesVB> <article title=Article 1 Title url=http://www.somewhere.com/article1.aspx> <authors> <author>Russell Jones</author> </authors> </article> <article title=Article 2 Title

Solution 17 Creating Custom Configuration Settings in ASP.NET

259

url=http://www.somewhere.com/article2.aspx /> <authors> <author>Russell Jones</author> <author>Barry Jones</author> </authors> <!-- more links here --> </articlesVB>

Add the <articlesVB> tag and its contents (the preceding XML fragment) to your web.config file just before the ending </configuration> tag, and then save your changes. WARNING Dont run the project yet. If you run it now, youll get an error because theres no handler
defined for the <articlesVB> section.

To read the <articlesVB> tag from a configuration file, you need to create a custom handler. Creating a custom handler is not difficult but requires a separate project, because the handler implementation searches for an EXE or DLL file with the handler name. Create a new Class Library project and name it CustomItemHandler. Delete the default class that VS creates and add a new class named CustomTagHandler to the project (see Listing 2). Custom handlers must implement the IConfigurationSectionHandler interface, which has only one method, called Create. The method takes three parameters: a parent object, an HttpConfigurationContext object, and a section XmlNode.

Listing 2
Imports Imports Imports Imports

The CustomTagHandler method


System System.Collections System.Xml System.Configuration

Public Class CustomTagHandler Implements IConfigurationSectionHandler Public Function Create(ByVal parent As Object, _ ByVal configContext As Object, _ ByVal section As System.Xml.XmlNode) As Object _ Implements System.Configuration. _ IConfigurationSectionHandler.Create Implementation here End Class

When the ASP.NET Framework reads the <articlesVB> node, it creates an instance of the CustomTagHandler class and calls its Create method. The section parameter is an XmlNode instance containing the XML content of the section you want to readin this example, the <articlesVB> custom tag and its contents. Of the three parameters, you would

260

ASP.NET Solutions

typically use only XmlNode, but to be complete, the parent parameter contains the configuration settings from any corresponding parent configuration section. The configContext parameter is an instance of the HttpConfigurationContext class and is useful primarily for obtaining the virtual path to the web.config file. Youre free to make the contents of custom configuration sections as simple or as complex as you like. Ive elected to make this example more complex than a simple name-value pair so that you can see some of the possibilities inherent in using XML-formatted configuration files. The Create method returns an object. You can decide which type of object you want to return, but because youre implementing an interface method, you cant change the return type; therefore, code that calls your custom handler must cast the object to the correct type. The CustomTagHandler class reads the list of child articles from the <articlesVB> tag and populates an ArrayList with a list of Article objects. Each Article object has three public read-only properties that hold the title, the URL, and the list of authors for each article. Note that because there may be an arbitrary number of authors, the Article object implements the list of authors as an ArrayList as well. Its important to realize that you do not have to treat the data in this manner. You could just as easily return the XML node itself and let the calling program deal with extracting the data, or you could return the author list as a collection of XML nodes, or whatever you need. The point is that creating a custom handler lets you treat the data in the most appropriate way. Listing 3 shows the full code for the CustomTagHandler and Article classes.

Listing 3
Imports Imports Imports Imports

CustomTagHandler and Article classes


System System.Collections System.Xml System.Configuration

Public Class CustomTagHandler Implements IConfigurationSectionHandler Public Function Create(ByVal parent As Object, _ ByVal configContext As Object, _ ByVal section As System.Xml.XmlNode) As Object _ Implements System.Configuration. _ IConfigurationSectionHandler.Create Dim NArticle, NAuthor As XmlNode Dim articleNodes, authorNodes As XmlNodeList Dim authors As ArrayList Dim aTitle As String Dim aURL As String Dim articles As New ArrayList()

Solution 17 Creating Custom Configuration Settings in ASP.NET

261

articleNodes = section.SelectNodes(article) For Each NArticle In articleNodes aTitle = NArticle.Attributes. _ GetNamedItem(title).Value aURL = NArticle.Attributes. _ GetNamedItem(url).Value authors = New ArrayList() authorNodes = NArticle.SelectNodes( _ authors//author) If Not authorNodes Is Nothing Then For Each NAuthor In authorNodes authors.Add(NAuthor.InnerText) Next End If articles.Add(New Article(aTitle, aURL, authors)) Next Return articles End Function End Class Public Class Article Private m_title, m_url As String Private m_authors As ArrayList Public Sub New(ByVal aTitle As String, _ ByVal aURL As String, ByVal authors As ArrayList) m_title = aTitle m_url = aURL m_authors = authors End Sub Public ReadOnly Property Title() As String Get Return m_title End Get End Property Public ReadOnly Property URL() As String Get Return m_url End Get End Property Public ReadOnly Property Authors() As ArrayList Get Return m_authors End Get End Property End Class

262

ASP.NET Solutions

Using the CustomItemHandler Class


Now youre ready to test the CustomItemHandler class. First, make sure your code compiles without errors. To test the class, you need to add the <section> tag that defines your custom handler for the <articlesVB> tag. Switch back to the ASP.NET project you started at the beginning of this solution and add a reference to the CustomItemHandler.dll file created when you compiled the CustomItemHandler project. To create the reference, right-click on the References item in the Solution Explorer window and select Add Reference. Click the .NET tab and then click the Browse button. Youll find the DLL in the bin subdirectory of your CustomItemHandler project. Next, make one more modification to your web.config file. Within the <configSections> tag you created earlier, add a new <section> tag. Set the name attribute to articlesVB and set the type attribute to the class and assembly name of the handler you just created. At this point, the <configSections> section should look like this (your version numbers may differ):
<configSections> <section name=customSystemItems type=System.Configuration.NameValueSectionHandler, System, Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089/> <section name=articlesVB type=CustomItemHandler.CustomTagHandler, CustomItemHandler/> </configSections>

WARNING The web.config file is XML and is therefore case sensitive. Make sure the attribute values match the case for both the tag and the assembly and class names.

Add the following code to your customItems.aspx Web Form to retrieve and display the articles as links:
Dim Dim Dim Dim articles As ArrayList anArticleVB As CustomItemHandler.Article o as Object s As String

Response.Write(<h3>ArticlesVB</h3>) articles = CType(System.Configuration. _ ConfigurationSettings.GetConfig _ (articlesVB), ArrayList) If Not articles Is Nothing Then For Each o In articles

Solution 17 Creating Custom Configuration Settings in ASP.NET

263

anArticleVB = CType(o, _ CustomItemHandler.Article) Response.Output.WriteLine _ (<a href= & anArticleVB.URL & _ & > & anArticleVB.Title & _ </a>) If Not anArticleVB.Authors Is Nothing Then s = by For Each obj In anArticleVB.Authors s += CType(obj, String) & , Next Response.Output.WriteLine _ (s.Substring(0, s.Length - 2)) End If Next End If

Finally, compile and run the customItems.aspx Web Form. Youll see the header ArticlesVB followed by the list of article links defined by the <articles> section in the web.config file (see Figure 2). FIGURE 2:
The list of links from the custom configuration section in the web.config file

264

ASP.NET Solutions

You can follow the steps in this solution to create custom handlers for any type of information that you can store in a configuration file. Youve seen how to use the built-in <appSettings> section to read generic key-value settings, how to use system-defined handlers to read custom sections with system-defined attributes, and how to create and define custom handlers. Define your handlers in the machine.config file when you want to use custom sections across all ASP.NET applications on a particular server, or define them in the web.config file when you want to create custom sections that apply to only a single application.

Using the Response.Output.WriteLine Method


You can use the Response.Output property directly rather than indirectly via the simpler Response.Write method to avoid having to concatenate the <br> tag to every line you want to write. The Response.Output property returns the Response objects underlying Stream and the Stream has a NewLine property that you can set. By setting it to <br>, you can use the Streams WriteLine method to output lines with the appended NewLine value. Note that you can also set the NewLine property to other values, which is useful when youre writing content other than HTML. Unfortunately, the NewLine property appears to work only with the overloaded WriteLine version that accepts String objects, not with the Streams other overloaded methods. For example, if you pass an object directly to the WriteLine method, it wont append the value of the NewLine property. However, you can work around that problem easily by using the ToString method on the object first. And because you can also write formatted strings, writing code to produce output such as a list of currency-formatted values becomes both easy and convenient:
Dim prices() as Integer = {10000, 20000, 30000, 40000} Dim price as Integer Response.Output.NewLine = <br> For Each price In prices Response.Output.WriteLine(price.ToString(C)) Next

Finally, by setting the Response.Output.NewLine property in the global.asax file during the Global_BeginRequest event, you can take advantage of the WriteLine method without having to remember to set the NewLine value for each page in your application.

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

265

SOLUTION

18
SOLUTION

Internationalize Your ASP.NET Applications (Part 1 of 2)


PROBLEM

I need to deliver my Web site to an international audienceand they dont want to read everything in English. How should I go about setting up my site so that I can deliver the same content in multiple languages?

ASP.NET provides comprehensive support for internationalization, but you cant simply slap content up; you need to go about developing an internationalized ASP.NET application in a planned and deliberate manner. Learning a few details about ASP.NETs support for internationalization and studying these examples will give you a head start.

NOTE

This solution and the one that follows were written by Ollie Cornes for DevX.com.

The Internet was originally used primarily by English speakers, but the percentage of non-English speakers is increasing as Internet penetration increases. With huge populations in Asian countries coming online and European countries needing to work more closely together, theres a growing need for Web sites that cater to visitors from various cultures. This trend is clearly a good thing, but it creates new challenges for Web site developers. The .NET Framework includes a variety of features to help you create truly international Web sites, many of which apply to all types of applications, whether theyre Windows Forms, console, or ASP.NET applications. Although ASP.NET cannot help you translate content, it can help you manage content once it has been translated. In this solution, youll see the features that are especially useful to ASP.NET developers in managing this content. This solution uses a sample Web site that provides access to a series of news articles. Unlike with most Web sites, however, a site visitor can select a language from a drop-down list to see the article in the selected language. The Web site uses the localization features of .NET to locate pre-translated content for the page in the correct language and then display it to the site visitor. The .NET Framework provides a range of extremely useful features that save you a significant amount of time, but there are many aspects of localization that you must examine before implementing a successful project.

266

ASP.NET Solutions

Language and Culture Considerations


When deciding what needs to look and function differently for each of the cultures and languages included in a localized Web site, you should consider a few core areas: Database content This area includes large pieces of information, like news stories, articles, and product descriptions. This type of data is most often located in a database, although many Web sites will cache it within HTML files. Graphics Most sites have graphics, and although many graphics wont be affected by changes in language, some will. Certainly every image that contains text will require internationalizing, as will images containing symbols with differing meanings across cultures. Text resources This refers to those little bits of text that appear over a sitethe corporate switchboard number, fax number, single-line copyright statement, and the front page welcome message, for example. The majority of Web sites keep this type of information in the page file itself, whether its an ASPX file or a flat HTML file. Dates Various cultures display their dates differently, and it isnt just the words in long date strings that differ between locales. For example, the date 1st Dec 2001 is displayed in England as 1/12/01, but in the United States, it is 12/1/01. Displaying information in a format that is optimized for the viewer is crucial if you want all your readers to be comfortable with the site and have access to accurate information. In addition to these four areas, other factors you should consider are currencies, number format localization (e.g., commas or periods), string sorting and casing, and imperial (pounds and ounces) versus metric (kilograms and grams) measurement systems. The translation process is worth a mention; it is often more significant in terms of operational planning than the technology itself. Here are some questions you should answer as you plan your internationalized application:

Who will translate the content? What languages will the site support? When content is available only in a limited number of languages, does the site display the content in the default (wrong) language, or hide it from the user? What process or processes will support the translation? Will there be parallel releases of content in different languages, or will each be released as and when it is completed? Which language will be the default language used when errors occur or resources cannot be found?

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

267

A short warning: After you build your site, the process will become the key focus. Consider the process early to avoid problems after you launch your site. Im mentioning this because the translation process used in this solution is relatively trivial (see the accompanying note). The site will display the most appropriate content to our audience, but the sample application includes some restrictions. NOTE
Throughout this solution, you will see examples of text in English, Chinese, French, and Japanese. Id like to apologize in advance to the speakers of these languages for the quality of the translations. I hope you can forgive me and see the text as secondary to the technology that the examples are attempting to demonstrate. Ive used AltaVistas Babel Fish (http://babelfish.altavista.com/) to perform the translations and provide some sample content. Babel Fish, while convenient, creates some slightly dubious translations at times. I would also like to give a mention to BBC News Online (http://news.bbc.co.uk/), snippets of whose news items I have used for the English text of some of the test applications content.

Mapping Content to Classes


The sample site for this two-part solution is a Web-based content viewer built using ASP.NET that provides news articles in several languages in parallel. The content is stored in several languages and is displayed to the Web site visitor in the correct language depending on his or her preference. The site ensures that all article text, page text, dates, and images are displayed for the specified culture. The news site stores its articles in a database, which is common in sites of this type because it simplifies building management interfaces for editors and journalists. The first step in putting the application together is to build a user interface that displays the news stories. The challenge lies in controlling which language version is displayed. Lets look at the requirements for the site. The site stores each article in four languages:

Users select the language from English - UK, French - France, Chinese - China, and Japanese - Japan. The default language is UK English. The site displays a series of news articles in the selected language (if available). Each article consists of a unique identifier, title, body, and publication date.

Because this is an example, there will be no interface to submit, edit, delete, or otherwise manipulate articles. The test data were inserted directly into the database (SQL Server).

268

ASP.NET Solutions

A class called Article represents an instance of an article in a specific language. At the start of the Article.vb class file are some directives for the namespaces youll need:
using System.Data; using System.Globalization;

The System.Data namespace provides the DataSet class, and the System.Globalization namespace provides classes you use to manage the localization of information. The most important of these is the CultureInfo class, which represents a culture and which you use to specify the culture to be used to display a specific page. The Article class has private members used to store the identifier, title, body, publication date, and culture (each Article instance represents a piece of content in a specific culture):
Private Private Private Private Private mArticleID As Integer mArticleTitle As String = mArticleBody As String = mArticlePublicationDate As DateTime mArticleCulture As CultureInfo

Next are some properties for the private fields that will be displayed on the page for each article:
Public ReadOnly Property Title() As String The title of the article/story. Get Return mArticleTitle End Get End Property Public ReadOnly Property Body() As String The main body text of the article/story. Get Return mArticleBody End Get End Property Public ReadOnly Property PublicationDate() As DateTime The publication date for the article/story. Get Return mArticlePublicationDate End Get End Property

Public ReadOnly Property Culture() As CultureInfo The culture for which the

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

269

article/story was written. e.g. en-GB, English (United Kingdom) Get Return mArticleCulture End Get End Property Public ReadOnly Property ArticleID() As Integer the identifier unique to this article/story. Get Return mArticleID End Get End Property

Nothing to get too excited about there. The SQL Server 2000 database that holds the articles includes a view called ArticleDetail, which returns a DataSet that looks like Figure 1. Each row in the DataSet represents a version of an article in a specific language, and each version includes the article identifier, date, title, body, and a culture identification string. NOTE
Although both Microsoft Access and SQL Server support Unicode, you may need to configure your development machine to display characters in foreign languages by installing additional language support. To do this in Windows, look in Control Panel at the Regional settings and you will find an area called Language Settings For The System. You will need to select each language that you want to use. Clearly, which items you require will vary depending on the current configuration of your machine and the languages with which you intend to work.

The Article class has an overloaded constructor that accepts an integer identifying a specific article. In addition, the class includes a System.Globalization.CultureInfo object that represents the culture and returns an Article object:
Public Sub New(ByVal articleID As Integer, _ ByVal culture As CultureInfo) ... End Sub

A SQL query in the constructor uses the two parameters to retrieve the relevant translation of the article:
Dim Sql As String = SELECT * FROM & _ ArticleDetail WHERE ArticleID= & articleID & _ AND LanguageID= & culture.Name &

The constructor passes the SQL query to a private helper method called GetDataSet in a Database class that returns the relevant rows. Listing 1 shows the complete Database class code.

270

ASP.NET Solutions

FIGURE 1:
Sample data from the SQL Server ArticleDetail view

Listing 1

The Database class code (Database.vb)

Option Strict On Imports System Imports System.Data Imports System.Data.SqlClient Imports System.Web Summary description for Database. </summary> Public Class Database Public Shared Function GetDataSet(ByVal Sql As String) _ As DataSet Dim DS As DataSet = New DataSet Dim myPhysicalPath As String = HttpContext.Current.Request. _ PhysicalApplicationPath Dim connectionString As String = _ Server=server;Database=database; & _ UID=;PWD=; Dim myConnection As SqlConnection = New SqlConnection(connectionString) Dim myDataAdapter As SqlDataAdapter = New SqlDataAdapter(Sql, myConnection) Dim myDataSet As DataSet = New DataSet myDataAdapter.Fill(myDataSet) Return myDataSet End Function End Class

So, the Article class retrieves an article by formulating the SQL statement and passing that to the Database object, which calls the GetDataSet method:
Dim ds As DataSet = Database.GetDataSet(Sql)

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

271

If the query returns no rows, then there is no matching article in the specified culture. In our case, this next block of code runs another SQL query to search for an English version (the default language in the application):
If ds.Tables(0).Rows.Count = 0 Then Sql = SELECT * FROM ArticleDetail WHERE & _ ArticleID= & articleID & AND LanguageID= & _ en-gb & ds = Database.GetDataSet(Sql) End If

If after we run the second SQL statement there are still no rows in the result set, then the code throws an exception:
If ds.Tables(0).Rows.Count = 0 Then Dim ex As Exception = New Exception _ (Article does not exist) Throw ex End If

If either query returns a row, the constructor sets the values of the private members for the title, body, publication date, and culture:
Dim dt As DataTable = ds.Tables(0) mArticleTitle = dt.Rows(0)(Title).ToString() mArticleBody = dt.Rows(0)(Body).ToString() mArticlePublicationDate = DateTime.Parse(dt.Rows(0)_ (PublicationDate).ToString()) mArticleCulture = CultureInfo.CreateSpecificCulture _ (dt.Rows(0)(LanguageID).ToString()) End Sub

The last line of the constructor uses the CreateSpecificCulture method to create a new CultureInfo object that represents the culture of the article. The CreateSpecificCulture method takes a culture string such as en-US and returns a CultureInfo object representing the most appropriate culture. The application displays articles on the page using a server control. The server control has a single property that represents the article number. The control automatically selects the correct culture when it displays the article (more on this later). The server control class starts with the definition of a private member variable that holds the article identifier and a public property that manipulates that value:
Imports System.Globalization Imports System.Threading Public Class ArticleControl Inherits Control

272

ASP.NET Solutions

Private mArticleID As Integer The identifier specifying the article to display. Public Property ArticleID() As Integer Get Return mArticleID End Get Set(ByVal Value As Integer) mArticleID = Value End Set End Property

The server controls CreateChildControls method creates the HTML tags by adding controls to the page. First, it extracts the culture in which the page is executing as a CurrentUICulture object using the Thread object in the System.Threading namespace. You will see how to manipulate this setting later in this solution.
Protected Overrides Sub CreateChildControls() Dim culture As CultureInfo = Thread.CurrentThread.CurrentUICulture

The CreateChildControls method calls the Article class constructor and passes the culture value and the article identifier (from the ArticleID property) to create the Article object (myArticle) that will be displayed on the page:
Article myArticle = new Article(articleID,culture);

The ArticleControl object then renders the page by creating a series of LiteralControl objects that squirt HTML out to the browser through the HTTPResponse object:
Dim Literal As LiteralControl Literal = New LiteralControl(<h1> & myArticle.Title & </h1>) Controls.Add(Literal) Literal = New LiteralControl(<b><i> & myArticle.PublicationDate. _ ToLongDateString() & _ </i></b><br /><br />) Controls.Add(Literal) Literal = New LiteralControl(myArticle.Body & _ <br />) Controls.Add(Literal)

With the server control, its easy to insert an article into a page in a Web application. For example, the following code displays an article:
<%@ Page language=vb %> <%@ Register TagPrefix=NewsSite

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

273

Namespace=Multilingual.Controls Assembly=Multilingual %> <html> <body> <NewsSite:ArticleControl runat=Server id=ArticleCtrl ArticleID=1 /> </body> </html>

The three-tier design of this site keeps the program logic in the business objects and reserves the ASPX files for the interface and layout. The @Register directive in the Web Form specifies where .NET should look for controls. In this case, the @Register directive means When you come across any server controls, look in the Multilingual.Controls namespace to see if there is a matching class. You insert the <NewsSite:ArticleControl> tag into the page with its ArticleID property set to an ID that matches one of the articles. If you set the ArticleID property to a value that does not match an article, the Article class throws an exception. Figure 2 shows the result of the previous <NewsSite:ArticleControl> tag example. To view different articles, you can change the article identifier in the query string. The culture is taken from the thread in which the page code is running. You can change the culture setting by using the Culture property in the @Page directive. For example, lets change the first line of the page to include the Culture and UICulture attributes:
<%@ Page language=c# Culture=fr-FR UICulture=fr-FR %>

After we make the change, the page displays the same article (see Figure 3) but in French (fr-FR is actually French as spoken in France). Notice that not only has the title and body text switched languages, but the page also displays the date formatted appropriately for French readers. Later you will see how to change the culture setting dynamically. FIGURE 2:
A typical article displayed by adding a <NewsSite:Article Control> tag to a Web Form

274

ASP.NET Solutions

FIGURE 3:
The same article displayed in French

Changing the culture setting in the @Page directive affects the way the date is displayed, but changing the UICulture setting alters the language of the article itself. The former sets the culture for the display of information, and the latter specifies how the user interface should be shown. Notice also that if the culture is invalid, the page throws an exception. And if you enter a culture code for which there is no matching article, the page displays the English version. Many Web applications using .NET hook the front end directly to the database (rather than using business objects) because of the ease and speed with which its possible to create interfaces using server controls like the DataGrid and DataList. That approach works well for most Web sites; however, you must be aware that for larger Web sites this architecture is inappropriate and it is necessary to move the data access code into business objects and out of the code-behind. This method demonstrates how to use the @Page directive to specify that a page in the site should always be displayed in a certain language, whatever the culture settings. Thats not always useful. You may want to be able to change the language and culture dynamically. The next section discusses a means of providing site visitors with an interface to choose their own culture.

Letting Users Choose a Language


As youve seen, using the @Page directive changes the culture for a page for all users. But the application needs to store the culture setting for each user. You could let users select the language each time they connect to your site and store the result on the server in a users Session, but more realistically, you need to store the culture setting between visits. Therefore, the example site has a drop-down list that lets users select their preferred language and then stores a users language choice in a permanent cookie that contains a culture string. This approach lets you use the selected settings for all future page requests from that user on that machine.

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

275

The Application_BeginRequest method in the global.asax.vb file uses the cookie value. The ASP.NET Framework calls this method for each page request. The method reads the cookie value and sets the culture of the thread appropriately. The page code picks up the setting to display the article in the appropriate culture. First, grab the cookie value to see whether it contains a culture string:
Sub Application_BeginRequest(ByVal sender As Object, ByVal e As EventArgs) Dim culturePref As String = en-GB default culture If Not Request.Cookies(CulturePref) Is Nothing Then culturePref = Request.Cookies(CulturePref).Value Else ... End If

Next a Try-Catch block attempts to set the thread culture using the cookie value. The error handling protects against invalid cookie values:
Try Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(culturePref) Catch Thread.CurrentThread.CurrentCulture = _ New CultureInfo(en-GB) End Try Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture

The final line of the previous code sets the CurrentUICulture to the same culture value as the CurrentCulture, because in this application theres no need for them to be different. Another server control creates an interface that lets visitors select their language (and therefore set the culture cookie). The control displays a list of four cultures in a drop-down list using a form. This form is not a server-side form (it does not have a runat=server attribute) because only one server-side form can be used on each page. All the pages in the Web site include the language chooser, so its important to use a standard HTML form to leave the option of using the server-side form on the site. On submission, the form sends the data to a page called culturechooser.aspx that contains the following code in its Page_Load method:
Dim culture As CultureInfo = CultureInfo.CreateSpecificCulture( _ Request.QueryString(CultureChoice).ToString()) Thread.CurrentThread.CurrentCulture = culture Thread.CurrentThread.CurrentUICulture = culture Dim cookie As HttpCookie = New HttpCookie( _ CulturePref, culture.Name) cookie.Expires = DateTime.Now.AddYears(100) Response.Cookies.Add(cookie)

276

ASP.NET Solutions

Dim referrer As String = Request.UrlReferrer.ToString() Response.Redirect(referrer) CultureInfo culture = _ CultureInfo.CreateSpecificCulture _ (Request.QueryString[CultureChoice].ToString()); HttpCookie cookie = new HttpCookie _ (CulturePref, culture.Name); cookie.Expires = DateTime.Now.AddYears(100); Response.Cookies.Add(cookie); string referrer = Request.UrlReferrer.ToString(); Response.Redirect(referrer);

The main purpose of this code is to save the culture name as a cookie, but it also redirects back to the page from which the user came. To the user, it looks as if the page simply refreshes with the selected language. You can add the culture chooser server control to the article viewer page by using a standard server control statement:
<NewsSite:CultureControl runat=Server id=CultureCtrl> </NewsSite:CultureControl>

After adding the control and refreshing the page, you can see the new drop-down list, as shown in Figure 4. If you select French, the code sets the cookie and redirects your browser back to the article page, which now displays the content in French. FIGURE 4:
The article page with the drop-down language selection control

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

277

Localizing Other Text Resources


In addition to handling the text in articles stored in the database, the example site localizes smaller pieces of text on the pagesuch as the copyright messageby using resource (.resx) files. You can create resource files in Visual Studio .NET, or create resources in text files and convert them to .resx files. You can also create resource files programmatically using the System.Resources.ResourceWriter class. Files with the.resx extension are XML resource files and are easy to hand-edit. Because humans can read them, you can translate them directly. They can contain serialized objects as well as text information. The sample site contains resource strings for a welcome message, a copyright message, and the text above the culture drop-down list. Figure 5 shows how the English-language resource file Resources.resx looks in the Visual Studio .NET XML editor. Note that there are fields containing the string value, a brief comment describing its purpose, and a name for the string resource. The code uses the name field value to retrieve individual strings from the resource files. VS.NET stores default-language resources within the main assembly. To create a French equivalent, you produce another resource file called Resources.fr.resx. The naming is important; Visual Studio .NET uses the filename to maintain the logical relationship between these files and pass that information to the compiler. In this case, it uses the fr in the filename Resources.fr.resx to determine that this is the French version of Resources.resx. Figure 6 shows the French version. The French version contains only two strings so that I can show you how the site handles a missing resource string. You can repeat the resource file process for each language. If you now rebuild the project and look in the bin folder of the Web site, youll see something interesting. Although the primary English resource file (Resources.resx) is embedded in the main assembly, Visual Studio.NET creates the other three (French, Chinese, and Japanese) as their own assemblies and places them in separate folders named using the culture identifier (see Figure 7). FIGURE 5:
The Resources.resx file in the Visual Studio .NET XML editor

FIGURE 6:
The French version of the resources file (Resources.fr.resx)

278

ASP.NET Solutions

FIGURE 7:
Subfolders hold resource files for each additional language.

NOTE

The reason I set the Chinese-China culture as zh-CN rather than zh is that for political reasons there is no high-level culture zh.

If you are not using Visual Studio .NET, you can embed the main resource file in the main assembly by using the /res compiler flag. You can create satellite assemblies using the al.exe utility. After compiling the resources into a useful form, you can access them from code and ensure that you select a string for the most suitable culture. To do that, I created another server control called SiteTextControl that you can add to the page wherever you want to display localized text. The server control has a public property for setting the name of the string value (such as Copyright) that you want to access:
Public Property Name() As String Get Return textName End Get Set(ByVal Value As String) textName = Value End Set End Property

The CreateChildControls method renders the control. In this case, it just writes the relevant resource text without any HTML tags. To retrieve the text, create a System. Resources.ResourceManager instance that provides access to the resource files. To instantiate the ResourceManager object, pass it the name of the resource file to use and

Solution 18 Internationalize Your ASP.NET Applications (Part 1 of 2)

279

a reference to the currently executing assembly so that it knows where to look for the resource strings:
Dim res As ResourceManager = New _ ResourceManager(Multilingual.Resources, _ [Assembly].GetExecutingAssembly())

To retrieve the resource string value, call the GetString method and supply the textName property that you set using the Name property of the server control:
Dim text As String = res.GetString(textName)

Finally, create a literal control for holding the resource string and add it to the page control structure:
Dim literal As LiteralControl = New LiteralControl(text) Controls.Add(literal)

Notice that code doesnt have to manage the culture. The .NET Framework does that for you, because the culture of the thread has already been set by the code in Application_BeginRequest. For example, to add the copyright message to the article page, add a SiteTextControl control to the page and specify the name of the resource display using the Name property:
<NewsSite:SiteTextControl runat=Server id=CopyrightMessage Name=Copyright />

After adding the control and refreshing the article page, youll see the copyright message shown in Figure 8. If you change the culture using the drop-down list, the control displays the copyright message in the selected language. Now suppose you want to add a caption above the drop-down list to prompt visitors to select their language. Unfortunately, you may remember that the ChooseLanguage string resource exists only in the UK English resource file (Resources.resx). To add the caption, you must modify the CreateChildControls method inside the server control that displays the drop-down list. Add the following code just before the drop-down list is created:
SiteTextControl text = new SiteTextControl(); text.Name = ChooseLanguage; Controls.Add(text); literal = new LiteralControl(<br />); Controls.Add(literal);

280

ASP.NET Solutions

If you add the code and refresh the site, it displays the new drop-down list caption. The site defaults to using the resources in the main assembly when it doesnt find relevant resources in the satellite assemblies; therefore, if you view a French page (see Figure 9), youll see that because the French resource doesnt exist, the drop-down list caption defaults to UK English. If you add resource strings to translate the drop-down caption into other languages, the page would display those without any other code changes A framework like this is relatively simple to implement. After its in place, its important to develop a process for inserting text strings into pages using resources rather than embedding strings directly into your code or ASP.NET page files. In this solution, you have seen how to put together a simple Web site that uses some of the core localization features of .NET, including the CultureInfo object and resource files. You have seen how to alter the culture setting at the page level and also how to insert localized content into the page from both a database and a resource file. In the next part of this solution, Ill show you how to manage the localization of graphics, as well as how to select the most suitable culture for visitors automatically when they first arrive at your Web site.

FIGURE 8:
Article page with a <NewsSite:Site TextControl> tag specifying the copyright message

FIGURE 9:
The drop-down caption in UK English

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

281

SOLUTION

19
SOLUTION

Internationalize Your ASP.NET Applications (Part 2 of 2)


PROBLEM

I need to deliver my Web site to an international audienceand they dont want to read everything in English. How should I go about setting up my site so that I can deliver the same content in multiple languages?

ASP.NET provides comprehensive support for internationalization, but you cant simply slap content up; you need to go about developing an internationalized ASP.NET application in a planned and deliberate manner. Learning a few details about ASP.NETs support for internationalization and studying these examples will give you a head start.

In part 2 of this solution, youll examine the features that ASP.NET provides to help you build fully localized Web applications in more detail. In particular, youll see how .NET manages culture information and how you localize various types of informationincluding images, content from a database, text such as copyright messages, and small pieces of information such as numbers, currencies, and dates. This solution will give you a good grounding in the more interesting localization features of the platform.

Specifying Cultures
How do you specify a culture unambiguously? When you say French, or UK English, or US English, can you be sure youre talking about the same language/culture that someone else is? Thankfully, there is an Internet standard called RFC 1766 that specifies codes for cultures. Here are some examples:

en-GBEnglish - United Kingdom en-USEnglish - United States fr-FRFrench - France zh-CNChinese - China

You can see that the first part (the lowercase letters) specifies the language, and the second part (the uppercase letters) specifies a country or region. There are more variants than are shown here; you can get much more information from the Microsoft Developer Network (MSDN).

282

ASP.NET Solutions

Now that you can uniquely specify a culture, its interesting to know that Windows 2000 introduced the concept of a pair of culture settings: one called the Current Culture and the other called the Current UI Culture, or the user interface (UI) culture. Although the two settings are generally the same, there are cases where it is useful for them to be different. For example, if a user is British and using a UK English application (CurrentUICulture=en-GB) but wants to see application data displayed in the French format (CurrentCulture=fr-FR), the two settings could be different. Heres how the two settings differ:
CurrentUICulture

Specifies the culture for the user interface and is used for resource

file lookups. Specifies the culture for data and information. It is used to format dates, numbers, currencies, and sort strings.
CurrentCulture

To see how these settings work, put these two lines into any ASP.NET page:
<%=System.Threading.Thread.CurrentThread. CurrentUICulture.ToString()%><br /> <%=System.Threading.Thread.CurrentThread. CurrentCulture.ToString()%>

Youll see that the output shows the values of the two culture settings. On my machine, I see these:
en-US en-GB

You can change the CurrentCulture setting through Control Panel, but the CurrentUI Culture is set when the operating system is installed. Applications use the CurrentUICulture setting to render the UI and the CurrentCulture setting to display data and information (for example, dates, numbers, and currencies). When you write code that localizes text, you must consider whether the information should be classified as part of the UI or as data and use the appropriate setting. On my machine, the CurrentUICulture is set to US English but the CurrentCulture is set to display data in UK English; however, on most machines, the two settings will either be the same, or set to very similar cultures. End-to-end Unicode support is another .NET feature thats valuable to developers. The .NET Framework String classes all use Unicode, ASP.NET uses Unicode, and both SQL Server and Access support Unicode. To work with cultures, .NET provides a CultureInfo class that represents a particular culture. For example, you can create a culture object representing UK English like this:
CultureInfo culture = new _ CultureInfo.CreateSpecificCulture(en-GB)

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

283

The CultureInfo class supports a variety of methods. For example, the DisplayName property returns the culture name in the cultures language; the EnglishName property gives you the culture name in English; the Calendar property specifies which calendar is in use; the DateTimeFormat property provides settings for displaying dates and times; the Number Format property offers options for displaying numbers; and the LCID property returns the locale/region identifier for the specified culture (1033 for UK). You can set the Current Culture and CurrentUICulture objects using a CultureInfo object as follows:
System.Threading.CurrentThread.CurrentCulture = culture System.Threading.CurrentThread.CurrentUICulture = culture

In Web applications its often useful to set culture info in the Application_BeginRequest method in the global.asax file. That method always runs first when an application begins, which ensures that your application has already set the culture before any page code executes. The first time a user accesses your site, you want the page to display the most appropriate of the supported cultures automatically. In other words, its convenient for the Web site to make a smart guess at the default language for a new user. Fortunately, most browsers provide a value in the HTTP request headers that gives you a list of cultures desired by the user, sorted in descending order of importance. In Internet Explorer, users control the list by selecting Tools Options and clicking the Languages button in the Options dialog box. When you do that, youll see the Language Preference dialog box shown in Figure 1, from which you can select a preferred language. The sample application compares the first language in the culture list sent by the browser to the list of cultures supported by the application, and if theres a match, it selects that culture; otherwise, the site uses the default culture. The code that performs this initial check goes in Application_BeginRequest. First, check to see whether you have previously set a cookie containing the users preferred culture:
Sub Application_BeginRequest(ByVal sender As Object, _ ByVal e As EventArgs) Dim culturePref As String = en-GB default culture If Not Request.Cookies(CulturePref) Is Nothing Then culturePref = Request.Cookies(CulturePref).Value End If

If the cookie doesnt exist (and it wont the first time the user visits the site), then you can retrieve the list of language preferences from the browser by using the Request objects UserLanguages property:
Else Dim browserPreference As String = _ HttpContext.Current.Request.UserLanguages(0)

284

ASP.NET Solutions

Next, grab a copy of the array of cultures that the application supports, which is stored in the Application object during application startup:
Dim languages As String() = _ CType(HttpContext.Current.Application _ (Cultures), String())

Finally, cycle through the list of languages supported by the site and see if any culture identifier matches the users preferred culture identifier. Notice that the code checks only against the first two characters of the culture identifier. For example, in this application someone browsing using a machine configured in Belgium would probably have the culture identifier fr-BE, but the application would provide content to that user in French (fr-FR) because the first two characters (fr) of the identifiers match:
For i As Integer = 0 To languages.Length - 1 If languages(i).Substring(0, 2) = _ browserPreference.Substring(0, 2) Then culturePref = languages(i) End If Next End If

The rest of the method simply uses the culture identifier to set the culture properties on the current thread so that the page can access them:
Try Thread.CurrentThread.CurrentCulture = _ CultureInfo.CreateSpecificCulture(culturePref) Catch Thread.CurrentThread.CurrentCulture = New _ CultureInfo(en-GB) End Try Thread.CurrentThread.CurrentUICulture = _ Thread.CurrentThread.CurrentCulture End Sub

FIGURE 1:
Internet Explorers Language Preference dialog box

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

285

Caching Article Content


Like many Web applications, performance is important for the sample application. Because the articles that make up the site (see Solution 18 for more information about the article content of the sample site) change infrequently, you can easily cache article output by placing the following OutputCache directive in the article.aspx file:
<%@ OutputCache Duration=600 VaryByParam=id %>

Note that the OutputCache directive uses the VaryByParam attribute. That causes ASP.NET to cache the article.aspx output several timesonce for each article identifier passed with the id parameter. The Duration attribute value of 600 means that the cache retains each cached copy for 10 minutes (600 seconds) before refreshing the article from the database. Serving the pages from cache takes a significant load off the database and provides faster performance. However, this caching scheme has a problem. If you load an article and then change the language using the drop-down list, youll see the same article. The VaryByParam setting maintains articles in the cache by article ID, not by language, so changing the language has no effectthe cache will return the article in whichever language was requested first. The solution to this problem lies in the OutputCache directives VaryByCustom attribute. The standard setting for VaryByCustom is browser, which directs the Framework to cache a copy of the page for each different Web browser that hits the sitemeaning that you can deliver browser-specific page versions. However, in this case youre not interested in browser versions. Instead, you can use the VaryByCustom attribute to cache the articles once for each language. To do this, you need to override the GetVaryByCustomString method in global.asax.cs, which is where processing of the VaryByCustom cache code occurs. The GetVaryByCustomString method returns a string, and the Framework maintains a cache copy for each different string. So, to create a cached copy of each article in each culture you just have to return a unique code for each culturein this case, the value of the CulturePref cookie:
Public Overrides Function GetVaryByCustomString( _ ByVal context As HttpContext, ByVal arg As String) _ As String Select Case arg Case LanguageChoice Dim langChoice As String = If Not Request.Cookies(CulturePref) Is _ Nothing Then langChoice = _

286

ASP.NET Solutions

Request.Cookies(CulturePref).Value End If Return LanguageChoice= + langChoice Case Else Return End Select End Function

To alter the way articleViewer.aspx caches pages, add the VaryByCustom attribute to the page:
<%@ OutputCache Duration=60 VaryByParam=id VaryByCustom=LanguageChoice %&>

When you rebuild the application, refresh the page, and change the language using the drop-down list, youll see that the caching occurs for each language. The application now benefits both from localization and from ASP.NETs output caching.

Localizing Static Images


In a Windows Forms application, it would be natural to put images in resource files, but Web applications typically serve images from image files using HTTP. Therefore, using resource files is not a viable solution; the server would have to write each image to disk as a GIF, PNG, or JPEG file before it could be served. For the same reason, you probably wont store the images in a database. In this solution, youll create a folder that will store the images for each culture that the sample site supports. Youll then use the localization code to dynamically assign the correct URLs. For example, the URLs for the French and UK English versions of the sample site differ:
<img src=/appname/images/fr-FR/image.gif width=&#133; height=> <img src=/appname/images/en-GB/image.gif width=&#133; height=>

To dynamically create these URLs, create another server control containing an HTML <img> tag. Name the new control Image and create one private imageName member variable for holding the base name of the image. Create a public property called Name that gets and sets the imageName member variables value. At runtime, you want to be able to assign the <img> tags href attribute value dynamically. To do that, override the Create ChildControls method and construct the controls href attribute by concatenating the images folder to the culture name and the ImageUrl property. To create a French version for example, if the base imageName property contained image.gif, you would end up with the URL /appname/images/fr-FR/image.gif. Heres the overridden CreateChildControls method:
Protected Overrides Sub CreateChildControls() Dim culture As CultureInfo = _ Thread.CurrentThread.CurrentCulture

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

287

Dim flag As System.Web.UI.WebControls.Image = _ New System.Web.UI.WebControls.Image flag.ImageUrl = images/ & culture.Name & / & _ ImageName flag.Width = New Unit(mImageWidth) flag.Height = New Unit(mImageHeight) Controls.Add(flag) End Sub

Notice that the code uses properties for the width and height of the image, which were set by sizing the control in the Form Designer. This scheme has two limitations. First, you must ensure that an image is present across all the culture folders. Second, all the images for the cultures must be the same size. In the articleViewer.aspx page, add two of these Image controlsone to display a flag relating to the culture, and the other to display a map of the geographic area identified with the culture:
<NewsSite:ImageControl runat=Server id=FlagImage ImageName=flag.gif width=65 height=39> </NewsSite:ImageControl> <NewsSite:ImageControl runat=Server id=MapImage ImageName=map.gif width=39 height=39> </NewsSite:ImageControl>

Note that you use the Image control in much the same way that you use the <img> tagall you need to do is specify the image name and the dimensions of the image. To test the image localization, select a language from the drop-down list (Figure 2) and then refresh the page in the browser to see the effect of the new language choice (Figure 3). Another way to deal with localized images is create the images dynamically. When a client requests an image, the code creates that image on the fly and sends it straight out to the site visitor. FIGURE 2:
Selecting a language from the drop-down list changes the image.

288

ASP.NET Solutions

The sample site includes an example page called image.aspx that returns a GIF file containing the name and identifier for the current culture. When you open the page in a Web browser, the culture in the image matches the culture you have chosen (see Figure 4). The page is a normal ASP.NET page, but rather than returning HTML to the browser, it returns a GIF file. Unlike the image examples youve already seen, the application creates these images on the fly based on your CurrentCulture and CurrentUICulture settings. The Page_Load method contains all the code for creating the GIF image and sending it back to the user. First, the method loads a background image from disk:
Private Sub Page_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load Dim fileImg As System.Drawing.Image = System.Drawing.Bitmap.FromFile(Server.MapPath(images/background.gif))

Next, the method creates a custom image, which it returns to the user. To do that, you need a Graphics object. The Graphics object lets you access the image and provides a large variety of methods for editing and changing images:
Dim img As Bitmap = New Bitmap(255, 75, PixelFormat.Format24bppRgb) Dim g As Graphics = Graphics.FromImage(img)

FIGURE 3:
Selecting English and refreshing the browser displays the page in English.

FIGURE 4:
The image.aspx page showing a dynamically generated image

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

289

You use the Graphics object to copy the background image loaded from disk onto the new blank image:
g.DrawImageUnscaled(fileImg, 0, 0, _ fileImg.Width, fileImg.Height) Dim format As ImageFormat = ImageFormat.Gif

With a new copy of the image, you can write some code to draw the text onto it. First, create a font and a graphics brush to draw the text:
Dim aFont As Font = New Font(Georgia, 14)

Next, write the text for the first line (the culture description) and draw the text string onto the image using the Graphics DrawString() method. Note the statement that sets the TextRenderingHint property of the Graphics object; that property causes the Graphics object to draw anti-aliased text, which gives you much higher quality output.
Dim aBrush As SolidBrush = New SolidBrush(Color.Black) Dim text As String = _ System.Threading.Thread.CurrentThread. _ CurrentCulture.DisplayName.ToString() g.TextRenderingHint = _ System.Drawing.Text. _ TextRenderingHint.AntiAlias g.DrawString(text, aFont, aBrush, 4, 14) End Sub

Now you can draw the second string, but a little further down:
text = System.Threading.Thread. _ CurrentThread.CurrentCulture.Name.ToString() g.DrawString(text, aFont, aBrush, 4, 40)

At this point, you have an image ready to send to the browser. So that the browser will recognize the returned data as an image, you must set the page response type to image/gif rather than the default text/html setting:
If format Is System.Drawing.Imaging. _ ImageFormat.Gif Then Response.ContentType = image/gif Else Response.ContentType = image/jpeg End If

You can save images to Streams in various image formats. The Response object exposes its underlying stream through the Response.OutputStream property, so you need only one statement to send the image data to the browser. Note the second parameter, which saves the image as a GIF file. You could just as easily save it as a PNG or JPG file, though.
img.Save(Response.OutputStream, format)

290

ASP.NET Solutions

Finally, always remember to dispose of any Image or Graphic objects you create so that the garbage collector can work its magic and reclaim the memory:
img.Dispose() g.Dispose()

Localizing Dates
As long as you store dates in a DateTime object, they are pretty easy to localize, which is good. To give you an idea of the problem that needs solving, just look at how differently French and UK English dates are usually displayed:

UK: 20 August 2001 French : lundi 20 aot 2001

Its easy to see that the language for long-format dates is different, but short-format dates are different as well. For example, the order of the month and day differs between UK and US dates. In the UK, August 20, 2001 is 20/8/01, but in the United States its 8/20/01. Clearly, such differences are critical to communicating information correctly. In ASP.NET, to display a date in a different culture you use the Thread object to change the current thread culture, and then use one of the DateTime class display methods to display the date. For example, the following fragment displays a date in long format determined by the French (fr-FR) culture setting:
Dim culture As CultureInfo = _ CultureInfo.CreateSpecificCulture(fr-FR) System.Threading.Thread.CurrentThread. _ CurrentCulture = culture Dim date As String = DateTime.Now.ToLongDateString() Response.Write(date)

Localizing Numbers and Currencies


Currencies and numbers vary in much the same way as datesand .NET handles them just as neatly. As with dates, after you set the culture for the current thread the Framework handles formatting. For example, the French/France locale uses commas in numbers, whereas English cultures generally use periods. To make the change, you first set the culture and then display the formatted string. For example, to display the value 5.5 in French as 5,5 you can write:
Dim culture As CultureInfo = _ CultureInfo.CreateSpecificCulture(fr-FR) System.Threading.Thread.CurrentThread. _ CurrentCulture = culture Label1.Text = (5.5).ToString()

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

291

Currencies work similarly, but you must specify explicitly that you want a value displayed as a currency. For example, to display the Japanese price 100 (100 Yen) you can write:
Dim culture As CultureInfo = _ CultureInfo.CreateSpecificCulture(ja-JP ) System.Threading.Thread.CurrentThread. _ CurrentCulture = culture Label1.Text = String.Format({0:c},100)

The preceding code uses the String format specifier c, which displays the value as a currency using the threads culture setting.

Localizing Database Content


Custom databases can be designed to hold content for Web sites in a variety of different designs, depending on the application requirements. Once you have a suitable database structure, you need a way to ensure that the user of the Web site sees the right content and that performance issues are addressed successfully. ASP.NET provides this functionality so that the glue required to localize content in a database is relatively small. Much of this code is provided by the System.Globalization namespace that gives you information about the users locale and allows you to acquire more details about that locale (for example, the currency). Another important part of ASP.NET from the standpoint of database-located content is caching. It provides caching at the page level, but also at the UI control and application levels. These features mean that you can create a Web site that delivers content from a database while being confident that most of the performance issues have already been solved for you.

Localizing Graphics
Internationalizing images on a Web site has the potential to be a huge task; however, there are things you can do to reduce the burden. Most important is that you reduce the number of images that need internationalizing. Consider re-creating images that contain text, numbers, dates, currencies, measurements, or icons that can be related to specific cultures (flags, maps, logos, etc.) in a more international form. After reducing the problem to a more manageable size, you can consider how to deal with images that do need different versions for different cultures. There are two approaches: Dynamic images This technique means that each image is created on the fly dependent on the culture of the visitor. Although this is the ideal solution in many ways from a management point of view, performance issues provide some restrictions here. Static images Often images for each culture are stored separately, perhaps in separate folders. The Web site then ensures that the correct image is sent to the user in response to a Web request.

292

ASP.NET Solutions

The features that ASP.NET provides for caching and managing cultures make both approaches relatively simple, and certainly much easier than with ASP 3.0. After you begin internationalizing your applications, you must ensure that an image exists in each culture folder for each localized image that your application requires. In many cases, thats difficult. You may find that you want to default to using an image for a specific culture or to a generic image. In that case, you can check if the ideal image file exists and, if not, change the generated HTML to point to the default language version.

Localizing Text
Localizing small pieces of text is also much easier with ASP.NET. The Framework includes a method for managing resources much like that used in earlier Windows development. Using resource files creates a broad range of options for your applications and also lets you manage those resources more easily. For example, you can easily pass resources to a translation agency and put them back into the application without recompiling the entire application.

Managing Frequently Changing Resource Strings


In some applications, you may find it necessary to be able to edit text resources easily, without having to edit resource files. For instance, some sites might want to be able to change the welcome message on the site daily. To achieve this, you might like to consider an alternative approach where the resources are stored in a database. You can provide an interface on the site that allows site managers to make such changes. Storing these strings in a database is not as efficient as using resource files, and could cause serious performance problems. So if you take this route, you should look into caching these objects in an HttpCachePolicyObject (Page.Cache).

Dont Overuse ResourceManager Objects


Youll recall that in the sample code the SiteText server control that displays things like the copyright message instantiates an object from the ResourceManager class. The control uses that instance to gain access to the string resources for each culture. Microsoft recommends that for applications where performance is an issue, you should avoid constantly instantiating new objects from this class. A more scalable approach is to create and store a single ResourceManager instance in the Application object and hook the SiteText class up to that stored copy rather than creating a new one for each request.

Improving Culture Matching


As you saw earlier, the sample code for this solution contains a global.asax file method that estimates the most appropriate culture for the visitor. Currently, if a visitor came from

Solution 19 Internationalize Your ASP.NET Applications (Part 2 of 2)

293

Belgium with a culture identifier of fr-BR, the string would not match the fr-FR identifier in the culture list and so the language would default to English rather than French (and that may or may not be appropriate). The code would benefit from being extended so that if there is no direct match, it checks to see if there is a match on the first part of the culture code. For example, if you were to extend the sample the improved version would check for a match against fr rather than the full fr-FR identifier after the exact check failed.

Caching and Performance


ASP.NET provides an extensive range of caching options that make it much easier to create applications that perform better not only from the users perspective but on the Web site back end. Using these caching options allows you to reduce database hits and formatting cycles by caching pre-created content. ASP.NET lets you cache objects on a per-page and per-control basis. But you can go beyond that by creating custom caching schemes where pages are cached on the basis of any value you choosefor example, the users browser type and version. When youre building a localized Web site, the need for caching and performance enhancement is no less of an issue. Using the features of ASP.NET, you can build an application that not only provides good localization features but also maintains the benefits of the caching that it provides. However, make sure that you dont run out of memory. The sample (see Solution 18) applies output caching to each news article on the site. Although you can realize tremendous performance gains from caching, its important to understand that theres a downsidethe memory required to store the cached pages. In fact, if the site supports a large number of articles and cultures, the output cache requirements could be huge. If the cache exceeds the available RAM, the server is forced to pull content from the database regularly, thereby negating the benefits of caching content. When caching, its extremely important to ensure that the cache is large enough to support the data being cachedin this case, you would need to ensure that the machine had enough RAM to hold all the cached pages.

Dont Forget Content Management


Finally, despite the fact that this solution focuses on content delivery, in a real application you also need to implement interfaces to add, edit, and delete or hide content located in a database. Dont underestimate the amount of work involved; its common with content-management systems for the back-end interfaces to take much longer to develop than the frontend, because the back-end often must do much more work with the data.

294

ASP.NET Solutions

SOLUTION

20
SOLUTION

Managing Focus in Web Forms


PROBLEM

Web Forms give you much of the convenient drag-and-drop GUI-building interface of Windows Forms, but theres no simple way to cause a given control to get the focus, particularly after a postback. How can I ensure that a specific control will get the focus on my Web Forms?

You need to write some clientside code to assign the focus to a specific control. However, you dont have to write it manually. Instead, you can build this FocusManager custom Web control, which lets you set the focus from the server at either design time or runtime.

Distributed applications with thin-client, HTML-driven user interfaces have many advantages, but fine-grained developer control of the interface is not among them. For example, suppose you have a form that has multiple controls and validates data via postback to the server when the user leaves the control. When that happens, you want to set the focus to the next logical control. Or suppose a user posts a form containing invalid data, and a datavalidation error occurs on the server. In this case, you would want to set the focus back to the control containing the invalid data. Unfortunately, even with all the properties and methods built into Web Forms, theres no simple method for determining which control gets the focus when a Web Form loads. Sure, you can play around with the z-order programmatically to force a control to get the focus, or you can manually write JavaScript code to set the focus, but either solution can be difficult and awkward to maintain. Fortunately, theres more than one way to solve this problem. Despite the lack of a serverside Focus property on Web controls, you can create your own FocusManager custom Web control that sets the focus to any other control on the page that can gain the focus. You specify which control should receive the focus with a String property called BoundControlID.

Creating a Custom Web Control


You can build this control in a separate Web control library project (I keep a project library specifically for Web custom controls). The design will have two criteria:

After building the project, you want to be able to drag-and-drop the control onto a Web Form at design time, and then set the BoundControlID property to the ID of any other control on the form that can receive the focus.

Solution 20 Managing Focus in Web Forms

295

From server-side code, you want to be able to set the BoundControlID property programatically, giving you the ability to change the focus on the client page from the server.

Create a new Web control library project named RJWebControls. Visual Studio .NET (VS.NET) creates the new project with a default Web control class. Rename the default class to FocusManager.vb. Open the FocusManager.vb class file in the designer. When VS.NET creates the class file, it inserts the following Imports statements for you automatically and provides a default class definition:
Imports System.ComponentModel Imports System.Web.UI <DefaultProperty(Text), _ ToolboxData(<{0}:WebCustomControl1 _ runat=server></{0}:WebCustomControl1>)> _ Public Class WebCustomControl1 Inherits System.Web.UI.WebControls.WebControl

VS.NET names the default class WebCustomControl. The first thing you should do is change the class name from WebCustomControl to FocusManager wherever it appears. After making the change, the class definition will look like this:
<DefaultProperty(Text), ToolboxData(<{0}:FocusManager runat=server></{0}:FocusManager>)> _ Public Class FocusManager ...more code here

By default, custom Web controls inherit from the System.Web.Control class, but you can inherit your custom control from either System.Web.UI.WebControl or System.Web.UI.Control. The difference is most apparent in the items that appear in the Properties window when you drop your custom control on a form. Custom controls that inherit from the WebControl class automatically inherit user interface properties such as Font, BackColor, ForeColor, Height, and Width. However, for this project, you dont need those properties; the FocusManager control will be invisible at runtimeit has no visual componentsso you dont need the inherited visual properties, and you can inherit from the simpler Control class instead:
<DefaultProperty(Text), ToolboxData(<{0}:FocusManager runat=server></{0}:FocusManager>)> _ Public Class FocusManager Inherits System.Web.UI.Control ... implementation here End Class

296

ASP.NET Solutions

VS.NET provides two attributes on the default class definition: DefaultPropertyAttribute and ToolboxDataAttribute.

Attribute classes go by two names in VS.NET. The full name of each built-in attribute class follows the pattern NameAttribute, but you can leave off the Attribute portion of the class name for any of the built-in attribute classes. For example, youll usually see the ToolboxDataAttribute class called simply ToolboxData. The DefaultProperty attribute controls the class property that the .NET Framework attempts to access when a user neglects to specify a property name. The ToolboxData attribute controls how the designer renders the default tag for your control when a user drags it from the Toolbox to the design surface. The {0} is a placeholder. When you drop the control at design time, VS.NET replaces the {0} with the text following the colonin this case, just the class name, FocusManager. For the FocusManager, the generated HTML looks like this:
<cc1:FocusManager runat=server></cc1:FocusManager>

It turns out that you can remove the ToolboxData attribute altogether with no change in the generated HTML; however, Ive left it alone in the example. The ToolboxData attribute becomes useful when you need to add default attributes and values to the HTML that VS.NET generates for your custom control.

Adding Control Properties


Based on the design criteria, you need some way to provide the FocusManager control with the ID of the control to which you want to set the focus. The simplest solution is to create a String property that accepts the name of another control. At runtime, the FocusManager control sets the initial-page focus to the control with that ID. You want the property available at both design time and at runtime, and you want people using your control to be able to access it, so it must be a public property. The default WebControl class generated by VS.NET already contains a String property called Text that you can use as a model. Again, the default method definition contains several attributes. Delete the default Text property and the _text variable declaration, and substitute the following code to create a BoundControlID property:
Add this line inside the Class definition Private mBoundControlID as String Replace the Text property code with this BoundControlID property code <Bindable(True), Category(Appearance), _ DefaultValue()> _ Property BoundControlID() As String Get Return mBoundControlID End Get

Solution 20 Managing Focus in Web Forms

297

Set(ByVal Value As String) mBoundControlID = Value End Set End Property

The Bindable attribute determines whether developers can bind a control to data. Although the sample code doesnt use binding, you can leave the attribute in place. The Category attribute controls the category in which the property appears in the Properties window in the VS.NET designer. While Appearance may not be the best choice, none of the others seems to apply any betterand it appears at the top of the Properties window when you arrange the properties by category, a big plus for example code. The DefaultValue property needs no explanation. For the FocusManager control, you dont need a default value, because you dont know what other controls might be on the page or which of those should get the focus.

Overriding the Render Method


The Render method controls the output of your control at runtime. When the ASP.NET Framework calls Render, it provides an HTMLTextWriter instance that you use to output (render) the HTML for your control. For the FocusManager, you want to output a client-side JavaScript method that executes when the page loads and sets the focus to the control with the ID specified for the Bound ControlID property. So the first thing you want to do is check to see whether the user set the BoundControlID property at all. If so, you should check to make sure it was set to a valid control. However, the control itself is in a different namespace than the Web Form containing it. Fortunately, the Control class provides a Page property that returns the containing Page object. If either condition fails, the control doesnt write any script. You may want to alter the default so that the control does something else, but doing nothing seems safest in this particular caseand its certainly the least intrusive option. When the BoundControlID contains a valid ID, you create the script. However, you dont use the provided HtmlTextWriter object to write it; you use the Page.RegisterStartupScript method instead. This is a little tricky because, by default, the method doesnt appear in the IntelliSense list for the Page object when youre working in the custom control project. Nevertheless, the RegisterStartupScript method compiles and runs just fine. The RegisterStartupScript method writes a client-side script inside the server-side (<form runat=server>) tag of the Web Form just before the closing </form> tag. That script placement ensures that the browser will already have instantiated the other controls on the page before your script runs. This means that you can reference other controls that appear within the <form> tag without testing first to see whether theyre available. Note that

298

ASP.NET Solutions

that does not absolve you from the responsibility for testing if your script attempts to access controls not placed within the server-side <form> tag. Heres the code for the Render method:
Protected Overrides Sub Render(ByVal output As _ System.Web.UI.HtmlTextWriter) Dim s As String If Not mBoundControlID Is Nothing Then If Not Me.Page.FindControl(mBoundControlID) _ Is Nothing Then s = <script type=text/javascript> s += document.getElementById( & _ mBoundControlID & ).focus(); s += </script> Me.Page.RegisterStartupScript _ (FocusManager, s) End If End If End Sub

Note the class and method attributes in the preceding code, and the fact that the class inherits from System.Web.Control. The class exposes a public BoundControlID property that appears in the VS.NET designer. The overridden Render method writes client-side JavaScript that sets the focus to the control ID assigned to the BoundControlID property. Listing 1 contains the full source code for the FocusManager.vb class.

Listing 1

The VB.NET source code for the FocusManager class (FocusManager.vb)

Imports System.ComponentModel Imports System.Web.UI <DefaultProperty(Text), _ ToolboxData(<{0}:FocusManager runat=server & _ DefaultColor=blue</{0}:FocusManager>)> _ Public Class FocusManager Inherits System.Web.UI.Control Private mBoundControlID As String Private mDefaultColor As String <Bindable(True), Category(Appearance), _ DefaultValue()> _ Public Property BoundControlID() As String Get Return mBoundControlID End Get Set(ByVal Value As String) mBoundControlID = Value End Set

Solution 20 Managing Focus in Web Forms

299

End Property <Bindable(True), Category(Appearance), _ DefaultValue()> _ Public Property DefaultColor() As String Get Return mDefaultColor End Get Set(ByVal Value As String) mDefaultColor = Value End Set End Property Protected Overrides Sub Render(ByVal output As _ System.Web.UI.HtmlTextWriter) Dim s As String If Not mBoundControlID Is Nothing Then If Not Me.Page.FindControl(mBoundControlID) _ Is Nothing Then s = <script type=text/javascript> s += document.getElementById( & _ mBoundControlID & ).focus(); s += </script> Me.Page.RegisterStartupScript _ (FocusManager, s) End If End If End Sub End Class

Adding the FocusManager Control to the Toolbox


At this point, save your work and build the RJWebControls project. Fix any errors before you continue. You need the resulting DLL file so that you can add a reference to it in a test project. You also need it to add the FocusManager control to the Toolbox. Next, build a quick project to test the FocusManager control. Create a new ASP.NET project and name it whatever you like. Add a reference to the RJWebControls project. To do that, right-click on the References item in the Solution Explorer and then browse to the DLL for the RJWebControls project. Youll find the RJWebControls.dll file in the bin subdirectory of your project folder. Add a new Web Form and name it FocusMgrTest.aspx. Then, add a couple of TextBox controls or Button controls to the Web Form. You want to place an instance of the FocusManager control on the Web Form. Although you can do that without adding it to the Toolbox, its much more convenient to simply drag it onto the design surface just as you do with any other control. To do that, select the Web

300

ASP.NET Solutions

Forms tab on the Toolbar, right-click inside the Toolbox, and select Add/Remove Items from the pop-up menu. When the Customize Toolbox dialog box opens, click the .NET Framework Components tab to see a list of available components (see Figure 1). The Customize Toolbox dialog box lets you select both COM components and .NET Framework components to add to the Toolbox; however, your custom components dont appear in the .NET Framework Components list unless you specifically add them. To do that, click the .NET Framework Components tab, and then click the Browse button and navigate to the bin directory of the RJWebControls project. Select the RJWebControls.dll file, and then click the Open button. VS.NET adds a FocusManager item to the list of available .NET Framework components. After adding the DLL, scroll through the list until you find the FocusManager item. Make sure its checked, and then click OK to close the dialog box. VS.NET adds the FocusManager control to the bottom of the Toolbox Web Forms control list.

Testing the FocusManager Control


To test your work, drag and drop a FocusManager control onto your Web Form. Click on the FocusManager control and set its BoundControlID property to the ID of some other control on the form that you want to receive the focus at startup. Thats it. Save your Web Form and run it. If everything works, the control you specified in the
BoundControlID property of the FocusManager control will receive the focus when the Web

Form loads. If you right-click in the browser window and select View Source from the pop-up menu, you can see the JavaScript that the control emits during the Render event; for example:
<script type=text/javascript> document.getElementById(SomeControlID).focus(); </script>

FIGURE 1:
The Customize Toolbox dialog box

Solution 20 Managing Focus in Web Forms

301

You can also set the BoundControlID property from code so that you can use the control to determine where the focus occurs after a postback event occurs. For example, if you want to set the focus to a control named TextBox1, add this code in the Page_Load event for your Web Form:
FocusManager1.BoundControlID = TextBox1

Using the syntax shown here, you can use server-side code to set the focus to any control on your page that can receive focus. The sample code for this project includes a Web Form that lets you test the control interactively. Listing 2 contains the HTML for the .aspx file (FocusMgrTest.aspx), and Listing 3 contains the code-behind class module (FocusMgrTest.aspx.vb). In Listing 2, the @Register directive controls the tag prefix and specifies the namespace and assembly for the FocusManager control. The <cc1:FocusManager> tag is the HTML representation of the control.

Listing 2

The VS.NET-generated HTML code for the FocusMgr Test Web Form (FocusMgrTest.aspx)

<%@ Page Language=vb AutoEventWireup=false Codebehind=focusMgrTest.aspx.vb Inherits=aspnetexamples.focusMgrTest%> <%@ Register TagPrefix=cc1 Namespace=RJWebControls Assembly=RJWebControls %> <!DOCTYPE HTML PUBLIC -//W3C//DTD HTML 4.0 Transitional//EN> <HTML> <HEAD> <title>WebForm1</title> </HEAD> <BODY> <meta content=Microsoft Visual Studio.NET 7.0 name=GENERATOR> <meta content=Visual Basic 7.0 name=CODE_LANGUAGE> <meta content=JavaScript name=vs_defaultClientScript> <meta content= http://schemas.microsoft.com/intellisense/ie5 name=vs_targetSchema> <form id=Form1 method=post runat=server> <asp:button id=Button1 style=Z-INDEX: 101; LEFT: 247px; POSITION: absolute; TOP: 123px runat=server Height=24px Text=Button 1 Width=108px></asp:button> <asp:button id=Button2 style=Z-INDEX: 102; LEFT: 247px; POSITION: absolute; TOP: 199px runat=server Height=24px Text=Button 2 Width=108px></asp:button>

302

ASP.NET Solutions

<asp:button id=Button3 style=Z-INDEX: 103; LEFT: 243px; POSITION: absolute; TOP: 276px runat=server Height=24px Text=Button 3 Width=108px></asp:button> <asp:textbox id=TextBox1 style=Z-INDEX: 104; LEFT: 34px; POSITION: absolute; TOP: 70px runat=server Height=22px Width=181px></asp:textbox> <asp:listbox id=ListBox1 style=Z-INDEX: 105; LEFT: 36px; POSITION: absolute; TOP: 118px runat=server Height=211px Width=174px> <asp:ListItem Value=Item 1> Item 1</asp:ListItem> <asp:ListItem Value=Item 2> Item 2</asp:ListItem> <asp:ListItem Value=Item 3> Item 3</asp:ListItem> <asp:ListItem Value=Item 4> Item 4</asp:ListItem> <asp:ListItem Value=Item 5> Item 5</asp:ListItem> <asp:ListItem></asp:ListItem> </asp:listbox> <asp:label id=Label1 style=Z-INDEX: 106; LEFT: 44px; POSITION: absolute; TOP: 41px runat=server Height=20px Width=441px> Enter the ID of a control ( ListBox1, Button1, Button2, Button3)</asp:label> <asp:button id=btnGo style=Z-INDEX: 107; LEFT: 229px; POSITION: absolute; TOP: 70px runat=server Height=23px Text=Go Width=65px></asp:button> <HR style=Z-INDEX: 108; LEFT: 18px; WIDTH: 112.66%; POSITION: absolute; TOP: 103px; HEIGHT: 2px width=112.66% SIZE=2> <cc1:FocusManager id=FocusManager1 runat=server></cc1:FocusManager> </form> </BODY> </HTML>

In Listing 3, the btnGo_Click event handler shows how you can use the FocusManager control to set client-side focus programmatically from the server with a single line of code.

Solution 20 Managing Focus in Web Forms

303

Listing 3

The code-behind module for the FocusMgrTest Web Form (FocusMgrTest.aspx.vb)

Public Class focusMgrTest Inherits System.Web.UI.Page Protected WithEvents Label1 As _ System.Web.UI.WebControls.Label Protected WithEvents TextBox1 As _ System.Web.UI.WebControls.TextBox Protected WithEvents btnGo As _ System.Web.UI.WebControls.Button Protected WithEvents Button1 As _ System.Web.UI.WebControls.Button Protected WithEvents Button2 As _ System.Web.UI.WebControls.Button Protected WithEvents Button3 As _ System.Web.UI.WebControls.Button Protected WithEvents FocusManager1 As _ RJWebControls.FocusManager Protected WithEvents ListBox1 As _ System.Web.UI.WebControls.ListBox #Region Web Form Designer Generated Code This call is required by the Web Form Designer. <System.Diagnostics.DebuggerStepThrough()> _ Private Sub InitializeComponent() End Sub Private Sub Page_Init(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Init CODEGEN: This method call is required by the Web Form Designer Do not modify it using the code editor. InitializeComponent() End Sub #End Region Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Put user code to initialize the page here Button1.Attributes.Add(onfocus, _ alert(Button1 got the focus);) Button2.Attributes.Add(onfocus, _ alert(Button2 got the focus);) Button3.Attributes.Add(onfocus, _ alert(Button3 got the focus);) ListBox1.Attributes.Add(onfocus, _ alert(ListBox1 got the focus);) End Sub

304

ASP.NET Solutions

Private Sub btnGo_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnGo.Click If Me.TextBox1.Text <> Then FocusManager1.BoundControlID = TextBox1.Text End If End Sub End Class

To use the code, create a test project; unzip the files into a directory on your server, and then add the FocusMgrTest.aspx, FocusMgrTest.aspx.vb, and FocusMgrTest.aspx.resx files to your test project. You may need to alter the code-behind file for the btnGo_Click event so that the code matches the ID you provide for the control. By default, VS.NET will assign the ID FocusManager1. If you change that, youll need to change the code-behind file as well. All the code does is assign the string you enter in the TextBox to the BoundControlID property of the FocusManager control you added to the page:
Private Sub btnGo_Click(ByVal sender As _ System.Object, ByVal e As System.EventArgs) _ Handles btnGo.Click If Me.TextBox1.Text <> Then FocusManager1.BoundControlID = TextBox1.Text End If End Sub

To use the FocusMgrTest page, run it, type the ID of one of the other controls on the page into the TextBox control at the top of the page, and post the form by clicking the Go button (see Figure 2). Clicking the Go button posts the form to the server and sets the BoundControlID property of the (invisible at runtime) FocusManager control, which then emits the script to set the focus to the control whose ID you entered. The Go button performs a postback, which fires the btnGo_Click event handler on the server. This event in turn sets the focus to the control with the ID you typed into the text box. Each control on the page below the line on the form displays a JavaScript alert when it receives the focus, so after clicking the Go button youll see an alert, as shown in Figure 3, letting you know that the FocusManager is doing its job properly. After you enter a control ID and click the Go button, the server replies with the script that sets the focus to the referenced control. For test purposes, each of the buttons and the ListBox display a JavaScript alert when they receive the focus. Now that you know the basic process of creating custom Web controls, you can extend your VS.NET environment with custom controls that write content and script for you automatically.

Solution 20 Managing Focus in Web Forms

305

FIGURE 2:
The FocusMgrTest Web Form

FIGURE 3:
JavaScript alert showing a control that receives the focus

306

ASP.NET Solutions

SOLUTION

21
SOLUTION

The Missing Message Boxes in ASP.NET


PROBLEM

Although ASP.NET lets you specify the layout and behavior of many browser features from the server, it doesnt provide an easy way to pop up a message box.

Using a combination of Internet Explorers showModalDialog method, a custom Web server control, and some client-side JavaScript and VBScript, you can design your message boxes on the server, like other ASP.NET Web controls. Doing so lets you create custom dialog boxes that look and function much like Windows Forms message boxes, or that use your own custom design.

WARNING This solution works only with Microsoft Internet Explorer browsers. As written, it does not
work with any other browser.

A message box is a modal dialog window in which you can display a text message to users in such a way that they cant continue with your application until after they close the dialog. A modal dialog captures input for an application, effectively disabling the window that launched it while the dialog is active. Message boxes are extremely convenient for displaying information when you need user confirmation. This solution shows you how to create a custom Web server control that gives you the capability of defining message boxes at design time, without writing client-side script.

MessageBox Features
In Windows Forms applications, you can display a message box using the MessageBox class, passing the message to display as a string argument:
MessageBox.Show(10 Minute Solution)

The preceding line of code displays the string 10 Minute Solution in a simple default dialog with no title and an OK button (see Figure 1). The Show method has a number of overloaded constructors that let you specify the title, an icon (such as a question mark or a stop sign), and a set of buttons. Heres a more complicated

Solution 21 The Missing Message Boxes in ASP.NET

307

version of the Show method, which displays a message box with a specific title, icon, and set of buttons (see Figure 2).
MessageBox.Show(A custom message., _ Custom Title, _ MessageBoxButtons.YesNoCancel, _ MessageBoxIcon.Exclamation)

The MessageBoxIcon enumeration controls the set of buttons available. Among your choices are the values OK (the OK button onlythe default), OKCancel (OK and Cancel buttons), YesNo (Yes and No buttons), and so forth. The ability to display multiple buttons wouldnt do you any good unless you could retrieve a value representing the button the user selected; therefore, the MessageBox.Show method returns one of the DialogResult enumeration values, which have names matching the buttons, such as Yes, No, Cancel, Abort, Retry, etc. The DialogResult value is None for message boxes the user closes without clicking a button. Overloaded versions of the MessageBox.Show method let you specify which button is the default button (the one clicked if the user simply presses Enter when the message box appears). Finally, the MessageBoxIcon enumeration lets you specify one of a set of icons to display on the message box. Traditionally, the icon you use should reflect the immediacy or importance of the message. For example, you should use an information icon to provide users with extra information, an exclamation icon to emphasize that users should pay extra attention to the message, a stop icon to provide information about fatal or dangerous actions, and a question mark icon when youre using the message box to ask a question.

Message Boxes in Client-Side Script


Both VBScript and JavaScript have features that let you display modal dialog message boxes. VBScripts MsgBox function works much like the MessageBox.Show method in .NET. You can specify a title, a message, a set of buttons, the default button, and an icon; however, you cant FIGURE 1:
A simple message box

FIGURE 2:
A more complicated message box

308

ASP.NET Solutions

control the size or position of the dialog. The message box looks and acts much like the standard Windows Forms message box. The MsgBox function returns a constant value that represents which button was clicked. JavaScript (and Im including client-side JScript in that term) supports the alert method, which lets you specify only a messageyou cant specify a title, an icon, or button information. The alert method pops up a message box with an exclamation icon and (in Internet Explorer) a title of Microsoft Internet Explorer. You cant change the title, and the alert method doesnt return a value. Instead, JavaScript also supports a confirm method, which displays a message box with OK and Cancel buttons, and returns true when a user clicks the OK button or false when a user clicks the Cancel button (or closes the dialog if no button is clicked). Listing 1 shows an HTML page that displays all three typesJavaScript alert and confirm message boxes, and a VBScript-generated message box using the MsgBox function.

Listing 1

HTML alert, confirm, and MsgBox examples (messagebox.htm)

<html><body> <label for=info_jscript>JScript result</label> <div id=info_jscript>JavaScript confirm result will appear here.</div><br><br> <label for=info_vbcript>VBScript result</label> <div id=info_vbscript>VBScript MsgBox result will appear here.</div><br> <script type=text/javascript> alert(Hi. This is a JavaScript alert message!); response = confirm(This message box was displayed + using the JavaScript confirm() method.); if (response==true) { info_jscript.innerText = You clicked the OK button.; } else { info_jscript.innerText = You clicked Cancel, + pressed Esc, or closed the dialog without + selecting a button.; } </script> <script type=text/vbscript> dim info select case msgbox(Hello from VBScript, _ 1,Custom Title) case 1 OK button info= You clicked the OK button.

Solution 21 The Missing Message Boxes in ASP.NET

309

case 2 info= You clicked Cancel, pressed Esc, or & _ closed the dialog without selecting a button. case else info= You didnt select either the OK or & _ Cancel button. end select info_vbscript.innerText = info </script> </body></html>

As you can see, Internet Explorer lets you show modal dialog boxes using client-side script; however, thats not nearly as convenient as being able to design message boxes on the server at design time, specifying messages in the Properties window the same way you specify the contents of a TextBox or the properties of a Button. The solution is to build your own custom MessageBox Web server control.

Building a MessageBox Web Server Control


To be useful, a custom Web server MessageBox control should meet these needs:

You should be able to define the message, icon, title, and buttons at design time (or with .NET code) from the server. The control should give you the ability to pop up a message box on the client whenever some event occurs. The resulting message box should be modaldisabling the browser window until the user respondsand should be at least as customizable as standard Windows Forms message boxes. You can display a modal dialog from Internet Explorer in several ways: You can take advantage of the VBScript MsgBox command, as youve seen. You can use the JavaScript alert and confirm functions. Unfortunately, alert and confirm are useless for all but the simplest of dialogs, because you cant change the dialog window title (it always reads Internet Explorer), and you cant control the icon or buttons beyond OK or Yes/No, either. For these reasons, Ill ignore the alert and confirm functions for the rest of this solution. You can use the window objects showModalDialog function to display a Web page in a modal window. For example, you would use this if you needed your company logo on every dialog. Although you have to create the displayed Web page yourself, this method is the most flexible. It gives you the option of building a generic page that handles most simple message box needs while still letting you build a custom page to handle more complicated dialogs.

310

ASP.NET Solutions

The WebMessageBox control youll build in the rest of this solution meets the goals outlined in this section. It lets you create and display either client-side VBScript-driven message boxes or custom modal dialogs that use the showModalDialog method.

Building the WebMessageBox Control


A Web custom control is a dynamic link library (DLL) that exposes a class for holding the properties and performing the actions you need. The class should have the ability to generate design time HTML that an integrated development environment (IDE) can use to display the control on a Web Forms design surface, as well as runtime HTML and script rendered appropriately for a browser. The design and runtime dont necessarily have to be the same HTML. For example, a custom control may have a visual interface at design time but not at runtime. To create the custom Web server control in Visual Studio, create a new Web Control Library project. Name the project WebMsgBox, and rename the default class from WebControl1 to WebMessageBox.

WebMessageBox Properties
Youll need to be able to specify the values of several properties at design time, such as the text message, the title, the buttons that should appear, and an icon, among others. Start with the Message and Icon properties. Add two member string variables to the WebMessageBox class named mMessage and mTitle. These variables will hold the message and title strings.
Private mMessage As String Private mTitle As String

Now, add properties for setting and retrieving the message and title (see Listing 2).

Listing 2

The Message and Title properties (WebMessageBox.vb)

<Bindable(True), Category(Appearance), _ DefaultValue()> _ Public Property Message() As String Get Return mMessage End Get Set(ByVal Value As String) mMessage = Value End Set End Property <Bindable(True), Category(Appearance), _

Solution 21 The Missing Message Boxes in ASP.NET

311

DefaultValue()> _ Public Property Title() As String Get Return mTitle End Get Set(ByVal Value As String) mTitle = Value End Set End Property

The attributes decorating these properties specify that you can bind them to a data source, that the properties should appear in the Appearance section of the Properties window, and that the default value for each property is an empty string. You also need some way to specify the set of buttons and an icon. To do that, add two public enumerationsWebMessageBoxButtonEnum and WebMessageBoxIconEnumwhich contain values for options equivalent to standard Windows message boxes (see Listing 3).

Listing 3

Public enumerations in the WebMsgBox project (WebMessageBox.vb)

Public Enum WebMessageBoxIconEnum Icon_Information Icon_Stop Icon_Question Icon_Exclamation End Enum Public Enum WebMessageBoxButtonEnum Button_AbortRetryIgnore Button_OK Button_OKCancel Button_RetryCancel Button_YesNo Button_YesNoCancel End Enum

After defining these enumerations, you can add Button and Icon properties, each of which takes one of the appropriate enumeration values (see Listing 4).

Listing 4

Button and Icon properties in the WebMessageBox class (WebMessageBox.vb)

<Bindable(True), Category(Appearance), _ DefaultValue()> _ Public Property Icon() As WebMessageBoxIconEnum Get Return mIcon

312

ASP.NET Solutions

End Get Set(ByVal Value As WebMessageBoxIconEnum) mIcon = Value End Set End Property <Bindable(True), Category(Appearance), _ DefaultValue()> _ Public Property Buttons() As WebMessageBoxButtonEnum Get Return mButtons End Get Set(ByVal Value As WebMessageBoxButtonEnum) mButtons = Value End Set End Property

Every Web server control renders HTML for the design surface (by default, controls display their name) and for the custom control tag in the .aspx file HTML. For example, if you drop a WebMessageBox control onto a Web Form and then look at the HTML view, youll see a tag that looks something like this:
<cc1:WebMessageBox id=WebMessageBox1 style=Z-INDEX: 103; LEFT: 119px; POSITION: absolute; TOP: 299px runat=server></cc1:WebMessageBox>

As you set the controls properties in the Properties window, the Framework adds attributes to the custom control tag that match the property names. Theres a hurdle to cross, however. Even though a property may not be a string type, the custom tag representation can use only strings. Therefore, something has to perform a conversion between the type and the string representation for that type. Fortunately, when you add common property types, such as numbers, strings, or enumeration properties, the .NET Framework handles the translation to and from the enumeration type to the tag attributes in the .aspx file. In addition, the Framework automatically adds common types to the Properties window for the control. So, for example, when you drag your control onto a Web Form, you can select it and see the Icon property in the Properties window. It will appear as a drop-down list containing the string representation of the WebMessageBoxIcon enumeration. The result is that when you set the Icon property at design time to Icon_Stop, the IDE adds the attribute icon=Icon_Stop to your custom control tag, which would now look something like this:
<cc1:WebMessageBox id=WebMessageBox1 style=Z-INDEX: 103; LEFT: 119px; POSITION: absolute; TOP: 299px runat=server icon=Icon_Stop></cc1:WebMessageBox>

Solution 21 The Missing Message Boxes in ASP.NET

313

Because you can specify the size and position of Web pages you display via the window.show ModalDialog command, you also need properties for Left, Top, Width and Height. All custom controls inherit Width and Height properties from the base WebControl class, so you dont have implement those, but you do have to add Left and Top properties (see Listing 5).

Listing 5

The Left and Top properties (WebMessageBox.vb)

<Bindable(True), Category(Appearance), _ DefaultValue()> Public Property Top() As String Get Return mTop.ToString End Get Set(ByVal Value As String) mTop = Unit.Parse(Value) End Set End Property <Bindable(True), Category(Appearance), _ DefaultValue()> Public Property Left() As String Get Return mLeft.ToString End Get Set(ByVal Value As String) mLeft = Unit.Parse(Value) End Set End Property

Unlike the string properties and enumerations youve seen so far, these properties translate values back and forth from units to strings. The properties use the Unit.ToString method to translate a unit to a string representation, and they use the Unit.Parse method to translate the string entered by the user back into a unit You need three other properties (because theyre extremely similar to the properties already shown, I wont show the code for them): You need a property so that control consumers can specify whether they want to show a client-side (VBScript) or a server-side (showModalDialog) message box. The UseClientSideMessageBox property fulfills that need.
UseClientSideMessageBox Args The showModalDialog function takes an optional arguments parameter. You can set the arguments value by using the Args property. DefaultButton

You should be able to specify a default button for message boxes. The WebMessageBox class has a DefaultButton property. You can always set or retrieve this property, but it works only on client-side message boxes generated with VBScript, not on dialogs displayed with the showModalDialog function. The DefaultButton property accepts and returns one of the WebMessageBoxDefaultButtonEnum enumeration values.

314

ASP.NET Solutions

You can use the same attributes shown earlier to decorate all the properties, giving them a default value, if you like (although I didnt in the sample project that accompanies this solution). Although I left all the Category attributes set to Appearance, you may want to rearrange them so that they show up in other sections of the Properties window.

How the WebMessageBox Works


A custom control isnt much good if it doesnt do anythingand to do something, it has to render some HTML. At runtime, the control should gather all the property settings and translate those into a combination of HTML and client-side script that will either pop up a VBScript MsgBox or call the window.showModalDialog method. You need to be able to invoke the message box from any other control or event on the Web Form. Its relatively easy to take a server control and render VBScript that will pop up a message box. For example, if you define a message box that has MyMessage for a message and MyTitle for a title, you just concatenate a string to hold the following script:
<script language=VBScript> Function showMessageBox() showMessageBox = MsgBox(MyMessage, 1, MyTitle) End Function </script>

That works perfectlyfor the first message box. But what about the second message box, or the third? What would you call the function to display those message boxes? You could append a value to the function name, and simply write the function anew for each different WebMessageBox control on the pagefor example, showMessageBox1, showMessageBox2, and so on. Thats messy, though, and requires duplicate code. Instead, you should create a generic function to display the message boxes. So you refactor, and then write a more generic showClientMessageBox function that doesnt contain any hard-coded message, button, or title information:
<script type=text/vbscript> Function showClientMessageBox(msg, options, title) showClientMessageBox = MsgBox(msg, options, title) End Function </script>

That will work fine. Now, how can you pass the title and message information to this function so that it can display the dialog? You could get the user to specify the control and the event that should invoke the message box, and write a bit of JavaScript there that would call the showClientMessageBox method and process the return value. For example, the user might specify Button1 and the Click event, and youd insert an onclick handler into the button. However, to do that, youd have to alter the rendering of the specified controlnot necessarily the best approach.

Solution 21 The Missing Message Boxes in ASP.NET

315

A better way is to write the onclick handler to a hidden button (<input type=button>) control that the WebMessageBox control generates dynamically. That way, you have complete control over the HTML for the button. The real beauty of this method is that:

You can give the rendered button control the same ID as your WebMessageBox. You can click the button programmatically by calling its click() method from any other control.

The button passes the message box information to the showClientMessageBox function, which then displays the dialog. This scheme works no matter how many WebMessageBox server controls you drop on a form. Using this scheme, when the WebMessageBox control renders its HTML for the browser, you want it to appear as a hidden <input> tag containing a call to the showClientMessageBox function. For example, heres how the control might render for the browser:
<input type=button id=WebMessageBox1 value=WebMessageBox1 name=WebMessageBox1 style=display:none onclick=VBScript:MessageBoxReturnValue= showClientMessageBox(A Message!, 37,A Title)>

Now, you can display the message box by calling the click event for the hidden button from anywherefor example ,from a visible button on the form:
<input type=button onclick=showMessageBox(WebMessageBox1);>

When you click that button, it fires the showMessageBox function, which fires the click event (programmatically). That calls the showClientMessageBox function, which displays the message box. Theres still a problem, however. The showClientMessageBox function dutifully returns the value from the MsgBox call, but you probably dont want to write the logic into the event handler for whatever event caused the message box to appear. Instead, create a client page-level script variable. You can store the return value there, and then call a method to process the result. The WebMessageBoxReturnValue variable holds the return value from the last WebMessageBox displayed. Heres the logic: 1. A WebMessageBox control on the server renders as an <input> tag containing an onclick handler that makes a call to a showClientMessageBox function, passing the necessary message information. 2. The showClientMessageBox function displays the dialog and stores the result in the WebMessageBoxReturnValue variable. 3. The WebMessageBox control writes a generic showMessageBox(anID) function that displays any WebMessageBox when you pass its ID.

316

ASP.NET Solutions

From a development viewpoint, the control consumer can drag a WebMessageBox control onto a new Web Form, set its properties, and display it on the client with one line of code:
showMessageBox(WebMessageBox1);

With the client-side message box working, you can turn to the showModalDialog method. The logic scheme for that is similar. Remember that the showModalDialog method makes a request back to a Web server to get the HTML to display. The method can take three parameters:

The URL of the Web page to display modally (required). A Variant containing any type of data, including an array (optional). A string containing a set of features that describe the size, position, and decorations (such as menu bar, status bar, scrolling capability, etc.) of the dialog window. Each feature is a name=value pair. You separate the features with semicolons. For example, a call to showModalDialog might look like this:
returnValue = window.showModalDialog(somePage.htm,, dialogHeight: 300;dialogLeft: 50;dialogTop: \ 50;dialogWidth: 500;center: yes;dialogHide: no;edge: \ raised;help: no;resizable: no;scroll: yes;status: \ no;unadorned: no);

With this scheme in mind, you can implement the rendering for the WebMessageBox control.

Initializing the WebMessageBox


The control must write several generic scriptsclient-side code that might be used by several WebMessageBoxes placed on a form. It creates the code in the onInit event handler and renders it to the client using the Page.RegisterClientScriptBlock method. To use the method, create a string containing the script and pick a unique name for the script. You dont want to write these generic scripts more than once, though. Because there might be several WebMessageBox controls on a Web Form, you can make the first one write the generic scripts. You determine whether a script already exists by testing the Page.IsClientScriptBlockRegistered property, passing the unique name. The method returns True if the script has already been registered, or False if not. First, it writes a script that defines the global WebMessageBoxReturn variable (see Listing 6).

Listing 6

The Overridden OnInit method (partial listing)

Protected Overrides Sub OnInit(ByVal e As System.EventArgs) always create the return variable Dim s As String s = <script type=text/javascript> &

Solution 21 The Missing Message Boxes in ASP.NET

317

var WebMessageBoxReturnValue;</script> If (Not Me.Page. _ IsClientScriptBlockRegistered( _ WebMessageBoxVariables)) Then Me.Page.RegisterClientScriptBlock( _ WebMessageBoxVariables, s) End If ...

Next, it checks the value of the UseClientSideMessageBox property to determine whether it should render the script that launches a VBScript MsgBox or the script that fires the showModalDialog method (see Listing 7).

Listing 7

Determining which script to render (partial listing)

If Me.UseClientSideMessageBox = True Then render the showClientMessageBox function Dim sb As New StringBuilder sb.Append(<script type=text/vbscript> & vbCrLf) sb.Append(Function showClientMessageBox(msg, & _ options, title) & vbCrLf) message param sb.Append(WebMessageBoxReturnValue = MsgBox & _ (msg, options, title) & vbCrLf) translate return value into a string to make it easy to check sb.Append(If WebMessageBoxReturnValue = 1 Then & vbCrLf) OK button sb.Append( WebMessageBoxReturnValue = ok _ & vbCrLf) sb.Append(ElseIf WebMessageBoxReturnValue = 2 & Then & & vbCrLf) Cancel button sb.Append( WebMessageBoxReturnValue = & _ cancel & vbCrLf) sb.Append(ElseIf WebMessageBoxReturnValue = 3 & Then & vbCrLf) Abort button sb.Append( WebMessageBoxReturnValue = & _ abort & vbCrLf) sb.Append(ElseIf WebMessageBoxReturnValue = 4 & Then & vbCrLf) Retry button sb.Append( WebMessageBoxReturnValue = & _ retry & vbCrLf) sb.Append(ElseIf WebMessageBoxReturnValue = 6 & Then & vbCrLf) Yes button sb.Append( WebMessageBoxReturnValue = & _ yes & vbCrLf)

318

ASP.NET Solutions

sb.Append(ElseIf WebMessageBoxReturnValue = 7 & _ Then & vbCrLf) No button sb.Append( WebMessageBoxReturnValue = no & _ vbCrLf) sb.Append(End If & vbCrLf) sb.Append(showClientMessageBox = & _ WebMessageBoxReturnValue & vbCrLf) sb.Append(End Function & vbCrLf & vbCrLf) add a showMessageBox() function sb.Append(Function showMessageBox(anID) & vbCrLf) sb.Append( Dim button & vbCrLf) sb.Append( set button = & _ document.getElementById(anID) & vbCrLf) sb.Append( button.click & vbCrLf) sb.Append( showMessageBox = & _ WebMessageBoxReturnValue & vbCrLf) sb.Append(End Function & vbCrLf) sb.Append(</script>) s = sb.ToString Else render the showModalDialog version of showMessageBox s = <script type=text/javascript>function & _ showMessageBox(anID) & _ {document.getElementById(anID).click(); & _ if (WebMessageBoxReturnValue==null) & _ WebMessageBoxReturnValue=none; & _ return WebMessageBoxReturnValue;}</script> End If now register the script stored in the variable s If (Not Me.Page.IsClientScriptBlockRegistered( _ WebMessageBoxFunction)) Then Me.Page.RegisterClientScriptBlock( _ WebMessageBox, s) End If End Sub

The control also renders a hidden button that calls one of these scripts, in the overridden Render method (see Listing 8). NOTE
At this point, you may be wondering: Why doesnt he write all the rendering code in the Render method (which was designed expressly for that purpose)? The answer is that the RegisterClientScriptBlock method doesnt work in the Render method. It compiles and runs, but generates no output. There is a workaround, though; you can use the RegisterStartupScript method insteador you can move the rendering to another location, where RegisterClientScriptBlock works properly. For more information, see Victor Garcia Apreas Web log at http://weblogs.asp.net/vga/posts/6457.aspx.

Solution 21 The Missing Message Boxes in ASP.NET

319

Listing 8

The Overridden Render method (WebMessageBox.vb)

Protected Overrides Sub Render(ByVal output As System.Web.UI.HtmlTextWriter) If Me.UseClientSideMessageBox = True Then Dim dblQuote As Char = Chr(34) create an input control of type button that renders invisibly on the client Dim sb As New StringBuilder sb.Append(<input type=button id= & _ Me.ID & & value= & Me.ID & ) sb.Append( name= & Me.ID & ) sb.Append( style=display:none) sb.Append( onclick=VBScript: & _ WebMessageBoxReturnValue= & _ showClientMessageBox() sb.Append(dblQuote & Me.Message & dblQuote) Dim param2 As Integer = Me.calculateButtons() sb.Append(, & param2.ToString) sb.Append(, & dblQuote & Me.Title & dblQuote & _ )> & vbCrLf & vbCrLf) output.Write(sb.ToString) Else Dim sb As New StringBuilder sb.Append(<input type=button id= & _ Me.ID & & value= & Me.ID & ) sb.Append( name= & Me.ID & ) sb.Append( style=display:none) sb.Append( onclick= & _ JavaScript:WebMessageBoxReturnValue= & _ showModalDialog() sb.Append( & Me.URL & ?title= & _ context.Server.UrlEncode(Me.Title)) sb.Append(&message= & _ context.Server.UrlEncode(Me.Message)) sb.Append(&buttons= & Me.Buttons.ToString) sb.Append(&defaultButton= & _ Me.DefaultButton.ToString) sb.Append(&icon= & Me.Icon.ToString) sb.Append(&height= & Me.Height.ToString) sb.Append(&width= & Me.Width.ToString & ) sb.Append(, & Me.Args.ToString & ) sb.Append(, & Me.getFeatureString & );>) output.Write(sb.ToString) End If

Again, the code checks the value of the UseClientMessageBox property to determine which hidden button to construct. Both buttons are rendered invisibly on the client, but you can still click them programmatically, which fires the buttons onclick event handlerin this

320

ASP.NET Solutions

case, a line of VBScript or JavaScript code. The second buttons onclick handler constructs a set of query string parameters and appends them to the page URL set via the WebMessageBox controls URL property. The call to the calculateButtons function in Listing 8 simply runs a couple of Case blocks that calculate the value of the second parameter in the MsgBox call. That parameter is an integer combination of several constants that determine the set of buttons displayed in the message box, as well as which button should be the default.

Rendering Design-Time HTML


By default, Web custom controls display as a box containing the class name on a Web Forms design surface. If you want more control over how your control renders on a Web Form, create a custom Designer class. You can create the class in the same file. Your Designer class should inherit from the System.Web.UI.Design.ControlDesigner class. You want to override the GetDesignTimeHtml method, which returns a string of HTML that the IDE can use to render the control at design time. The WebMessageBox displays at design time as a buttonwhich seemed appropriate, since its a button at runtime too, albeit a hidden one. Listing 9 shows the complete class code.

Listing 9

The WebMessageBoxDesigner class (WebMessageBox.vb)

Public Class WebMessageBoxDesigner Inherits ControlDesigner Public Overrides Function GetDesignTimeHtml() As String Return <input type=button value= & & Me.ID & & name = & & Me.ID & & > End Function End Class

The class contains the overridden GetDesignTimeHtml method, which creates and returns the HTML for a simple input button. It uses the WebMessageBoxs ID property for the buttons name and value. You have to associate the new WebMessageBoxDesigner class with the WebMessageBox class. To do that, add a Designer attribute to the class:
<Designer(WebMsgBox.WebMessageBoxDesigner), _ etc. Public Class WebMessageBox ...

The Designer attribute takes one value: the full class name of the Designer class you want to use as the custom designer.

Solution 21 The Missing Message Boxes in ASP.NET

321

Adding the Control to the Toolbox


At this point, the control is ready to test. Compile it, and then find the WebMsgBox.dll file (usually in the projects bin folder). Fix any compile errors that occur. Create a new ASP.NET Web Application project (you can create it in the same solution as your WebMsgBox project). Load the default WebForm1.aspx Web Form into design view. Display the Toolbox and change to the Web Forms section. Right-click in that section and select Add/Remove Items from the context menu. Youll see a Customize Toolbox dialog box. Select the .NET Framework Components tab, and then click the Browse button in the lower-right corner. Browse to the WebMsgBox.dll file and click Open. That adds a checkbox for the WebMessageBox control. Make sure the checkbox is checked, and then click OK to close the Customize Toolbox dialog box. Youll see a gear icon with the label WebMessageBox appear in the Toolbox (you may have to scroll to find it).

Creating a Client-Side Message Box


Drag the WebMessageBox icon onto the Web Forms design surface. A button appears that has the caption WebMessageBox1 (see Figure 3). Select the WebMessageBox1 control, and then set the Message, Title, and Icon properties in the Properties window. Set the UseClientMessageBox property to True. NOTE
Be sure to set the UseClientMessageBox property to True; otherwise, the control will not work, because you dont have a Web page to display with the showModalDialog method.

FIGURE 3:
A WebMessageBox control at design time

322

ASP.NET Solutions

Finally, drag an HtmlButton onto the form. Set its Value property to Show Client MessageBox. Switch to HTML view in the editor, and add the following onclick event handler to the <input> tag for the button:
onclick=VBScript:MsgBox(showMessageBox(WebMessageBox1))

Save the file. In the Solution Explorer, right-click on the WebForm1.aspx file and set it as the Startup Page for the project. Run the project and click the Show Client Message Box button. Youll see a dialog similar to the one in Figure 4 (your message box will vary depending on how you set the properties). When you close the dialog, youll see the dialog return value in a second message box.

Creating a Server-side Message Box


So far, youve tested only half of the controls capabilities. You need to test it when the Use ClientSideMessageBox property is False, and to do that you need a Web page that functions as the content of the dialog. The MessageBox.aspx Web Form included with the sample project fulfills that purpose. Add the MessageBox Web Form to your ASP.NET project. The MessageBox.aspx.vb code-behind file contains quite a bit of code, but all it does is read the Request.QueryString parameters you constructed earlier in the Render method, setting the message, title, and buttons according to the values it retrieves from the query string collection. NOTE
This is a good time to point out a limitation of this scheme. As implemented, the values are appended to the URLand most browsers limit the total length of a URL to 2048 characters. That means that the total length of your title, message, and the concatenated features may not exceed the maximum URL length. In practice, because most message boxes contain relatively short messages, the length shouldnt be a big problem. However, if you plan to use this control in a production environment, add checks to ensure that the total length is acceptable, or use a different scheme entirely.

Drag a second WebMessageBox control onto WebForm1 in design mode, and set its Message and Title properties. This time, leave the UseClientSideMessageBox property set to False, and set the Width property to 400 and the Height property to 250. Drag a Web server Button control (not an HtmlButton) onto the Web Form, and set its
Text property to Show Server Message Box. This time, youll add the click event handler

dynamically. Name the button btnServer. Switch to the code-behind file and add a PreRender event handler, as shown here:
Private Sub btnServer_PreRender(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles btnServer.PreRender btnServer.Attributes.Add(onclick, showMessageBox(WebMessageBox2);) End Sub

Now save and run the project. Click the Show Server Message Box button. Youll see a dialog similar to Figure 5.

Solution 21 The Missing Message Boxes in ASP.NET

323

FIGURE 4:
The client-side message box at runtime

FIGURE 5:
A server-side message box at runtime

324

ASP.NET Solutions

Whats Next?
You should regard the WebMessageBox control more as a proof-of-concept than as a finished product. There are so many things that you could (and probably should) do to create a robust production version, including:

Adding error handling (high importance) Hiding the unused properties, such as BorderColor and BorderStyleor implementing them Changing the control so that at design time it appears not directly on the Web Form design surface, but on the nonvisual portion, like a Timer control Figuring out a way to avoid the URL length restriction for the server-side message boxes (hint: think ViewState)

Modifying the code so that after a user closes a WebMessageBox, it has the capability to cause a postback, sending the dialog result back to the server, like other Web server controls.

ADO.NET Solutions

SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION SOLUTION

22 23 24 25 26 27 28 29 30 31 32

Optimizing and Troubleshooting Database Connections Replacing Recordsets with DataSets Working with Typed DataSets Saving Time with Calculated DataColumns Combining Tables in a DataSet Getting Customized XML From SQL Server XML and the DataSet Databinding ListBoxes and ComboBoxes Advanced DataBinding Synchronizing DataSets with DiffGrams The 10-Minute Guide to Paging Data

326

ADO.NET Solutions

SOLUTION

22
SOLUTION

Optimizing and Troubleshooting Database Connections


PROBLEM

Connection strings have changed! I dont know how to get the right connection string in my application, and with .NETs automatic garbage collection, Im never quite sure whats going on with database connections in my application.

Use Server Explorer to test database connection strings quickly. Implement .NET connection pooling and know when you can safely close a connection to optimize connection use. Finally, use the Performance Monitor counters installed by the .NET Framework to monitor database connections.

Almost any application these days will store data somewhere. If theres a substantial amount of data involved, the obvious choice for storage is a database manager. For Visual Basic .NET applications, SQL Server (or its stripped-down cousin, MSDE [Microsoft Data Engine]) is the usual choice. But even if youve worked with databases in earlier versions of Visual Basic, you may feel a bit lost when you first start dealing with databases in Visual Basic .NET. The format of SQL Server connection strings has changed a bit, and there are new rules about using connections efficiently. Fortunately, the .NET Framework and the Visual Studio .NET IDE both offer good support for working with database connections.

Creating and Testing Database Connections


You could sit down with the help of the System.Data.SqlClient.SqlConnection.Connection String property and try to memorize the options that apply to connection strings. But theres an easier way to get the right connection string for your application. Visual Studio .NET includes a tool called Server Explorer that lets you build database connections using a familiar graphical interface. NOTE
To follow along with this solution, you should have the QuickStarts database from the .NET Framework SDK installed. If you have access to another instance of SQL Server, you can use all the same techniques but youll have to change the connection strings accordingly.

Server Explorer is usually tucked away as a small vertical tab at the left side of the Visual Studio .NET workspace, right next to the Toolbox. Hover your mouse over this tab for a

Solution 22 Optimizing and Troubleshooting Database Connections

327

moment and Server Explorer will slide out from the side of the screen. If this behavior drives you nuts, you can force Server Explorer to stay put on your screen by clicking the pushpin icon on its caption bar.

Connecting to a Database
To connect to a database, right-click on the Data Connections node in Server Explorer and select Add Connection, or click the Connect To Database toolbar icon in Server Explorer. Either action opens the Data Link Properties dialog box, shown in Figure 1. This dialog box is provided by the Microsoft Data Access Components (MDAC), so youve probably seen it before. In the figure, Ive chosen to connect to a particular database in the NetSDK named instance of SQL Server on a computer named SKYROCKET. You can use the special server name (local) to indicate that the SQL Server is on the same computer where youre running Visual Studio .NET, but youll find that Server Explorer creates the connection using the machine name in any case. When youre creating a connection in this way, its a good idea to use the Test Connection button to make sure that you have specified the correct security information. When youre satisfied with your choices, click OK to create the new connection within Server Explorer. TIP
Server Explorer connections are stored and are available to any project you have open in Visual Studio .NET.

FIGURE 1:
The Data Link Properties dialog box

328

ADO.NET Solutions

After creating a database connection, you can drill into it by expanding the tree in Server Explorer, as shown in Figure 2. In a SQL Server database, you can see details of database diagrams, tables, views, stored procedures, and functions in this tree. If you explore the context menu for these items, youll discover that you can work with database objects directly from the tree.

Testing the New Connection


While the interactive design facilities of Server Explorer are useful, its integration with Visual Studio .NET goes much deeper. You can use objects from Server Explorer directly in your applications. As an example, try these steps with a Visual Basic .NET Windows Forms application: 1. Activate Server Explorer and expand the Database Connections node to drill into a connection to the Northwind sample database. Expand the tree to locate the Customers table. 2. Drag the Customers table from Server Explorer and drop it on a Windows Form. Two objects will appear, not on the form itself, but in an area at the bottom of the Form Designer called the tray: SqlConnection1 and SqlDataAdapter1. 3. Select the SqlDataAdapter1 object. At the bottom of the Properties window, youll see several hyperlinks. Click the Generate Dataset hyperlink to open the Generate Dataset dialog box. 4. Create a new DataSet named dsCust, using the Customers table, as shown in Figure 3. Click OK. This creates a new object, DsCust1, in the tray. 5. Add a DataGrid control named dgCust to the form. 6. Set the DataSource property of the DataGrid control to DsCust1. Set the DataMember property of the DataGrid control to Customers. 7. Add the following code to the forms Load event:
Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load sqlDataAdapter1.Fill(DsCust1, Customers) End Sub

8. Run the application. Youll see the contents of the Customers table displayed on the DataGrid. As you can see, designing database-backed applications with Visual Studio .NET is pretty simple. In particular, you didnt have to write any code at all to set the properties of the SqlConnection1 object.

Solution 22 Optimizing and Troubleshooting Database Connections

329

FIGURE 2:
A database connection in Server Explorer

Inspecting the Connection Properties


Of course, the code is still there; it was just written by Visual Studio .NET. If you expand the Windows Form Designergenerated code region in the forms code file, youll find the connection string that Visual Studio .NET created in response to your drag-and-drop action (Ive reformatted the code slightly for easier reading):
Me.SqlConnection1.ConnectionString = _ data source=SKYROCKET\NetSDK; & _ initial catalog=Northwind; & _ integrated security=SSPI; & _ persist security info=False; & _ workstation id=SKYROCKET;packet size=4096

330

ADO.NET Solutions

FIGURE 3:
Generating a DataSet

You can also view the entire connection string in the Properties window if you select the SqlConnection1 component in the tray area of the Form Designer. Now that you know the correct connection string for your own server and database, you can reuse it easily in an application. To create a connection without using drag and drop, you first need to import the System.Data.SqlClient namespace. Then create a SqlConnection object and set its ConnectionString property:
Dim SqlConnection1 As SqlConnection = _ New SqlConnection SqlConnection1.ConnectionString = _ data source=SKYROCKET\NetSDK; & _ initial catalog=Northwind; & _ integrated security=SSPI; & _ persist security info=False; & _ workstation id=SKYROCKET;packet size=4096

Many developers automatically prefer to create objects such as connections in their code instead of using the tools in the IDE. This is understandable, because in the past some IDEs and tools have generated excessive or buggy code in their quest to make designing objects simple. Visual Studio .NET is largely free of this problem. From a code efficiency standpoint, it doesnt matter whether you drag and drop to create connections or create them in code. Use whichever technique appeals to you.

Using Connection Pooling


In the world of VB6 and classic ADO, connection management was fairly simple: you opened a connection when you needed to get to data, and kept it open the entire time that you

Solution 22 Optimizing and Troubleshooting Database Connections

331

wanted to use the data. Typically this meant opening the connection at the time an application was launched, and then closing it when the application was shutting down. This had the unfortunate side effect of tying up potentially scarce server resources for the entire time that your application was in memory. In the .NET world, things are different. ADO.NET has been optimized for disconnected and distributed data use. Connections are not required to be opened persistently, and ADO.NET offers good facilities for connection pooling, allowing connections to be shared between multiple processes.

When Do You Need an Open Connection?


The short answer is that you need an open connection only when youre actually exchanging data with a database. This breaks down into three cases, depending on which class youre using to communicate with the database:

If youre executing a SqlCommand object (for example, to call a stored procedure in the database), then you must call the Open method of the associated SqlConnection object before calling any of the Execute methods. You can call the Close method of the SqlConnection object as soon as the Execute call has completed. If youre retrieving data with a SqlDataReader object, you must call the Open method of the associated SqlConnection object before reading any data. The connection must remain open as long as youre reading data. If youre using a SqlDataAdapter object to exchange data between a DataSet object and a database, then the connection must be open when you call the Fill or Update methods of SqlDataAdapter. Theres no need to keep the connection open after filling the DataSet. The data in the DataSet will remain valid, and you can open the connection again to call the Update method at a later time.

In fact, if youre using the SqlDataAdapter/DataSet combination, you never need to call the Open method of the associated SqlConnection object at all. The Framework takes care of opening and closing the connection as necessary. This guarantees the shortest possible time for open connections.

Understanding the Connection Pool


In general, you dont worry about the exact time that a .NET Framework object ceases to exist. One of the features of .NET is automatic garbage collection. When you call the Dispose method of an object, or drop all references to the object, the .NET Framework knows that its safe to destroy the object. But exactly when it will decide to do so is determined by a complex set of internal algorithms.

332

ADO.NET Solutions

This complicates things for relatively expensive objects such as database connections. Ideally, youd like to manage your connections by getting rid of them when theyre no longer needed. This can save server resources as well as local application resources. Connection management is important for desktop applications that need to cooperate with other applications in the enterprise, but its even more important for ASP.NET, remoting, Web Services, and other distributed applications. An ASP.NET application, for example, might have hundreds or thousands of simultaneous sessions in progress. If each of those sessions required its own connection to the database, the drain on server resources would be severe. Fortunately, ADO.NET supports connection pooling, which lets a group of processes share a relatively small number of database connections. A connection pool is a set of SQL Server connections maintained by ADO.NET that are used to communicate with SQL Server. This is important to the client as well as to the server, because the act of making a SQL Server connection is itself fairly costly. When ADO.NET opens a SqlConnection object, this delays the application. To minimize this delay, ADO.NET doesnt throw SqlConnection objects away when you call their Close or Dispose methods. Instead, it returns them to the connection pool. If a future request for a SqlConnection can be satisfied by a pooled connection, then ADO.NET recycles that connection instead of opening a new one. ADO.NET determines whether a pooled connection can be recycled by checking three things:

Is the connection currently unused? Does the connection string match the desired connection string exactly? Does the transaction context of the thread match the desired transaction context exactly?

Note the requirement for an exact match. If youre using connection strings with user information, the corresponding SqlConnection objects wont pool with each other. That is, suppose you initialize a SqlConnection object with this ConnectionString property:
SqlConnection1.ConnectionString = Server=MAINSQL; & _ Initial Catalog=Northwind;username=John;password=pw1

Then you use that connection, close it, and initialize a SqlConnection object with this ConnectionString property:
SqlConnection2.ConnectionString = Server=MAINSQL; & _ Initial Catalog=Northwind;username=Mary;password=pw2

In this case, the second request will not be satisfied by returning the first connection from the pool, because the second connection string is not identical to the first one. If youre in this situation, you should consider whether you can avoid placing authentication information

Solution 22 Optimizing and Troubleshooting Database Connections

333

directly in the connection string. One possibility is to use integrated security rather than username and password security on your SQL Server. Another is to authenticate users to your application, but then use an application role to connect to the SQL Server (see SQL Server Books Online, which ships with SQL Server, for more details on creating and using application roles).

Customizing the Connection Pool


You can exercise some control over your connection pool by adding additional properties to the connection string (of course, youll want to use identical values in all connection strings in your application, to be sure the connections can be pooled). There are six pool-related connection string properties: This property can be either true (the default) or false. You can use it to specify that a particular connection should not participate in the pool but instead should be destroyed when it is closed. You might use this property if you know that youll never have an identical connection request in your application and would like to avoid the overhead associated with managing the pool.
Pooling Min Pool Size

This property is an integer with a default of zero. It specifies the minimum number of connections you want to maintain in the pool. If you set this to 5, for example, then the first time you connect to the server ADO.NET will create five connections and prepare them for pooling. Although this will result in a longer startup time for your application, it means that requests for a connection will not have to wait for the connection to be created. This property is an integer with a default of 100. It specifies the maximum number of connections you want to maintain in the pool. This property can be either true (the default) or false. It controls whether a connection will automatically enlist in the calling threads transaction when it is taken from the pool.

Max Pool Size

Enlist

Connection Reset

This property can be either true (the default) or false. It controls whether the connection will automatically be reset (clearing any pending results) when its returned from the pool.

Connection Lifetime This property controls the maximum age of connections (in seconds). If a connection has been open for more than the specified number of seconds when you call its Close or Dispose method, it will be destroyed rather than being returned to the pool. By default, this property is set to zero, which means that connections are kept in the pool regardless of age.

334

ADO.NET Solutions

The Pool Limiter


Note that the Max Pool Size property acts as a limiter to the connection pool. If you leave this property set to 100 (the default), then at most 100 connections from your application will be placed in the pool. What happens when the 101st connection request comes along? The incoming request is queued to wait for an available connection. If no connection becomes available during the timeout period for the connection string (you can customize the timeout period using the Connect Timeout connection string property, which defaults to 60 seconds), then an error is returned instead of an open connection. The intent of the Max Pool Size property is to limit the resources that a runaway process can grab. But in the case of a busy ASP.NET or other server application, it may have the undesired side effect of limiting the number of simultaneous users for your application. If the connection pool maxes out while new connection requests are still coming in, youll see connection requests refused, apparently at random. The cure in this case is to simply specify a higher value for the Max Pool Size property.

Monitoring the Connection Pool


Fortunately, you dont have to guess at an appropriate size for the connection pool; .NET supplies some Performance Monitor counters that can help you monitor the connection pool on a computer. Performance Monitor is a Windows utility that can keep track of a wide variety of statistics about your computer. Youll find these counters in the .NET CLR Data category in Performance Monitor:
SqlClient: Current # of pooled and non-pooled connections

Tells you how many

connections currently exist, pooled or not.


SqlClient: Current # pooled connections

Tells you how many connections are cur-

rently in the pool.


SqlClient: Current # connection pools

Tells you how many connection pools have been created. This counter helps you determine whether youve configured properties so that all connections are being taken from the same pool. Tells you the highest number of connections Tells you how many connection attempts have that have been used.

SqlClient: Peak # pooled connections

SqlClient: Total # failed connects

failed for any reason. To view these counters in Performance Monitor, follow these steps: 1. Select Start Program Administrative Tools Performance to launch Performance Monitor. 2. Click the Add button in the Performance Monitor toolbar.

Solution 22 Optimizing and Troubleshooting Database Connections

335

3. Select the .NET CLR Data Performance object. 4. Select a counter from the list, and select an instance of the counter to monitor. 5. Click Add to add the counter to the graph. 6. Click Close when youve finished adding counters. Figure 4 shows a Performance Monitor session with several of the connection pool monitors loaded. If the Peak counter is at the Max Pool Size value, and youre seeing the failed connection counter increase while your application is running, its time to think about increasing the size of the connection pool. WARNING A bug in the .NET Framework prevents these counters from being properly reset in some
circumstances. If youve monitored one of these counters, you must close both your application and Performance Monitor to reset the counter to zero.

The .NET Framework also includes support for retrieving the value of Performance Monitor counters directly from code. This is useful if you want your application to adjust its behavior on the fly based on actual performance. As with connections, you can use Server Explorer to give you an easy way to work with Performance Monitor counters. FIGURE 4:
Monitoring counters with Performance Monitor

336

ADO.NET Solutions

To add a counter to your application, follow these steps: 1. Activate Server Explorer and expand nodes in this order: Servers Your server name Performance Counters .NET CLR Data. 2. Drag the SqlClient: Current # pooled connections counter from Server Explorer and drop it on your form. 3. Add a Button control named btnMonitor and a Label control named lblMonitor to the form. 4. Add code to update the label when you click the button:
Private Sub btnMonitor_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnMonitor.Click lblMonitor.Text = PerformanceCounter1.NextValue() End Sub

Now when you run the application and click the button, it will display the current Performance Monitor counter value. In a production application, you might monitor this value and dynamically change connection pool parameters based on the results.

No Magic Bullet
As always, theres no magic bullet in fixing applications. Dont automatically assume that connection pooling will solve your performance problems; this is just one of many areas where you can tune things. Always tackle performance issues by investigating first (for example, by checking the Performance Monitor counters) and then making changes that address your findings.

SOLUTION

23
SOLUTION

Replacing Recordsets with DataSets


PROBLEM

I dont understand how to work with relational data in .NET. All of my investment in learning ADO Recordsets is obsolete! How can I retrieve, edit, and update data in .NET?

The DataSet is the closest .NET equivalent to the Recordset. With just a few lines of code, you can load multiple tables into a DataSet, define their relations, edit them, and save the updates back to the original database.

Solution 23 Replacing Recordsets with DataSets

337

Although some of Microsofts marketing materials have presented ADO.NET as a simple upgrade to ADO, thats a rather misleading way to look at it. In reality, ADO.NET is an almost completely new architecture for data access. It shares some basic concepts with ADO, but if you attempt to do things the ADO way, youre in for some frustration. Fortunately, once you understand how ADO.NET works, youll find that it offers tremendous power for retrieving and manipulating data.

DataSets: The Big Picture


In ADO through version 2.7 (sometimes called classic ADO), the basic object for holding a group of related records in an application is the Recordset. A Recordset is, roughly, a single table or view stored in memory. It also has a direct and intimate connection with the original data source. Depending on your cursor settings, a Recordset may retrieve batches of records as you move through the data. Each Recordset has a pointer to a current record, which you can edit. Edits to the current record are saved or discarded before you move to another record. The Recordset and other classic ADO objects are universal, applying equally well to any type of data. NOTE
Were simplifying the classic ADO story somewhat by ignoring disconnected Recordsets and batch updating. If youre familiar with those techniques, youll see similarities to ADO.NET as we proceed.

If youve been working with ADO for any length of time, youve probably memorized all of those Recordset facts and now assume that this is how data access is supposed to work. If thats the case, prepare yourself for a bit of a shock. In fact, in the immortal words of the Firesign Theatre, Everything you know is wrong. Here are some points to keep in mind as you learn about the DataSet, which is the new core data object for ADO.NET:

A DataSet can represent an entire relational database in memory, complete with tables, relations, and views. A DataSet is designed to work without any continuing connection to the original data source. Data in a DataSet is bulk loaded rather than being loaded on demand. Theres no concept of cursor types in a DataSet. DataSets have no current record pointer. You can use ForEach loops to move through the data. You can store many edits in a DataSet and write them to the original data source in a single operation. Though the DataSet is universal, other objects in ADO.NET come in different versions for different data sources.

338

ADO.NET Solutions

Figure 1 shows schematically how the DataSet object fits into the big picture of ADO.NET. The DataSet object is the root of an object hierarchy. The DataSet contains a collection of DataTable objects, each of which can be viewed as a collection of DataRow or DataColumn objects, depending on what youre doing. As youd guess from the names, these objects are conceptually similar to their database counterparts: tables, rows, and columns. Other objects that are analogous to database objects are the DataRelation and DataView. All of the objects within the DataSet are universalthat is, they apply to any data source. But the objects that hook the DataSet up to the data source are specific to the type of data source. If youre storing your data in a SQL Server database, for example, youll use a SqlConnection object to connect to the database and a SqlCommand object to represent a SQL statement or stored procedure. The SqlDataAdapter object acts as a link between these database-specific objects and the more abstract DataSet. The SqlDataAdapter.Fill method transfers data from a SQL Server database to a DataSet, and the SqlDataAdapter.Update method transfers changes from a DataSet back to the database. On the other hand, if youre using data from a Jet database, you can use the OleDbConnection, OleDbCommand, and OleDbDataAdapter objects in an analogous fashion to work with the data. The DataSet doesnt care whether its data comes from SQL Server, Jet, or any other data source. In fact, data from multiple sources can happily be combined in a single DataSet. FIGURE 1:
ADO.NET architecture
DataView DataTable DataRow DataRelation DataColumn DataColumn DataSet DataView DataTable DataRow

SqlDataAdapter SqlCommand SqlConnection

OleDbDataAdapter OleDbCommand OleDbConnection

SQL Server database

Jet, Oracle, or other database

Solution 23 Replacing Recordsets with DataSets

339

Loading Data into a DataSet


The first step to working with data in a DataSet is to get the data into the DataSet. If youre storing multiple tables in the same DataSet, you can break this down into two steps: loading the tables and then creating the relations between those tables. NOTE
Throughout this solution, well use data from the SQL Server Northwind sample database to illustrate the workings of the DataSet. This means that well use the objects from the System.Data.SqlClient namespace to communicate with the data source. You should keep in mind that alternative objects exist for other data sources.

Loading Tables
One way to load data into a DataTable within a DataSet is to follow this recipe: 1. Create a SqlConnection object that refers to the desired data source. 2. Create a SqlCommand object that returns the desired data. 3. Assign the SqlCommand to the SelectCommand property of a SqlDataAdapter object. 4. Call the Fill method of the SqlDataAdapter object. NOTE
One thing youll discover as you explore ADO.NET is that it has an extremely rich object model, with multiple overloads for almost every method. Well show you ways to perform basic tasks in this solution, but there are almost always alternative ways to write the same code.

Heres a sample that loads the Customers table from the Northwind sample database into a DataSet and then displays it on a DataGrid control:
1. Create a SqlConnection object that refers to the desired data source. Dim cnn As SqlConnection = New SqlConnection( _ Data Source=(local);Initial Catalog=Northwind; & _ Integrated Security=SSPI) 2. Create a SqlCommand object that returns the desired data. Dim cmd As SqlCommand = _ New SqlCommand(SELECT * FROM Customers, cnn) 3. Assign the SqlCommand to the SelectCommand property of a SqlDataAdapter object. Dim da As SqlDataAdapter = New SqlDataAdapter da.SelectCommand = cmd

340

ADO.NET Solutions

4. Call the Fill method of the SqlDataAdapter object. Dim ds As DataSet = New DataSet da.Fill(ds, Customers) Display the results on the UI dgCustomers.DataSource = ds dgCustomers.DataMember = Customers

TIP

Youll need to add Imports System.Data.SqlClient to the top of the module containing this code (and all of the other code in this solution).

You can think of the SqlDataAdapter object in this example as a pump that moves data from the SQL Server database to the DataSet. The SelectCommand property of the SqlDataAdapter is set to a SqlCommand object that dictates the data to be loaded. The Fill method does the actual work. The two parameters to the Fill method are the DataSet object to be filled and the name of the DataTable object to create. Figure 2 shows the result of running this code. One thing you might notice is that theres no reference to the SqlConnection object after its used to create the SqlCommand object. The SqlConnection object does support an Open method to open a connection to the data source or a Close method to close the connection, but in this particular case, you dont need to call those methods explicitly. The SqlDataAdapter object will open and close the connection as needed. In fact, the connection will be closed assoon as all of the requested data has been retrieved and placed in the DataSet. This scheme helps minimize the length of time that connections to the database are open, which improves the scalability of your applications.

Loading Multiple Tables


Loading multiple tables is just a matter of running the single-table code multiple times. Remember, the DataSet can represent an entire database, not just a single table. So, for example, you could load both the Customers and Orders tables in this way:
Identify the data source Dim cnn As SqlConnection = New SqlConnection( _ Data Source=(local);Initial Catalog=Northwind; & _ Integrated Security=SSPI) Load the customers data Dim cmd As SqlCommand = _ New SqlCommand(SELECT * FROM Customers, cnn) Dim da As SqlDataAdapter = New SqlDataAdapter da.SelectCommand = cmd Dim ds As DataSet = New DataSet

Solution 23 Replacing Recordsets with DataSets

341

da.Fill(ds, Customers) Load the orders data Dim cmd2 As SqlCommand = _ New SqlCommand(SELECT * FROM Orders, cnn) Dim da2 As SqlDataAdapter = New SqlDataAdapter da2.SelectCommand = cmd2 da2.Fill(ds, Orders)

Note that the DataSet object is declared only once and then filled with two different DataTables to contain the data. Theres no practical limit to the number of different DataTables that you can place in a single DataSet.

Creating Relations
The DataSet does not assume that different tables should be related in any way (just as a database does not assume that tables should be related). In a database, youd relate tables by creating primary and foreign keys. In the DataSet, you can do this by creating a DataRelation object that specifies the relationship between the two tables. For example, given the code in the previous example that loads the Customers and Orders tables, you could relate the two by using this code:
Dim relCustOrder As DataRelation = _ ds.Relations.Add(CustOrder, _ ds.Tables(Customers).Columns(CustomerID), _ ds.Tables(Orders).Columns(CustomerID))

The DataRelation object is part of the DataSet hierarchy and so does not have a data source specific name. In this case, were creating a new DataRelation by specifying the name of the DataRelation object (CustOrder), the primary key column in the Customers DataTable, and the foreign key column in the Orders DataTable. As you can see, theres a simple syntax of collections and names that you can use to refer to DataTables and DataColumns within a DataSet. FIGURE 2:
A table loaded through ADO.NET

342

ADO.NET Solutions

One interesting implication of the DataRelation being data source independent is that you can easily relate data from multiple data sources. For example, you could load the Customers table from a SQL Server database, the Orders table from a Jet database, and the Order Details table from an Oracle database, and then relate them together within a single DataSet. The DataGrid control has special functionality for displaying related tables. You can drill from a parent table into child data. Figure 3 shows a DataGrid displaying the DataSet with both tables.

Navigating through the DataSet


In the classic ADO world, there are a variety of methods for navigating through a Recordset. These methods are designed to manipulate the current record pointerfor example, the MoveNext method moves the current record pointer to the next record. This pattern is a natural consequence of the cursor-oriented nature of the Recordset. Because data might be cached on the server and retrieved only in chunks, you need to tell ADO explicitly which particular data you want to work with at any given time. In ADO.NET, things are different. The DataSet isnt connected directly to the data source (remember, the SqlDataAdapter object closes the connection when it has finished retrieving the data). The data is just there, loaded into the DataSet all at once. If you want to visit every piece of data, you can use ForEach and ForNext loops, just as you can with any other collection. For example, if you load the Customers and Orders tables into a DataSet, you could dump all of the data that they contain to a ListBox control in this way:
Dump the data to the UI For Each dt As DataTable In ds.Tables lbData.Items.Add(DataTable: & dt.TableName) For Each dr As DataRow In dt.Rows lbData.Items.Add( DataRow: ) For i As Integer = 0 To dt.Columns.Count - 1 If dr.Item(i) Is System.DBNull.Value Then lbData.Items.Add( Item: Null) Else lbData.Items.Add( Item: & CStr(dr.Item(i))) End If Next Next Next

The Items collection of the DataRow object returns an Object whose type is specific to the database column being represented. This requires an explicit check for Null values in this case, because a Null cant be converted directly to a string. Figure 4 shows the result of running the previous code snippet.

Solution 23 Replacing Recordsets with DataSets

343

FIGURE 3:
Related tables displayed on a DataGrid control

FIGURE 4:
Dumping the contents of a DataSet

If youre just trying to find particular data, ADO.NET offers several useful ways to proceed. You can create a DataView object from a DataTable object to limit the rows to those matching a WHERE clause. The DataTable also exposes a Select method that returns an array of DataRow objects. You can refer to the .NET Framework SDK documentation of the System.Data namespace for more information on these techniques.

Editing Data
As you probably expect by now, editing data in ADO.NET is different from editing data in classic ADO. Because ADO.NET doesnt have any concept of a current row, you dont have to call any special method to prepare a row for editing. Instead, you just change the data in the appropriate DataRow object. Similarly, you can remove a row by deleting a DataRow object from the Rows collection of a DataTable or add a new row by creating a new DataRow object and appending it to the Rows collection. The biggest difference, though, lies in getting the data back to the original data source. In classic ADO, when you edit a record, the results of your actions are written back to the data source immediately. Not so in ADO.NETwhen you edit data in a DataSet, youre changing

344

ADO.NET Solutions

data only in the DataSet, not in the underlying data source. To make changes to the data source (such as the SQL Server database that were using for examples in this solution), you must tell ADO.NET when and how to make the changes, as youll see in the remainder of this section.

Editing in the DataSet


Well start by demonstrating code for the three fundamental editing operations:

Adding a new row of data Editing an existing row of data Deleting a row of data

Lets see how the DataSet simplifies these operations. Adding Data To add data to a DataSet, you can call the NewRow method of the DataTable. The Add Data form in the sample files loads the Customer table into a DataSet and displays it on a DataGrid. When you click the button, it runs code to add a new row to the DataSet:
Create a new, empty row Dim dr As DataRow = ds.Tables(Customers).NewRow Add some data dr(0) = AAAAA dr(1) = Aardvark Industries dr(2) = Alfred A. Antimony And add the row back to the table ds.Tables(Customers).Rows.Add(dr)

If you run this code, youll find that the new row shows up at the end of the DataGrid. The DataGrid displays new rows as theyre added, but it doesnt try to sort them into their proper order. Editing Data Editing data is even simpler than adding data. You already know that you can refer to particular pieces of data using the column number within the DataRow. Just change the data, and youre done. The Edit Data sample form demonstrates with this code:
Edit some values in the first row of data Dim dr As DataRow = ds.Tables(Customers).Rows(0) dr(1) = Amalgamated Industries dr(2) = Melvin Trout

The DataGrid control displays the new data as soon as these lines of code are run; theres no need to invoke any sort of explicit save on the changed data. Deleting Data Finally, to delete data you simply delete the row containing that data from the DataTable:
ds.Tables(Customers).Rows.RemoveAt(0)

Solution 23 Replacing Recordsets with DataSets

345

The RemoveAt method removes the specified row from the DataTable by index. Theres also a Remove method that removes a particular DataRow, which is useful if you have a variable that holds the DataRow youd like to delete:
ds.Tables(Customers).Rows.Remove(dr)

Saving Your Changes


If youve run the previous examples, you may have noticed that all of the changes to the data were transient: if you stop the application, the database is unchanged. Thats because all of these examples are working in the DataSet itself. The DataSet is disconnected from the original data source, and until you reconnect it and explicitly tell the DataAdapter to save changes, none of the changes will be permanent. To save changes, you call the Update method of the DataAdapter object. When you call this method, the DataAdapter walks through the data in the specified DataTable. It then executes commands based on its own properties:

If a row has been added, the DataAdapter executes the command pointed to by its own InsertCommand property. If a row has been edited, the DataAdapter executes the command pointed to by its own UpdateCommand property. If a row has been deleted, the DataAdapter executes the command pointed to by its own DeleteCommand property.

You can develop the SQL statements that youll need for these commands in one of several ways. You can write them by hand and specify columns in the DataSet that hold the data to be used. This process tends to be quite time consuming and error prone. Another option is to build your DataSet by dragging a table or view from Server Explorer and dropping it on the form. If you create a DataAdapter in this manner, Visual Studio .NET will attempt to write the proper commands for you. Still another alternative is to use the CommandBuilder helper object to build your commands. This is the easiest choice, and the one that well demonstrate here. Weve created a form named frmSaveChanges that starts by loading a DataGrid with data when the form is first loaded:
Dim ds As DataSet = New DataSet Dim da As SqlDataAdapter = New SqlDataAdapter Private Sub frmSaveChanges_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Load the Customers table to the DataGrid Dim cnn As SqlConnection = New SqlConnection( _ Data Source=(local);Initial Catalog=Northwind; & _ Integrated Security=SSPI)

346

ADO.NET Solutions

Dim cmd As SqlCommand = _ New SqlCommand(SELECT * FROM Customers, cnn) da.SelectCommand = cmd da.Fill(ds, Customers) dgCustomers.DataSource = ds dgCustomers.DataMember = Customers End Sub

When youre working with a DataGrid in this way, theres no need to write explicit code to add, edit, or delete rows. As the user works with the data in the DataGrid, the .NET Framework automatically makes the corresponding changes to the underlying DataSet. When the user clicks the Save Changes button, it calls code to write back to the database:
Private Sub btnSaveChanges_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSaveChanges.Click Build the commands to persist changes Dim cb As SqlCommandBuilder = New SqlCommandBuilder(da) And save the changes da.Update(ds, Customers) End Sub

The CommandBuilder object (here seen in its SqlCommandBuilder version, though of course other CommandBuilders are available for other types of data) takes the DataAdapter that you pass to its constructor, determines which database to query for metadata, and uses the information that it finds to fill in the UpdateCommand, DeleteCommand, and InsertCommand properties of the DataAdapter. After those properties are set, you can call the Update method of the DataAdapter, which is the reverse of the Fill method: it opens a connection to the data source, writes back changes from the DataSet to the data source, and then closes the connection. WARNING Because it retrieves metadata at runtime, the CommandBuilder can be a poor choice for
performance-critical applications. For maximum speed, you should plan on hard-coding the commands for the appropriate DataAdapter properties.

New Horizons
After that whirlwind tour, you should be convinced that ADO.NET is more than just an upgrade to ADO. The changes to behavior from the Recordset to the DataSet are not purely arbitrary. Rather, they were made to have a data object that fits in better with the overall purpose of the .NET Framework: building robust, high-performance, distributed applications. Because it holds data in memory and allows you to carefully manage the load on the database, the DataSet is an ideal object to use in such applications.

Solution 24 Working with Typed DataSets

347

SOLUTION

24
Use a typed DataSet to hold your data. A typed DataSet is a subclass of DataSet that provides data through a strongly typed interface, furnishing you with design-time IntelliSense for table and column names as well as automatic typemismatch checking.
SOLUTION

Working with Typed DataSets


PROBLEM

Some DataSet errors arent caught until I run my code. If I type the name of a column incorrectly, or assign a value to a variable of the wrong data type, the compiler doesnt warn me. How can I avoid these errors?

As you learned in Solution 23, the DataSet is the general-purpose data repository object in the .NET Framework. But the DataSet itself is very generic and uses a late-bound syntax for referring to its contents. That is, if you want to refer to a specific DataColumn in a DataSet, you use syntax like this:
dsOrdering.Tables(Customers).Columns(CompanyName)

There are two problems with the late-bound syntax:

When the compiler is building your project, it has no way to check that you spelled everything correctly. The names must be resolved at runtime, leading to slower performance.

Fortunately, the .NET Framework Class Library (FCL, synonymous with the .NET Framework) offers a solution: the typed DataSet. A typed DataSet is a subclass of the DataSet class that offers you early binding and type checking. In this solution, youll learn how to create and use typed DataSets.

Creating a Typed DataSet


Before you can use a typed DataSet, of course, you must create it. You have two main ways to create typed DataSets with Visual Studio .NET, and well show you both of them. First, youll learn how to create typed DataSets using drag and drop and other shortcuts in the IDE. After that, well show you how to use the Schema Designer to create a typed DataSet.

348

ADO.NET Solutions

Drag-and-Drop Creation
The easiest way to create a typed DataSet is to let Visual Studio .NET do all the work for you. Follow these steps: 1. Create a new Visual Basic .NET Windows application. 2. Open the Server Explorer window and expand a Data Connection node that refers to the Northwind SQL Server sample database until you can see the Customers table. 3. Drag the Customers table and drop it onto the default form in the application. This creates two objects in the tray: SqlConnection1 and SqlDataAdapter1. 4. Select only the SqlDataAdapter1 control. Youll see three hyperlinks at the bottom of the Properties window. Click on the Generate Dataset hyperlink. 5. In the Generate Dataset dialog box, click New under Choose A DataSet and type dsCustomers in the text box. Include the Customers table in the DataSet and click the Add This Dataset To The Designer option, as shown in Figure 1. Then, click OK. Visual Studio .NET creates a new node in Solution Explorer named dsCustomers.xsd and a new object in the tray named DsCustomers1. 6. Place a ListBox control on the form and name it lbCustomers. 7. Double-click the form and add this code to the forms Load event handler:
Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load sqlDataAdapter1.Fill(DsCustomers1) For Each cust As dsCustomers.CustomersRow In DsCustomers1.Customers lbCustomers.Items.Add(cust.CompanyName) Next End Sub

8. Run the project. Youll see a list of all customers from the Northwind database displayed in the ListBox control. If you examine the code, youll note that table and column names in this sample are treated as properties of objects. That means theyre resolved at design time rather than at runtime, and its impossible to make a mistake in the name of a column without detecting it. (Try misspelling CompanyName and youll see that the code wont even run.) Youll also discover another benefit of typed DataSets when you enter this code: IntelliSense extends to the names of tables and columns, as shown in Figure 2.

Using the Schema Designer


The dsCustomers.xsd file that you created is a DataSet schema file. A DataSet schema is an XML representation of the metadata that defines the structure of a DataSet. Visual Studio .NET includes tools that let you work with this metadata directly, defining and altering the

Solution 24 Working with Typed DataSets

349

structure of DataSet objects. Although Visual Studio .NET created this schema file for you, you can also create such files directly. Doing so allows you to build typed DataSets without dragging objects to a form. Well demonstrate this using the Orders table from the Northwind sample database: 1. Right-click on the project node in Solution Explorer and select Add Add New Item. 2. Select the Local Project Items node in the Categories TreeView. Then, select the DataSet template. Name the new DataSet Orders.xsd, and click Open. 3. The XML Designer opens the new DataSet schema file, and the Toolbox displays tools appropriate for creating new content in the schema file. 4. Drag the Orders table from Server Explorer and drop it on the schema design surface. This creates a visual representation of the structure of the Orders table, as shown in Figure 3. FIGURE 1:
The Generate Dataset dialog box

FIGURE 2:
IntelliSense with a typed DataSet

350

ADO.NET Solutions

FIGURE 3:
A typed DataSet in the Schema Designer

At the bottom of the Schema Designer, youll find two tabs: DataSet and XML. You can use the visual tools in the Toolbox to modify the DataSet schema while youre in DataSet view. For example, you can add or delete fields to match changes in your database. Alternatively, you can switch to XML view and see the schema file in its native XML format. Here is the XML for the Orders.xsd file, somewhat reformatted to fit on the page:
<?xml version=1.0 encoding=utf-8 ?> <xs:schema id=Orders targetNamespace=http://tempuri.org/Orders.xsd elementFormDefault=qualified attributeFormDefault=qualified xmlns=http://tempuri.org/Orders.xsd xmlns:mstns=http://tempuri.org/Orders.xsd xmlns:xs=http://www.w3.org/2001/XMLSchema xmlns:msdata=urn:schemas-microsoft-com:xml-msdata> <xs:element name=Orders msdata:IsDataSet=true> <xs:complexType> <xs:choice maxOccurs=unbounded> <xs:element name=Orders> <xs:complexType> <xs:sequence> <xs:element name=OrderID msdata:ReadOnly=true msdata:AutoIncrement=true type=xs:int /> <xs:element name=CustomerID type=xs:string minOccurs=0 /> <xs:element name=EmployeeID type=xs:int minOccurs=0 /> <xs:element name=OrderDate type=xs:dateTime minOccurs=0 /> <xs:element name=RequiredDate type=xs:dateTime minOccurs=0 /> <xs:element name=ShippedDate type=xs:dateTime minOccurs=0 />

Solution 24 Working with Typed DataSets

351

<xs:element name=ShipVia type=xs:int minOccurs=0 /> <xs:element name=Freight type=xs:decimal minOccurs=0 /> <xs:element name=ShipName type=xs:string minOccurs=0 /> <xs:element name=ShipAddress type=xs:string minOccurs=0 /> <xs:element name=ShipCity type=xs:string minOccurs=0 /> <xs:element name=ShipRegion type=xs:string minOccurs=0 /> <xs:element name=ShipPostalCode type=xs:string minOccurs=0 /> <xs:element name=ShipCountry type=xs:string minOccurs=0 /> </xs:sequence> </xs:complexType> </xs:element> </xs:choice> </xs:complexType> <xs:unique name=OrdersKey1 msdata:PrimaryKey=true> <xs:selector xpath=.//mstns:Orders /> <xs:field xpath=mstns:OrderID /> </xs:unique> </xs:element> </xs:schema>

Even without digging into the XML Schema Direct (XSD) standard in any detail, you should be able to see that this file defines the structure of the table and of the columns that it contains. NOTE
If youd like to learn more about XSD, a good place to start is the W3Cs XML Schema page at www.w3.org/XML/Schema.

But how does this XML file get converted into a type within your project? The answer lies in another of the advanced capabilities of Visual Studio .NET. If you inspect the properties of the Orders.xsd file in Solution Explorer, youll find that it has a Custom Tool property set to MSDataSetGenerator. Every time you change the XSD file, this custom tool automatically creates a corresponding class file. Click the Show All Files button in Solution Explorer to see the class file (in this case, Orders.vb) as a child of the schema file. The class file contains all of the methods and properties required to create the early-bound class that inherits from the base DataSet class.

352

ADO.NET Solutions

Benefits of Typed DataSets


The first thing to realize about a typed DataSet is that it does not give up any capabilities of a regular, untyped DataSet. Thats because the typed DataSet is a subclass of the untyped DataSet:
Public Class Orders Inherits DataSet

Suppose youve written some code that works with an untyped DataSet that holds the contents of the Northwind Orders table. You can create a typed DataSet based on the same table and substitute this for the untyped DataSet. You wont lose any functionality, but youll gain the benefits of early binding. This means that you can treat rows and columns as properties of the typed DataSet, which increases the readability of your code and lowers the chance of making a typing mistake thats not detected until runtime. A typed DataSet exposes a variety of members. Here are the ones youre most likely to use in code:

A property with the name of the underlying table that returns a DataTable object For example, in the Orders class, the Orders property returns an OrdersDataTable object, which inherits directly from the base DataTable object. A type with the name of the underlying table plus Row that inherits from the base DataRow class In the Orders class there is an OrdersRow class. Properties of the row class representing each column within the table In the OrdersRow class, the OrdersRow.OrderDate property represents the OrderDate column. Methods of the row class that make it easy to handle nulls The OrdersRow class has an IsFreightNull method that determines whether the Freight column currently contains a Null value, and a SetFreightNull method that places a Null value in the Freight column.

The end result of these members is to make code easier to read and less failure prone. For instance, the following code works with an untyped DataSet containing orders data:
Make sure all Freight values are Null For Each dr As DataRow In ds.Tables(Orders).Rows If dr(Freight) <> System.Convert.DBNull Then dr(Freight) = System.Convert.DBNull End If Next

For comparison, heres the same operation carried out with a typed DataSet:
Dim ds As Orders = New Orders For Each ord As Orders.OrdersRow In ds.Orders If Not ord.IsFreightNull() Then ord.SetFreightNull() End If Next

Solution 25 Saving Time with Calculated DataColumns

353

Not only is the code easier to read, it also executes faster because there is no need for the CLR to locate columns at runtime by parsing strings.

Think Types
When should you use a typed DataSet in your application? The answer is fairly easy: because you wont sacrifice any functionality using a typed DataSet rather than an untyped DataSet, and because it protects you from some common mistakes (such as typing a column name incorrectly), you should use typed DataSets whenever you can. As long as the structure of the data is fixed and known at design time, the benefits of a typed DataSet far outweigh the minimal design effort required to create it.

SOLUTION

25
SOLUTION The .NET DataSet supports calculated DataColumns. You can specify an expression that refers to fields in the current table or parent or child tables, and .NET will automatically synchronize the calculated DataColumn with the source data.

Saving Time with Calculated DataColumns


PROBLEM

I need to display calculated data, such as invoice totals, in my application. But I know I shouldnt store derived data in a normalized database. Whats the .NET solution?

If you have any experience with databases in your software development, you undoubtedly know about the rules of normalization. In particular, you probably long ago memorized the rule that you should not store calculated data in the database. This is one of the consequences of the rule for third normal form, which specifies that every column in a table must depend directly on the primary key of the table. For example, if your database includes an Order Details table containing columns for Price and Quantity, you should not put an Item Total column in that table as well. The item total can always be calculated from the price and quantity when its needed. By not storing the calculated column, you avoid having to update it whenever the data in one of the other columns changes. Avoiding such dependencies and extra calculations is one of the goals of database normalization.

354

ADO.NET Solutions

But even though the DataSet object can be thought of as the equivalent of a relational database in memory, there are times when you must remember the differences between databases and DataSet objects. In particular, the DataSet is designed to allow you to use calculated columns without dangerous side effects. When your application requires calculated data that can be derived from values in database columns by a simple expression, you should consider using calculated columns to deliver the desired results.

Creating a Calculated Column


To work with calculated data in ADO.NET, you can make use of the DataColumn objects Expression property. To demonstrate the use of this property, lets build a simple application that retrieves data to a Windows form. After the basic application is working, well add a calculated column.

Building the Application


Our test application will load data from three different tables in the Northwind database and display the data that they contain on a DataGrid control. The code for initializing the DataGrid is contained in the forms Load event handler. Listing 1 shows this code.

Listing 1

Retrieving three tables to a DataGrid

Private Sub frmDataColumn_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Connect to the database Dim cnn As SqlConnection = New SqlConnection cnn.ConnectionString = Data Source=(local); & _ Initial Catalog=Northwind;Integrated Security=SSPI Set up the DataSet to hold all the data Dim dsMain As DataSet = New DataSet Load customers Dim cmdCustomers As SqlCommand = cnn.CreateCommand() cmdCustomers.CommandType = System.Data.CommandType.Text cmdCustomers.CommandText = SELECT * FROM Customers Dim daCustomers As SqlDataAdapter = New SqlDataAdapter daCustomers.SelectCommand = cmdCustomers daCustomers.Fill(dsMain, Customers) Load orders Dim cmdOrders As SqlCommand = cnn.CreateCommand() cmdOrders.CommandType = System.Data.CommandType.Text cmdOrders.CommandText = SELECT * FROM Orders Dim daOrders As SqlDataAdapter = New SqlDataAdapter daOrders.SelectCommand = cmdOrders

Solution 25 Saving Time with Calculated DataColumns

355

daOrders.Fill(dsMain, Orders) Load order details Dim cmdOrderDetails As SqlCommand = cnn.CreateCommand() cmdOrderDetails.CommandType = System.Data.CommandType.Text cmdOrderDetails.CommandText = SELECT * FROM [Order Details] Dim daOrderDetails As SqlDataAdapter = New SqlDataAdapter daOrderDetails.SelectCommand = cmdOrderDetails daOrderDetails.Fill(dsMain, OrderDetails) Relate tables dsMain.Relations.Add(New DataRelation(relCustOrders, _ dsMain.Tables(Customers).Columns(CustomerID), _ dsMain.Tables(Orders).Columns(CustomerID))) dsMain.Relations.Add(New DataRelation(relOrdersDetails, _ dsMain.Tables(Orders).Columns(OrderID), _ dsMain.Tables(OrderDetails).Columns(OrderID))) Calculations will go here Bind data to the user interface dgMain.DataSource = dsMain dgMain.DataMember = Customers End Sub

The code in Listing 1 uses techniques that you learned in Solution 23. For each of the three tables involved, it creates a SqlDataCommand object that specifies the data you want to retrieve, and a SqlDataAdapter object that performs the actual retrieval. The data is all stored in a single DataSet object named dsMain. After the code loads all three tables into the DataSet, it creates DataRelation objects that mimic the relations between the tables in the original database. Finally, the form uses a DataGrid control to display the contents of the DataSet. When you have multiple tables loaded into a DataGrid, you can use the DataGrid to drill into the data. For example, follow these steps: 1. Click the plus sign (+) to the left of the first customer, ALFKI. This displays a hyperlink, relCustOrders, beneath the customer row. 2. Click the hyperlink to display all orders for this customer. 3. Click on the + sign to the left of the first order, 10643. This displays a hyperlink, relOrdersDetails, beneath the order row. 4. Click the hyperlink to display all order details for this order. Figure 1 shows the DataGrid control after drilling into this level of detail.

356

ADO.NET Solutions

Note that the header of the DataGrid provides information on the navigation path down to these details.

Adding the Calculated Column


Adding a calculated column to a DataSet requires only two lines of code. The code in Listing 1 contains a placeholder comment where these lines should be inserted:
dsMain.Tables(OrderDetails).Columns.Add(ItemTotal, _ Type.GetType(System.Double)) dsMain.Tables(OrderDetails).Columns(ItemTotal).Expression = _ UnitPrice * Quantity * (1 - Discount)

The first line of code here adds a new DataColumn named ItemTotal to the OrderDetails DataTable in the DataSet. The Add method takes two arguments in this particular overload: the name of the new column and the type of data that it will contain (here specified with the GetType method of the Type object). The second line of code then uses the Expression property of the new column to tell ADO.NET how to compute the values that this column will contain. In this particular case, the column values are calculated by using other columns in the same DataTable together with standard mathematical functions. Figure 2 shows the result of drilling down to the order details after making this change. The last column in the DataGrid displays the results of the calculation for each row. FIGURE 1:
Drilling into data with the DataGrid control

FIGURE 2:
The DataGrid with a calculated column

Solution 25 Saving Time with Calculated DataColumns

357

Working with Aggregate Data


You can also create calculated columns that contain aggregated data. This approach is similar to creating an aggregate query in SQL. For example, you could create a column that displays the total amount for an order by aggregating across the appropriate records in the order detail table. Heres the code that does this:
dsMain.Tables(Orders).Columns.Add(OrderTotal, _ Type.GetType(System.Double), Sum(Child.ItemTotal))

In this case, weve used a different overload of the Add method. This one takes the column name, data type, and expression as three arguments, so we dont need to supply the expression separately. In the expression, Child is a special keyword for the ADO.NET expression service. It refers to the child DataTable related to the current DataTable. So, in this case, the expression calculates the sum of the ItemTotal column (which is itself a calculated column) of all rows in the OrderDetails DataTable related to the current row in the Orders DataTable. To take this technique one step further, we can also calculate an overall total for the orders placed by each customer:
Add a calculated column to the customers dsMain.Tables(Customers).Columns.Add(CustomerTotal, _ Type.GetType(System.Double), _ Sum(Child(relCustOrders).OrderTotal))

This code uses a slightly different form of the expression, specifying precisely the relation that should be used in determining the child table. While not necessary in this case (because the Customers DataTable has only a single related child table), the more general syntax is necessary if a DataTable participates as the parent in two different relations. Figure 3 shows the new calculated column in the Customers table. FIGURE 3:
Calculated column aggregating data for an entire customer

358

ADO.NET Solutions

WARNING Although weve added only a single calculated DataColumn to each DataTable in this
example, you can have multiple calculated DataColumns in a single DataTable. You need to be careful, though, to avoid creating circular references (in which one column depends on another column, which in turn depends on the first column).

DataColumn Expression Syntax


The syntax for DataColumn expressions is fairly powerful. In addition to basic arithmetic and references to child columns, expressions can contain references to parent columns and some simple functions. Table 1 shows the syntax you can use in constructing a calculated DataColumn.
TA B L E 1 : DataColumn Syntax

Token
ColumnName 50 or 50.0 or 5E1 #9/2/1959# Polygon AND, OR, NOT <, >, <=, >=, <>, =, IN, LIKE +, -, *, /, % + * or % Child.ColumnName or Child(RelationName) .ColumnName Parent.ColumnName Sum(), Avg(), Min(), Max(), StDev(), Var() CONVERT(expression, type) LEN(string) ISNULL(expression, replacement) IIF(expression, truepart, falsepart)

Meaning
Refers to columns by name. If the column name contains a special character, enclose the name in square brackets. Numeric constants can be represented as integers, floating point, or in scientific notation. Date constants should be quoted with pound signs. String constants should be quoted with single quotes. Boolean operators. Comparison operators. Arithmetic operators. String concatenation operator. Wildcards for string comparison. Column in a child table.

Column in a parent table. Aggregate functions: sum, average, minimum, maximum, standard deviation, variance. Converts an expression to a .NET type. Length of a string. Returns the expression if it isnt NULL; otherwise, returns the replacement. Returns truepart or falsepart depending on whether the expression is true or false.
Continued on next page

Solution 25 Saving Time with Calculated DataColumns

359

TA B L E 1 C O N T I N U E D : DataColumn Syntax

Token
TRIM(expression) SUBSTRING(expression, start, length)

Meaning
Removes leading and trailing blanks. Returns the length number of characters from the specified starting point.

Thinking about Calculations


Now that you know about this nifty tool, when should you use it? In making the decision, you need to consider your applications user interface, potential performance penalties, and bugs. On the user interface front, remember that the calculation is being done for all rows in the affected DataTable, whether or not anyone looks at them. Thus, if youre displaying only a few rows out of a very large set of data, it may be more useful to calculate derived values when you need them instead of in advance. This avoids loading up memory and processor time with calculating things that are never used. On the other hand, if youre using a grid interface, it makes sense to calculate the values for all rows that will be seen in the grid, as we did in this solution. Performance is a tricky thing, because you need to worry about both real performance and perceived performance. Suppose youll end up displaying only 10 percent of the rows in your data but that you need to display the results of a time-consuming calculation with each row. Should you avoid the calculated DataColumn and instead perform the calculations at display time? Not necessarily. If you use a calculated DataColumn, the applications startup time may be longer, but individual rows will then display quickly, because all of the calculations will be performed in advance. This is a situation where perceived performance is more important than actual performance, arguing for the use of the calculated DataColumn. Finally, we did find one piece of unexpected behavior (that we personally would classify as a bug) that you should be aware of. Fire up the form, drill down to the order detail level, and change the quantity value for an existing row of data. Youll find that the ItemTotal column for the row is recalculated as soon as you leave the row but that the values for the OrderTotal and CustomerTotal (as displayed in the header rows) remain unchanged. Navigate back to the parent row (using the left-pointing arrow at the upper right of the DataGrid), or Alt+Tab to another application and back (forcing a screen repaint), and the OrderTotal will be updated. But try as we might, we cant find a way to force the CustomerTotal to be updated without writing code. It appears that automatic recalculation goes only one level up the hierarchy.

360

ADO.NET Solutions

SOLUTION

26
SOLUTION

Combining Tables in a DataSet


PROBLEM

My company just bought a new subsidiary, and I need to combine the customer data from their database with our existing database. I dont know whether theres any overlap in customers or even if the two tables have the same structure.

Use the Merge method of the DataSet to combine the contents of two tables. ADO.NET offers you fine control over errors caused by either schema or data discrepancies.

As you have already learned, although the DataSet is similar to a relational database, the .NET developers also took advantage of the new platform to build in capabilities that most relational databases do not have. One such feature is the Merge method, which lets you add data and schema objects to an existing DataSet in a flexible manner. This method (along with the ability to use more than one DataAdapter to place data into a single DataSet) makes the DataSet an ideal way to consolidate data from disparate sources. You might, for example, have customer data scattered across three databases and a Microsoft Excel worksheet, your order data in a fourth database, and parts data in an XML file. With judicious use of the DataSet methods, you can combine all of this data into a single relational entity.

The Flexible Merge Method


The purpose of the Merge method is to add new data to an existing DataSet. In some previous Microsoft APIs, you would have been required to put the new data into a particular format. The designers of the .NET Framework, however, have often taken a different approach by providing multiple overloads for a single method. The Merge method has seven overloads, as shown in Table 1.
TA B L E 1 : Overloads for the Merge Method

Signature
Merge(DataRow()) Merge(DataSet)

Description
Merges an array of DataRow objects to the DataSet Merges another DataSet and its schema to the DataSet
Continued on next page

Solution 26 Combining Tables in a DataSet

361

TA B L E 1 C O N T I N U E D : Overloads for the Merge Method

Signature
Merge(DataTable) Merge(DataSet, Boolean) Merge(DataRow(), Boolean, MissingSchemaAction) Merge(DataSet, Boolean, MissingSchemaAction) Merge(DataTable, Boolean, MissingSchemaAction)

Description
Merges a DataTable and its schema to the DataSet Merges another DataSet and its schema to the DataSet, optionally preserving changes in the existing DataSet Merges an array of DataRow objects to the DataSet, optionally preserving changes in the existing DataSet, with control over the handling of schema differences Merges another DataSet to the DataSet, optionally preserving changes in the existing DataSet, with control over the handling of schema differences Merges a DataTable to the DataSet, optionally preserving changes in the existing DataSet, with control over the handling of schema differences

After reading Table 1, you might consider the Merge method as several methods rolled into one. It can accept data in the form of DataRows, DataTables, or DataSets, and it can deal flexibly with schemas and changes. In the rest of this solution, Ill show you how to use some of these variations of the Merge method in your code.

Setting Up the Test Table


In this solution, Ill continue to use the Northwind sample SQL Server database, but because youll need to save some data changes and probably dont want to destroy the existing data, Ive added to the sample code a short procedure for building a test table. You should run this procedure (Listing 1) before any of the other samples in this solution.

Listing 1

Code to create and fill a sample table

Private Sub btnCreate_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnCreate.Click Create a table to work with in these examples Dim cmd As SqlCommand = cnn.CreateCommand cmd.CommandType = CommandType.Text First, create the table cmd.CommandText = CREATE TABLE NewCust(CustomerID nchar(5) & _ NOT NULL, CompanyName nvarchar(40), ContactName nvarchar(30)) cnn.Open() cmd.ExecuteNonQuery() Add a primary key to the new table cmd.CommandText = ALTER TABLE NewCust ADD CONSTRAINT & _ PK_NewCust PRIMARY KEY CLUSTERED (CustomerID) cmd.ExecuteNonQuery()

362

ADO.NET Solutions

And populate it with some sample data cmd.CommandText = INSERT INTO NewCust SELECT CustomerID, & _ CompanyName, ContactName FROM Customers WHERE Country = France cmd.ExecuteNonQuery() cnn.Close() MessageBox.Show(Table created) End Sub

In this procedure, Im using Visual Basic .NET as a convenient way to execute SQL statements on the server. Its the SQL Server that does all the work of building the new table, adding a primary key, and populating it with some initial data.

Merging Two Tables


Merging a new table into an existing table is one of the ways you can use the Merge method. To do this, you need to get both tables into memory at the same time. Lets demonstrate by building one table entirely in code and merging that new table into our existing table in the database. To start, heres some code for creating a new table. This table will exist entirely in memory as a DataTable object:
Create a new table from scratch Dim MoreCust As New DataTable(Customers) Add columns Dim c1 As New DataColumn(CustomerID, Type.GetType(System.String)) Dim c2 As New DataColumn(CompanyName, Type.GetType(System.String)) Dim c3 As New DataColumn(ContactName, Type.GetType(System.String)) MoreCust.Columns.Add(c1) MoreCust.Columns.Add(c2) MoreCust.Columns.Add(c3) DataColumn array to set primary key. Dim KeyColumns(1) As DataColumn Set primary key column. KeyColumns(0) = c1 MoreCust.PrimaryKey = KeyColumns

This code begins by creating a new, empty DataTable object. The next step is to build the columns in the table. We do this by creating three DataColumn objects. The constructor for the DataColumn objects takes the name of the new column and the data type we want to use for the column; in this case, weve matched the existing table exactly. The last part of this code sets the primary key for the new table by supplying an array of DataColumn objects (in this case, the array contains only one object) as the value for the DataTables PrimaryKey property.

Solution 26 Combining Tables in a DataSet

363

TIP

Setting the PrimaryKey property is important because it gives the Merge method the information it requires to determine whether a row is duplicated between the existing data and the new data.

The next step is to add data to the new table:


Add some data Dim r As DataRow = MoreCust.NewRow() r(CustomerID) = ZZZZZ r(CompanyName) = Zebra Importers Inc. r(ContactName) = Edward Zilliphant MoreCust.Rows.Add(r) r = MoreCust.NewRow() r(CustomerID) = YYYYY r(CompanyName) = Why a Duck Ltd. r(ContactName) = Groucho Harpo MoreCust.Rows.Add(r)

Adding data is easy. All we have to do is create new DataRow objects and then fill them with data. Note that the objects are created not with a constructor but with the NewRow method of the DataTable to which the rows will belong. This way, we can be certain that the DataRow objects will have the correct columns for the table. After each DataRow is created and filled with data, the code adds it to the Rows collection of the DataTable. Finally, the code opens a DataSet containing the existing table, merges in the new table, and saves the changes:
Open the existing table Dim cmdNewCust As SqlCommand = cnn.CreateCommand cmdNewCust.CommandType = CommandType.Text cmdNewCust.CommandText = SELECT * FROM NewCust Dim daNewCust As SqlDataAdapter = New SqlDataAdapter daNewCust.SelectCommand = cmdNewCust Dim cbNewCust As SqlCommandBuilder = New SqlCommandBuilder cbNewCust.DataAdapter = daNewCust Dim dsCustomers As DataSet = New DataSet daNewCust.Fill(dsCustomers, Customers) Merge in the new data dsCustomers.Merge(MoreCust) And save the changes daNewCust.Update(dsCustomers, Customers)

Youve seen most of the code involved here in earlier solutions. Refer to Solution 23 if you need a refresher on the DataSet, DataAdapter, and CommandBuilder objects.

364

ADO.NET Solutions

Figure 1 shows the contents of the NewCust table after we ran this code. As you can see, the new rows have been added to the existing table.

Merging Schemas with Data


In addition to merging data, the Merge method can merge schemas. That is, it can combine two tables whose columns are not identical into a single table. To demonstrate, lets create another new DataTable, but this table will contain four columns instead of three:
Create a new table from scratch Dim MoreCust As New DataTable(Customers) Add columns Dim c1 As New DataColumn(CustomerID, Type.GetType(System.String)) Dim c2 As New DataColumn(CompanyName, Type.GetType(System.String)) Dim c3 As New DataColumn(ContactName, Type.GetType(System.String)) Dim c4 As New DataColumn(Country, Type.GetType(System.String)) MoreCust.Columns.Add(c1) MoreCust.Columns.Add(c2) MoreCust.Columns.Add(c3) MoreCust.Columns.Add(c4) DataColumn array to set primary key. Dim KeyColumns(1) As DataColumn Set primary key column. KeyColumns(0) = c1 MoreCust.PrimaryKey = KeyColumns

Of course, well need some sample data:


Add some data Dim r As DataRow = MoreCust.NewRow() r(CustomerID) = XXXXX r(CompanyName) = X-Ray Company r(ContactName) = K. Roentgen r(Country) = Germany MoreCust.Rows.Add(r) r = MoreCust.NewRow() r(CustomerID) = WWWWW r(CompanyName) = Willard Industries r(ContactName) = A. Rat r(Country) = Bolivia MoreCust.Rows.Add(r)

And the call to the Merge method uses one of the other overloads:
Open the existing table Dim cmdNewCust As SqlCommand = cnn.CreateCommand cmdNewCust.CommandType = CommandType.Text cmdNewCust.CommandText = SELECT * FROM NewCust

Solution 26 Combining Tables in a DataSet

365

Dim daNewCust As SqlDataAdapter = New SqlDataAdapter daNewCust.SelectCommand = cmdNewCust Dim dsCustomers As DataSet = New DataSet daNewCust.Fill(dsCustomers, Customers) Merge in the new data dsCustomers.Merge(MoreCust, True, MissingSchemaAction.Add) Display the merged data MessageBox.Show(DataTable merged) dgCust.DataSource = dsCustomers

Figure 2 shows the results of this merge displayed on a DataGrid control. As you can see, the new rows have been merged with all of their columns, and the existing rows have been assigned NULL values in the new column. FIGURE 1:
The merged table in SQL Query Analyzer

FIGURE 2:
Merging data and schema information

366

ADO.NET Solutions

You can use the third argument of the Merge method to control what happens when the new data and existing data do not have the same schema. Table 2 shows the choices for this argument.
TA B L E 2 : MissingSchemaAction Values

Value
MissingSchemaAction.Add MissingSchemaAction.AddWithKey MissingSchemaAction.Error MissingSchemaAction.Ignore

Meaning
Adds the appropriate columns to complete the schema Adds the appropriate columns and primary keys to complete the schema Throws an error if the schema does not match Ignores any extra columns in the table being merged

Remember, the schema information in this case is merged only in the DataSet, which is an in-memory representation of the data. To save the merged data back to the original database, you would also need to add the new column to the SQL Server table, perhaps by executing an appropriate ALTER TABLE statement via a SqlCommand object.

A Practical Example
An area where the Merge method plays a critical role is in distributed applications. Thats because Merge works in conjunction with another method, GetChanges, to help minimize the amount of data that has to be transmitted between different tiers or components. To demonstrate the syntax in this case, well start with code that lies in a single component. To begin with, well retrieve the existing data from the SQL Server database and display it on a DataGrid control:
Load data from the database into the DataGrid control Dim cmdNewCust As SqlCommand = cnn.CreateCommand cmdNewCust.CommandType = CommandType.Text cmdNewCust.CommandText = SELECT * FROM NewCust Dim daNewCust As SqlDataAdapter = New SqlDataAdapter daNewCust.SelectCommand = cmdNewCust Dim dsCustomers As DataSet = New DataSet daNewCust.Fill(dsCustomers, Customers) dgCust.DataSource = dsCustomers dgCust.DataMember = Customers

Solution 26 Combining Tables in a DataSet

367

After you run this code, all of the data is displayed on the DataGrid and is available for editing. The DataGrid automatically lets you add, edit, or delete rows:

To add a new row, scroll to the end of the DataGrid and type data in the empty row that appears there. To edit data, click in any cell in the DataGrid and type the new value. To delete a row, click in the record selector to the left of the row and then press the Delete key.

When youve finished editing the data in the DataGrid, you can save changes back to the database using this code:
Prepare a fresh DataSet for updating Dim cmdNewCust As SqlCommand = cnn.CreateCommand cmdNewCust.CommandType = CommandType.Text cmdNewCust.CommandText = SELECT * FROM NewCust Dim daNewCust As SqlDataAdapter = New SqlDataAdapter daNewCust.SelectCommand = cmdNewCust Dim cbNewCust As SqlCommandBuilder = New SqlCommandBuilder cbNewCust.DataAdapter = daNewCust Dim dsCustomers As DataSet = New DataSet daNewCust.Fill(dsCustomers, Customers) Get just the changed data from the DataGrid Dim dsChanges As DataSet = CType(dgCust.DataSource, DataSet).GetChanges Merge the changed data in dsCustomers.Merge(dsChanges) And save the results daNewCust.Update(dsCustomers, Customers)

You may notice a couple of new things in this code. First, because the DataSource property of the DataGrid control is read/write, you can use this property to retrieve the data that the DataGrid is currently displaying. The code shows how you can cast this property back to a DataSet. Second, the GetChanges method of the DataSet object returns another DataSet with the same schema as the first one, but it contains only the rows that have changed since the first one was loaded. When all of the code is contained within a single tier, this method is unnecessarily complex. After all, you could just maintain the original DataSet variable at the module level and call the Update method of the associated DataAdapter when you were ready to save changes. But when the editing and save operations are on different tiers, this method of moving data makes a lot of sense.

368

ADO.NET Solutions

Suppose, for example, that youre writing a Web service that delivers data to be edited at a client. Changes made at the client must later be transmitted back to the Web service and stored in the database. You could proceed this way: 1. You provide a Web method that returns the original data to the client application. This Web method retrieves the original data from the database and returns it as a serialized DataSet. 2. The client application calls this Web method and displays the data on a DataGrid for editing. 3. Users make whatever changes they desire to the data. 4. The client application uses the GetChanges method to create a DataSet containing only the changed data. 5. You provide a second Web method that accepts a DataSet of changes as an input argument. 6. The client application calls this second Web method and sends back the DataSet of changes. 7. Within the second Web method, the Web service uses the Merge method to merge the changes into the original data and then saves the changed data back to the database. With this strategy, although all of the data must be transmitted to the client as part of the original Web method call, only the changed data is sent back to the server to be saved. This approach helps minimize the amount of data on the wire and thus maximizes the performance of the application.

More on the Merge Method


The Merge method is useful any time you have two sets of data that you want to combine into a single DataSet. In this solution, you saw how to use this method to load several types of data into an existing DataSet. The Merge method offers other uses that I didnt demonstrate in detail here. For example, suppose you have a data warehousing application that archives monthly tables of orders received by your company. In this application, you might have tables named Jan03Orders, Feb03Orders, and so on. If you need to work with combined orders from several months, you could open each of these tables in turn using its own DataAdapter object and then merge all of the results into a single consolidated DataSet.

Solution 27 Getting Customized XML from SQL Server

369

SOLUTION

27
SOLUTION

Getting Customized XML from SQL Server


PROBLEM

Information stored in our database needs to go to one of our business partners in a custom XML format. Whats the easiest way to create the required message?

Use the SQLXML extensions to SQL Server and the FOR XML clause of the SELECT statement to retrieve customized XML directly from SQL Server to your .NET application.

Theres more to .NET than the .NET languages, the .NET Framework, and Visual Studio .NET. Other Microsoft products integrate to a greater or lesser degree with .NET. One of the products thats most closely tied to .NET is Microsoft SQL Server. You already know that you can use ADO.NET to communicate with SQL Server, but the ties go much deeper than that. The SQL Server team has created a library for integrating SQL Server with XML. This library, SQLXML, includes many features that let SQL Server data interact with XML applications (and, of course, the .NET Framework is well supplied with classes for working with XML). In the current release of SQLXML, you can generate XML with SQL statements, using Microsoft T-SQL extensions to the SQL standard query language. You can also retrieve SQL Server data directly via new managed classes, as well as create Web Services that work directly with SQL Server. In this solution, well give you an overview of SQLXML, and then show you how to work with SQL Server data directly in XML messages.

Installing SQLXML
SQL Server 2000 shipped with some XML features built right into the product. For example, the FOR XML syntax (which youll see a bit later in this solution) is there in the box. But the SQL Server team has also tried to keep up with the advances in XML, as well as later product releases (such as the .NET Framework). Theyve done this by releasing a series of free Webbased setup packages under the name SQLXML. As we write this solution, the current version is SQLXML 3.0 SP1. Heres an overview of the features included in this package:

A direct Simple Object Access Protocol (SOAP) interface so that SQL Server can work with Web services without intervening components XML views via XML Schema Definition (XSD) schemas Client-side FOR XML support

370

ADO.NET Solutions

An object linking and embedding database (OLE DB) provider for SQL XML data Managed classes for exposing SQLXML functionality in the .NET environment Support for DiffGrams generated by .NET
A DiffGram is a specially formatted XML message containing only the changes to a DataSet. DiffGrams are used by the GetChanges method that we discussed in Solution 26.

NOTE

Installing SQLXML requires you to download the SQLXML package from the Microsoft Web site. To find the current release, start at the SQLXML home page, http://msdn .microsoft.com/library/default.asp?url=/nhp/default.asp?contentid=28001300. You need to have the following installed before you can successfully load SQLXML:

Windows Installer 2.0 Microsoft SOAP Toolkit 2.0 SQL Server 2000 Client Tools Microsoft Data Access Components (MDAC) 2.6 or higher .NET Framework 1.0

SQLXML 3.0 also requires Microsoft Extensible Markup Language (MSXML) 4.0, but if this component is not present on your computer, it will be installed as part of the SQLXML installation. To install SQLXML, download and run the executable. You can either choose to install all components or select specific components to install.

Generating XML with SQL Statements


Integrating Microsoft SQL Server with .NET through XML requires you to be familiar with two major tasks. First, you need to understand how to generate XML in SQL Server. Second, you must know how to consume that XML from a .NET application. Well cover these tasks in order. SQL Server allows you to retrieve the results of any SELECT query as XML rather than as a SQL result set by adding an appropriate FOR XML clause to the SELECT statement. WARNING FOR XML is a Microsoft-specific extension to the standard SQL language. That means
that you should use it only if you plan to keep your data on SQL Server permanently.

FOR XML has a rather complex syntax of its own. You can use a variety of options in the FOR XML clause to customize the XML that SQL Server generates. The three major variations of FOR XML that you should know about are:

FOR XML RAW

Solution 27 Getting Customized XML from SQL Server

371

FOR XML AUTO FOR XML EXPLICIT

Lets look at the first option, FOR XML RAW. When you use raw mode with FOR XML, SQL Server returns one element (always named row) for each row of the result set, with the individual columns represented as attributes. Heres a query that uses this option:
SELECT Categories.CategoryName, Products.ProductName, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE CategoryName = Confections AND UnitPrice < 14 FOR XML RAW

In the Northwind sample database, this query returns the result


<row CategoryName=Confections ProductName=Teatime Chocolate Biscuits UnitPrice=9.2000/> <row CategoryName=Confections ProductName=Sir Rodney&apos;s Scones UnitPrice=10.0000/> <row CategoryName=Confections ProductName=Zaanse koeken UnitPrice=9.5000/> <row CategoryName=Confections ProductName=Chocolade UnitPrice=12.7500/> <row CategoryName=Confections ProductName=Scottish Longbreads UnitPrice=12.5000/>

TIP

If you run the test queries from this solution in SQL Query Analyzer, youll discover that the results come back as a single long string, with no line breaks. Ive reformatted these results for easier display on the printed page. Also, by default Query Analyzer will show you only the first 256 characters of the result. To change this, select Tools Options Results and increase the Maximum Characters per Column setting.

The FOR XML RAW option works well for most types of data but will return a runtime error if the output contains any binary columns (such as text or image columns). You can work around this by using the BINARY BASE64 option:
SELECT Categories.CategoryName, Categories.Picture, Products.ProductName, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID

372

ADO.NET Solutions

WHERE CategoryName = Confections AND UnitPrice < 14 FOR XML RAW, BINARY BASE64

In the output XML, the BINARY BASE64 option encodes any binary data using the Base64 standard. The second variant of the FOR XML clause is FOR XML AUTO. When you use auto mode with FOR XML, nested tables in the result set are represented as nested elements in the XML. Columns are still represented as attributes. For example, try this query:
SELECT Categories.CategoryName, Products.ProductName, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE CategoryName = Confections AND UnitPrice < 14 FOR XML AUTO

The corresponding result nests the Products elements within the Categories element:
<Categories CategoryName=Confections> <Products ProductName=Teatime Chocolate Biscuits UnitPrice=9.2000/> <Products ProductName=Sir Rodney&apos;s Scones UnitPrice=10.0000/> <Products ProductName=Zaanse koeken UnitPrice=9.5000/> <Products ProductName=Chocolade UnitPrice=12.7500/> <Products ProductName=Scottish Longbreads UnitPrice=12.5000/> </Categories>

If the query returned results for more than one category, each category would be represented by a top-level Categories element with the appropriate number of nested Products elements. FOR XML AUTO also offers some additional flexibility. You can include the ELEMENTS option to represent columns as elements rather than as attributes. For example, this query specifies element-centric output:
SELECT Categories.CategoryName, Products.ProductName, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE CategoryName = Confections AND UnitPrice < 14 FOR XML AUTO, ELEMENTS

Solution 27 Getting Customized XML from SQL Server

373

The results will have additional nesting, because the columns are represented by elements rather than by attributes:
<Categories> <CategoryName>Confections</CategoryName> <Products> <ProductName>Teatime Chocolate Biscuits </ProductName> <UnitPrice>9.2000</UnitPrice> </Products><Products> <ProductName>Sir Rodney&apos;s Scones</ProductName> <UnitPrice>10.0000</UnitPrice> </Products> <Products> <ProductName>Zaanse koeken</ProductName> <UnitPrice>9.5000</UnitPrice> </Products> <Products> <ProductName>Chocolade</ProductName> <UnitPrice>12.7500</UnitPrice> </Products> <Products> <ProductName>Scottish Longbreads</ProductName> <UnitPrice>12.5000</UnitPrice> </Products> </Categories>

Finally, theres FOR XML EXPLICIT. In explicit mode, you must construct your query so as to create a result set with the first column named Tag and the second column named Parent. These columns create a self-join in the result set that is used to determine the hierarchy of the created XML file. Listing 1 is a simple example of FOR XML EXPLICIT.

Listing 1

Using FOR XML EXPLICIT

SELECT 1 AS Tag, NULL AS Parent, Categories.CategoryID AS [Category!1!CategoryID], Categories.CategoryName AS [Category!1!CategoryName], NULL AS [Product!2!ProductName] FROM Categories WHERE CategoryName = Confections UNION ALL SELECT 2, 1, Categories.CategoryID, Categories.CategoryName, Products.ProductName FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE CategoryName = Confections AND UnitPrice < 14 ORDER BY [Category!1!CategoryID], [Product!2!ProductName] FOR XML EXPLICIT

374

ADO.NET Solutions

And here are the corresponding results:


<Category CategoryID=3 CategoryName=Confections> <Product ProductName=Chocolade/> <Product ProductName=Scottish Longbreads/> <Product ProductName=Sir Rodney&apos;s Scones/> <Product ProductName=Teatime Chocolate Biscuits/> <Product ProductName=Zaanse koeken/> </Category>

As you can see, explicit mode allows you the finest control over the generated XML (for example, in this case I aliased the table names to slightly different element names), but its also the most complex mode to use in practice. You should stick to raw or auto mode whenever possible. The last thing you can do with FOR XML is to generate embedded schema information to go with the data. To do this, add the XMLDATA option to any of the forms of FOR XML. For example, this query returns both schema and data information:
SELECT Categories.CategoryName, Products.ProductName, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE CategoryName = Confections AND UnitPrice < 14 FOR XML AUTO, ELEMENTS, XMLDATA

The results include a second top-level tag with the schema information in standard XSD format:
<Schema name=Schema1 xmlns=urn:schemas-microsoft-com:xml-data xmlns:dt=urn:schemas-microsoft-com:datatypes> <ElementType name=Categories content=eltOnly model=closed order=many> <element type=Products maxOccurs=*/> <element type=CategoryName/> </ElementType> <ElementType name=CategoryName content=textOnly model=closed dt:type=string/> <ElementType name=Products content=eltOnly model=closed order=many> <element type=ProductName/> <element type=UnitPrice/> </ElementType> <ElementType name=ProductName content=textOnly model=closed dt:type=string/> <ElementType name=UnitPrice content=textOnly model=closed dt:type=fixed.14.4/>

Solution 27 Getting Customized XML from SQL Server

375

</Schema> <Categories xmlns=x-schema:#Schema1> <CategoryName>Confections</CategoryName> <Products> <ProductName>Teatime Chocolate Biscuits </ProductName> <UnitPrice>9.2000</UnitPrice> </Products> <Products> <ProductName>Sir Rodney&apos;s Scones</ProductName> <UnitPrice>10.0000</UnitPrice> </Products> <Products> <ProductName>Zaanse koeken</ProductName> <UnitPrice>9.5000</UnitPrice> </Products> <Products> <ProductName>Chocolade</ProductName> <UnitPrice>12.7500</UnitPrice> </Products> <Products> <ProductName>Scottish Longbreads</ProductName> <UnitPrice>12.5000</UnitPrice> </Products> </Categories>

Including the schema information makes the XML file more verbose, but it also provides potentially useful information about the structure of the XML to the consuming process.

Using ExecuteXmlReader
Getting data from SQL Server into an XML format is only half the battle. You still need to get this XML into an appropriate data structure within your .NET applications. Fortunately, the .NET Framework provides some support for SQL Servers XML output. You can use the ExecuteXmlReader method of the ADO.NET SqlCommand object to take a FOR XML result set and move it directly into an XmlReader object. Listing 2 contains some code that implements this method of moving XML from SQL Server to a .NET application.

Listing 2

Using the ExecuteXmlReader method

Dim strCommand As String = SELECT Categories.CategoryName, & _ Products.ProductName, Products.UnitPrice & _ FROM Categories INNER JOIN Products & _ ON Categories.CategoryID = Products.CategoryID & _ WHERE CategoryName = Confections AND & _

376

ADO.NET Solutions

UnitPrice < 14 & _ FOR XML AUTO, ELEMENTS Private Sub btnSqlXmlReader_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSqlXmlReader.Click Use a SqlCommand object to retrieve XML from a FOR XML statement Dim cmd As SqlCommand = sqlConnection1.CreateCommand cmd.CommandType = CommandType.Text cmd.CommandText = strCommand sqlConnection1.Open() Read the XML into an XmlReader Dim xr As XmlReader = _ cmd.ExecuteXmlReader() Recursively walk the reader to display its contents Dim strNode As String Dim intI As Integer Do While xr.Read Display info about element and text nodes If (xr.NodeType = XmlNodeType.Element) Or _ (xr.NodeType = XmlNodeType.Text) Then strNode = For intI = 1 To xr.Depth strNode &= Next strNode = strNode & xr.Name & strNode &= xr.NodeType.ToString If xr.HasValue Then strNode = strNode & : & xr.Value End If lbNodes.Items.Add(strNode) Walk through any attributes If xr.HasAttributes Then While xr.MoveToNextAttribute strNode = For intI = 1 To xr.Depth strNode &= Next strNode = strNode & xr.Name & strNode &= xr.NodeType.ToString If xr.HasValue Then strNode = strNode & : & xr.Value End If lbNodes.Items.Add(strNode) End While End If End If Loop Until you close things, the XmlReader will

Solution 27 Getting Customized XML from SQL Server

377

retain exclusive use of the connection xr.Close() sqlConnection1.Close() End Sub

This particular example dumps the returned XML out to a ListBox control, as shown in Figure 1. Of course, once you have XML in a .NET application, you can do many other things with it. You could, for example, convert it to a DataSet object (see Solution 28 for more details). Alternatively, you could write it to a file to be formatted by a Web server using Cascading Style Sheets (CSS). XML is so pervasive these days that you can use it in hundreds of ways. But theres a problem lurking here: the entity contained by the XmlReader object is not, in fact, XML. For example, if you try to generate an XmlDocument object from the XmlReader object, youll get a runtime error. Thats because the XML returned by SQL Servers FOR XML statements is well formed but not valid. Well-formed XML follows the rules for angle brackets, entity quoting, and the other low-level syntax of XML. But to be valid, an XML document must have an XML declaration and a root nodeand the XML from FOR XML queries lacks those things. Fortunately, the SQLXML library contains a fix, as youll see in the next section.

Using SqlXmlCommand
One reason you should install the SQLXML managed classes is that they provide the flexibility you need to turn FOR XML result sets into valid XML. The key improvement here is that these classes include a SqlXmlCommand, which has a RootTag property. This property allows you to specify a root element to be included in the generated XML. FIGURE 1:
XML from SQL Server

378

ADO.NET Solutions

Listing 3 shows how you can modify the code that you just saw to make use of a SqlXmlCommand object.

Listing 3

Using SqlXmlCommand

Private Sub btnSqlXmlCommand_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSqlXmlCommand.Click Use a SqlXmlCommand object to retrieve XML from a FOR XML statement Dim sxc As SqlXmlCommand = _ New SqlXmlCommand(Provider=SQLOLEDB; & _ Server=(local);database=Northwind; & _ Integrated Security=SSPI) sxc.CommandType = SqlXmlCommandType.Sql sxc.CommandText = strCommand Specify the root tag to produce valid XML sxc.RootTag = dataroot Read the XML into an XmlReader Dim xr As XmlReader = _ sxc.ExecuteXmlReader() Recursively walk the reader to display its contents Dim strNode As String Dim intI As Integer Do While xr.Read Display info about element and text nodes If (xr.NodeType = XmlNodeType.Element) Or _ (xr.NodeType = XmlNodeType.Text) Then strNode = For intI = 1 To xr.Depth strNode &= Next strNode = strNode & xr.Name & strNode &= xr.NodeType.ToString If xr.HasValue Then strNode = strNode & : & xr.Value End If lbNodes.Items.Add(strNode) Walk through any attributes If xr.HasAttributes Then While xr.MoveToNextAttribute strNode = For intI = 1 To xr.Depth strNode &= Next strNode = strNode & xr.Name & strNode &= xr.NodeType.ToString If xr.HasValue Then strNode = strNode & : & xr.Value End If lbNodes.Items.Add(strNode)

Solution 27 Getting Customized XML from SQL Server

379

End While End If End If Loop xr.Close() Create and save an XmlDocument xr = sxc.ExecuteXmlReader() Dim xd As XmlDocument = New XmlDocument xd.Load(xr) xr.Close() xd.Save(Products.xml) End Sub

TIP

To use the SqlXmlCommand object, youll need to add a reference and an Imports statement for the Microsoft.Data.SqlXml library.

Figure 2 shows the results. As you can see, the XML now contains the <dataroot> root tag. If you check your hard drive, youll also find a valid XML document.

Its All in the Libraries


As with many other tasks, using the right managed code library makes the task of working with XML data from SQL Server much simpler. Think for a moment about the original data returned by the XmlReader object. We could have converted this data to a valid XML document by using string-processing functions to add the appropriate tags to the start and end of the XML. Instead, we used the SqlXmlCommand class, which has this functionality built right into it. Whenever youre faced with a task in .NET that seems to require brute-force coding, its worth taking a few minutes to survey applicable classes in the .NET Framework Class Library (FCL) and elsewhere. You might save yourself a lot of effort and make your application more efficient at the same time. FIGURE 2:
Valid XML from SQL Server

380

ADO.NET Solutions

SOLUTION

28
SOLUTION

XML and the DataSet


PROBLEM

Ive received data in an XML file and want to edit it on a DataGrid. How can I move the XML data to a DataSet and then store any changes back to the original XML file?

Load the XML file into an XmlDataDocument object. The XmlDataDocument class provides a dual view of your data: you can treat it as XML or as a DataSet, depending on which operations you wish to perform at any given time.

Support for XML is pervasive in the .NET Framework Class Library (FCL). This includes support for such common XML standards as XPath and Extensible Stylesheet Language Transformations (XSLT), but it goes deeper than that. For example, the DataSet class is intimately connected with XML. Not only can you use an XML format to send DataSet changes from tier to tier (as a DiffGram), you can also render an XML document into a DataSet, or vice versa. The XmlDataDocument class lets you synchronize an XML document with a DataSet. XmlDataDocument inherits from and extends XmlDocument. The XmlDocument class, in turn, is an implementation of the Document Object Model (DOM). Before digging into the XmlDataDocument class, then, well start with a brief refresher on the DOM.

The Document Object Model


The DOM is an Internet standard for representing the information contained in an HTML or XML document as a tree of nodes. For example, consider this simple XML file:
<?xml version=1.0 encoding=utf-8 ?> <Assemblies> <Assembly> <AssemblyName>Dashboard</AssemblyName> <Weight>12</Weight> <Part> <PartName>Speedometer</PartName> <Quantity>1</Quantity> </Part> <Part> <PartName>Facing</PartName>

Solution 28 XML and the DataSet

381

<Quantity>1</Quantity> </Part> <Part> <PartName>Screws</PartName> <Quantity>8</Quantity> </Part> </Assembly> <Assembly> <AssemblyName>Transmission</AssemblyName> <Weight>262</Weight> <Part> <PartName>Gear</PartName> <Quantity>17</Quantity> </Part> <Part> <PartName>Housing</PartName> <Quantity>2</Quantity> </Part> <Part> <PartName>Clutch</PartName> <Quantity>1</Quantity> </Part> </Assembly> <Assembly> <AssemblyName>Engine</AssemblyName> <Weight>486</Weight> <Part> <PartName>Piston</PartName> <Quantity>8</Quantity> </Part> <Part> <PartName>Cylinder</PartName> <Quantity>8</Quantity> </Part> <Part> <PartName>Spark Plug</PartName> <Quantity>16</Quantity> </Part> </Assembly> </Assemblies>

Like any other XML file, this document is a series of nested items. In this case all of the items are elements, but XML files can also include attributes, processing instructions, and other items. Any nested structure can be transformed to an equivalent tree structure by making the outermost nested item the root of the tree, the next-in items the children of the root, and so on. Figure 1 shows a schematic representation of this XML file as a tree.

382

ADO.NET Solutions

FIGURE 1:
XML document represented as a tree
Assemblies

Assembly

Assembly

Assembly

Part

Part

Part

Part

Part

Part

Part

Part

Part

Individual nodes in the DOM representation of an XML document are represented in the .NET Framework by XmlNode objects (which are a part of the System.Xml namespace, along with the rest of the classes discussed in this chapter). You can obtain information about a node by reading its properties. For instance, the Type property of an XmlNode object returns a member of the XmlNodeType enumeration indicating whether a particular node represents an attribute, element, white space, and so on. After instantiating an XmlNode object that represents a particular portion of an XML file, you can alter the properties of the XmlNode object and then write the changes back to the XML file. The DOM provides two-way access to the underlying XML and is thus a convenient means for manipulating XML files. NOTE
The System.Xml namespace also contains a set of classes that represent particular types of nodes: XmlAttribute, XmlComment, XmlElement, and so on. These classes all inherit from the XmlNode class.

Some of the important properties of the XmlNode class are designed to let you move around within the DOM tree. Figure 2 shows schematically how these properties let you move from node to node.

The XmlDocument Class


Just as nodes in the DOM represent pieces of a document, XmlNode objects represent pieces of a larger whole. This larger whole is the XmlDocument class. You can instantiate an XmlDocument object from a particular XML document, and then use XmlNode objects to navigate within the XmlDocument. Among the properties of the XmlDocument class is DocumentElement, which returns a reference to the XmlNode object at the root of the DOM tree.

Solution 28 XML and the DataSet

383

FIGURE 2:
Navigational properties of the XmlNode class
Node

ild Ch st La de No nt re Pa

st Ch Pa ild re nt No de

ParentNode

Fir

PreviousSibling Node NextSibling Node

PreviousSibling Node NextSibling

Given the XmlDocument and XmlNode classes, you can see the entire structure of an XML document. For example, Listing 1 contains some code for loading and then dissecting the XML document that you saw earlier in the chapter.

Listing 1

Exploring an XML document with the DOM classes

Private Sub btnDumpDOM_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnDumpDOM.Click Dim xnod As XmlNode lbDOM.Items.Clear() Hook up to a disk file Dim xtr As New XmlTextReader(Assemblies.xml) Dont treat whitespace as significant xtr.WhitespaceHandling = WhitespaceHandling.None Create an XML document and fill it from the file Dim xdAssemblies As XmlDocument = New XmlDocument xdAssemblies.Load(xtr) Get the root node of the document xnod = xdAssemblies.DocumentElement Add it recursively to the listbox AddChildren(xnod, ) End Sub Private Sub AddChildren( _ ByVal xnod As XmlNode, ByVal Indent As String) Add the supplied node and its children recursively

384

ADO.NET Solutions

to the listbox on the form Dim xnodWorking As XmlNode Retrieve the name, type, and value of the node lbDOM.Items.Add(Indent & xnod.Name & ( & _ xnod.NodeType.ToString & ): & xnod.Value) Check for child nodes If xnod.HasChildNodes Then xnodWorking = xnod.FirstChild Visit each child node in turn While Not IsNothing(xnodWorking) AddChildren(xnodWorking, Indent & ) xnodWorking = xnodWorking.NextSibling End While End If End Sub

This code uses the XmlTextReader class to supply the XmlDocument object with an actual XML document. XmlTextReader acts as a pipe to move the document from the hard drive to the class. Theres a corresponding XmlTextWriter class that you can use to write changes back if you like. TIP
The XmlDocument.Load method can load XML from a stream, a URL, a TextReader, or an XmlTextReader.

Figure 3 shows the result of running this code.

The XmlDataDocument Class


Now that you know how to load XML from a file, you can move on to using it in conjunction with ADO.NET. The key class in this integration is the XmlDataDocument class. XmlDataDocument inherits from XmlDocument and adds several new properties and methods. The most important of these is the DataSet property, which lets you retrieve a DataSet with the same structure as the XML document. FIGURE 3:
Using the XmlDocument and XmlNode to display XML

Solution 28 XML and the DataSet

385

Listing 2 shows how you can use an XmlDataDocument to load data from an XML document into a DataSet object, which is then displayed on a DataGrid control.

Listing 2

Displaying XML on a DataGrid control

Private Sub btnLoadDataGrid_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnLoadDataGrid.Click Dim xnod As XmlNode Hook up to a disk file Dim xtr As New XmlTextReader(Assemblies.xml) Create an XMLDataDocument and the corresponding DataSet Dim xddAssemblies As XmlDataDocument = New XmlDataDocument Dim dsAssemblies As DataSet = xddAssemblies.DataSet Initialize the schema of the DataSet dsAssemblies.ReadXmlSchema(xtr) Reset the XmlTextReader xtr = New XmlTextReader(Assemblies.xml) Dont treat whitespace as significant xtr.WhitespaceHandling = WhitespaceHandling.None Fill the XmlDataDocument (and thus the DataSet) xddAssemblies.Load(xtr) Display the DataSet on the user interface dgMain.DataSource = dsAssemblies dgMain.DataMember = Assembly End Sub

Now compare Listing 2 to Listing 1 (which read XML into an XmlDocument object). Youll find a few significant changes in Listing 2:

The object that holds the XML file is declared as XmlDataDocument rather than XmlDocument. The code initializes the schema of the DataSet by calling its ReadXmlSchema method, using the same XmlTextReader that will be used by the XmlDataDocument object. This ensures that both the DataSet and the XmlDataDocument will have the same schema. The code re-creates the XmlTextReader after using it to initialize the DataSet. Thats because the XmlTextReader provides forward-only access to the underlying XML. After its been used to initialize the DataSet schema, it can no longer be used to fill the XmlDataDocument. Figure 4 shows the sample XML file displayed as a DataSet on a DataGrid control.

DataSet and XML Synchronization


The DataSet and the XmlDataDocument in Listing 2 are not two entirely distinct entities. Rather, they are two views of the same data structure in memory. If you make changes to the data in the DataSet, the same changes will show up in the XmlDataDocument, and vice versa.

386

ADO.NET Solutions

FIGURE 4:
XML file in a DataGrid

You can choose one of three ways to synchronize a DataSet and an XmlDataDocument. The first method starts with the XmlDataDocument and retrieves the DataSet from its DataSet property, as you just did. The second way begins with a DataSet and creates the corresponding XmlDataDocument from that:
Dim xddAssemblies As XmlDataDocument = _ New XmlDataDocument(dsAssemblies)

Assuming that youve already created a DataSet named dsAssemblies and filled it with data, this line of code will create the corresponding XmlDataDocument directly from the DataSet. This method is useful when you have data in a DataSet and want to use some of the XML capabilities of the .NET Framework with that data. Finally, you can follow a three-step recipe to create a schema-only DataSet and the corresponding XmlDataDocument: 1. Create a new DataSet with the proper schema but no data. 2. Create the XmlDataDocument from the DataSet. 3. Load the XML document into the XmlDataDocument. Heres some code that carries out these steps:
dsAssemblies.ReadXmlSchema(Assemblies.xml) Dim xddAssemblies As XmlDataDocument = _ New XmlDataDocument(dsAssemblies) Dim xtr As XmlTextReader = _ New XmlTextReader(Assemblies.xml) xtr.WhitespaceHandling = WhitespaceHandling.None xddAssemblies.Load(xtr)

The interesting thing about this third technique is that the DataSet isnt required to contain all of the schema information from the XML document. Although this code creates a DataSet that exactly matches the XML, you could also use an XML Schema Definition

Solution 28 XML and the DataSet

387

(XSD) file or other methods to create a DataSet that includes only some of the elements from the XML file. You can still synchronize that partial DataSet with the full XML file, giving you a DataSet thats a view into the larger document.

Using Synchronized Objects


If youve explored the ADO.NET System.Data namespace, you might have noticed that the DataSet class implements ReadXml and WriteXml methods, which enable it to communicate with XML files directly. Why, then, should you bring the XmlDataDocument class into your code? There are two major reasons to do this. First, the ReadXml and WriteXml methods dont guarantee full fidelity for the XML, which may be important in some circumstances. In particular, white space and element order might be changed if you use ReadXml to read an XML file and then later use WriteXml to write the same file. If you read and write the file using an XmlDataDocument object instead, the output file will match the formatting of the input file. More important, though, using the XmlDataDocument allows you to choose as you move through your application whether to consider data as relational tables or as XML at any point in time. In this solution, you saw how to treat XML as a DataSet so as to display it on a DataGrid. But that flexibility works both ways. For example, suppose you have a large and complex DataSet that contains customer names in several tables. Finding all of those names by creating views within the DataSet could be quite cumbersome. However, finding all instances of a particular element at any level in an XML document is an easy task for an XPath query. If you connect the DataSet to a corresponding XmlDataDocument, you can use an XPath expression in conjunction with the SelectNodes method to quickly return the required nodes. Another example of functionality that is present in XML but not in the DataSet is provided by XSLT. If you have information in a DataSet that youd like to output in formatted text, HTML, or any other structured format, you could move the data from the DataSet into an XmlDataDocument object and then use the XslTransform class together with an appropriate XSLT file to format the data as you please. Of course, the benefits work both ways. One way to move XML data into a relational database using .NET is to load the XML files into XmlDataDocument objects and then use ADO.NET with the corresponding DataSet objects to save the data. Once you start thinking of XML and DataSets as equivalent, youre sure to come up with other uses for this technique.

388

ADO.NET Solutions

SOLUTION

29
SOLUTION

DataBinding ListBoxes and ComboBoxes


PROBLEM

I want to provide users with an easy way to edit related tables. In particular, I want to be sure that only valid values can be selected for a foreign key field. How can I implement this?

This is an ideal place to use a data-bound ListBox or ComboBox control. These controls let you supply a list of acceptable values from one table and bind the selected value to a different table.

Many applications are built around the necessity of working with data. Perhaps its customer data, or inventory data, or invoice data, or employee datadatabases will be behind many of the applications that youll build during the course of your career. One thing to keep in mind is that although youll know the structure of the database thoroughly, your applications users often will not. Its up to you to provide an interface that makes it possible for users to edit data without making mistakes. Take the case of a foreignkey relationship, for example, such as the one between customers and orders. When a user is entering an order in the database, you want to make sure that he or she enters a valid customer number. In fact, ideally you want the user to choose a customer number from a list so that its impossible to make a mistake. Thats the purpose of the ListBox and ComboBox controls. In this solution, youll learn how to use these controls to enforce relationships in your applications data.

Building a Data-Input Form


For this solution, Ill start by building a simple data-input form. This form will let the user enter three pieces of information:

A customer ID An employee ID An order date

It will then use this information to create a new entry in the Orders table in the SQL Server Northwind sample database. After building the basic form, youll see how you can use the ListBox and ComboBox controls to improve it.

Solution 29 DataBinding ListBoxes and ComboBoxes

389

Building a Stored Procedure


To begin with, lets construct a stored procedure for creating new orders. Using a stored procedure rather than a SQL statement has two major advantages. First, the server can precompile the stored procedure, which results in a performance improvement. Second, stored procedures limit the ability of the user to send arbitrary SQL statements to the database, and thus increase the security of your application. To create a stored procedure using the Visual Studio .NET tools, follow these steps: 1. Launch Visual Studio .NET and create a new Visual Basic .NET Windows application. 2. Hover your mouse over the Server Explorer tab until the Server Explorer window appears. Locate a connection to the Northwind sample database. Expand the tree from this point to see the nodes within the database. Right-click the Stored Procedures node and select New Stored Procedure. This opens a stored procedure tab within the Visual Studio .NET code editor. 3. Highlight the default text for the stored procedure and replace it with this code:
CREATE PROCEDURE procInsertOrder @CustomerID nchar(5), @EmployeeID int, @OrderDate datetime AS INSERT INTO Orders (CustomerID, EmployeeID, OrderDate) VALUES (@CustomerID, @EmployeeID, @OrderDate)

4. Click the Save button on the toolbar to create the stored procedure. At this point, the necessary database plumbing is in place. The next step is to create a form that can use this plumbing.

Building the Form


The Windows application automatically comes with a default form, Form1. Open this form in design mode and then return to Server Explorer. Expand the stored procedures node under the Northwind sample database and youll find the new stored procedure. Drag the stored procedure from Server Explorer and drop it on the form. This creates two objects in the tray area at the bottom of the Form Designer: a SqlConnection object named SqlConnection1 and a SqlCommand object named SqlCommand1. Select the SqlCommand object and rename it cmdInsertOrder. Now, place three TextBox controls, three Label controls, and a Button control on the form. Figure 1 shows the completed form in design mode.

390

ADO.NET Solutions

FIGURE 1:
A form for entering orders

Name the TextBox controls txtCustomerID, txtEmployeeID, and txtOrderDate. Name the Button control btnSave. Next you need to write some code that uses the stored procedure to hook up the user interface to the database. The code in Listing 1 will take care of that.

Listing 1

Using a stored procedure to save data

Private Sub btnSave_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSave.Click Set up the parameters for the stored procedure With cmdInsertOrder .Parameters(@CustomerID).Value = txtCustomerID.Text .Parameters(@EmployeeID).Value = txtEmployeeID.Text .Parameters(@OrderDate).Value = txtOrderDate.Text End With Save the data to the database SqlConnection1.Open() cmdInsertOrder.ExecuteNonQuery() SqlConnection1.Close() MessageBox.Show(Order Added) Reset the user interface txtCustomerID.Text = String.Empty txtEmployeeID.Text = String.Empty txtOrderDate.Text = String.Empty End Sub

Solution 29 DataBinding ListBoxes and ComboBoxes

391

Testing the Form


Its always good practice to immediately test the code that you write. Run the project and enter some data on the form. For example, enter ALFKI as the customer ID, 3 as the employee ID, and 5/20/2004 as the order date. Then click the Save button. Youll see a message telling you that the order was added, and then the text boxes will be cleared. Now enter some other data: AAAAA as the customer ID, 17 as the employee ID, and 2/30/2004 as the order date. Click the Save button. This time, instead of a message telling you that the order was saved, youll break into the code with the error shown in Figure 2. The error occurs because no customer with that customer ID or employee with that employee ID exists. These are the sorts of errors that you can easily prevent simply by using appropriate controls for the user interface. The key is to present only valid choices to users rather than making them guess.

Fixing the User Interface


To make it easier for users to enter correct data into the database, lets make two changes to the data-entry form. First, you supply a list of customers in a ComboBox control. Then, you add a ListBox control with the list of employees. Youll see when the job is done that users dont have any choice but to enter valid data.

Adding a Customers Combo Box


To start the fix-up process, delete the txtCustomerID TextBox control. In its place, put a ComboBox control named cboCustomerID. Now you need a source of data that you want the ComboBox to display. Open Server Explorer again, and then drag the Customers table from the Northwind database to the form. This creates a SqlDataAdapter object in the tray. Rename the SqlDataAdapter object daCustomers. Select the daCustomers object and click the Generate Dataset link at the bottom of the Properties window to open the Generate Dataset dialog box. Enter dsCustomers in the New text box, as shown in Figure 3, and click OK. FIGURE 2:
An attempt to insert bad data

392

ADO.NET Solutions

FIGURE 3:
The Generate Dataset dialog box

Now you can set the properties of the ComboBox control to pick up the data from the DataSet object, as shown in Table 1.
TA B L E 1 : Properties for the cboCustomerID Control

Property
DataSource DisplayMember ValueMember

Value
DsCustomers1.Customers CompanyName CustomerID

With these settings, the ComboBox control will display the company name for each customer in the database but will return the corresponding CustomerID when you look at the controls value. The ComboBox control thus accomplishes two objectives. First, it limits the user to selecting only customers that already exist in the database, making it impossible for the user to enter bad data in the Customer ID column. Second, it displays information that makes sense to the user in place of arbitrary Customer ID values.

Adding an Employees List Box


Next youll repeat the same steps for the Employees data entry. This time it makes sense to use a ListBox control rather than a ComboBox, because the Employees table contains fewer records. Delete the txtEmployeeID TextBox and insert a ListBox named lboEmployeeID in its place.

Solution 29 DataBinding ListBoxes and ComboBoxes

393

First, create a source of data that you want the ListBox to display. Open Server Explorer again, and drag the Employees table from the Northwind database to the form. This creates a SqlDataAdapter object in the tray. Rename the SqlDataAdapter object daEmployees. Select the daEmployees object and click the Generate Dataset link at the bottom of the Properties window to open the Generate Dataset dialog box. Choose to create a new DataSet named dsEmployees, include the Employees table, and click OK. Now you can set the properties of the ListBox control to pick up the data from the new DataSet object, as shown in Table 2.
TA B L E 2 : Properties for the lboEmployeeID Control

Property
DataSource DisplayMember ValueMember

Value
DsEmployees1.Employuees LastName EmployeeID

The reworked form is shown in Figure 4.

Fixing the Code


Of course, you need to make some changes to the code to use the new control names. Listing 2 shows the updated code that refers to the ComboBox and ListBox controls. Note the addition of the Load procedure for the form. This procedure uses the SqlDataAdapter objects to place data in the two DataSet objects. FIGURE 4:
The new form, which uses a ComboBox and a ListBox

394

ADO.NET Solutions

Listing 2

Revised data-entry code

Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Fill the DataSets daCustomers.Fill(DsCustomers1, Customers) daEmployees.Fill(DsEmployees1, Employees) End Sub Private Sub btnSave_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSave.Click Set up the parameters for the stored procedure With cmdinsertOrder .Parameters(@CustomerID).Value = cboCustomerID.SelectedValue .Parameters(@EmployeeID).Value = lboEmployeeID.SelectedValue .Parameters(@OrderDate).Value = txtOrderDate.Text End With Save the data to the database SqlConnection1.Open() cmdinsertOrder.ExecuteNonQuery() SqlConnection1.Close() MessageBox.Show(Order Added) Reset the user interface cboCustomerID.SelectedIndex = 0 lboEmployeeID.SelectedIndex = 0 txtOrderDate.Text = String.Empty End Sub

Testing the Fixed Form


Now run the project again. Instead of entering arbitrary values for the CustomerID and EmployeeID, you can just pick the customer and employee from lists in the respective controls. Of course, the ComboBox control displays the list only when you click the down arrow, while the ListBox control always displays the list. Select values, enter an order date, and click the Save button. As long as you use a valid format for the date, you should have no trouble with the new interface.

Binding Choices
Data binding in .NET forms is not limited to DataSets. In fact, a ListBox or ComboBox control can use any object that supports the IList or IListSource interface as a source of data. This means that in some cases you might like to create the data for a ListBox or ComboBox directly in your code, rather than retrieving it from a database. For example, lets see how you could alter the form to supply a fixed list of employees. First, create a class to represent employees, as shown in Listing 3.

Solution 29 DataBinding ListBoxes and ComboBoxes

395

Listing 3

A simple Employees class

Public Class Employee Private m_EmployeeID As Integer Private m_LastName As String Sub New(ByVal EmployeeID As Integer, ByVal LastName As String) m_EmployeeID = EmployeeID m_LastName = LastName End Sub Public ReadOnly Property EmployeeID() As Integer Get EmployeeID = m_EmployeeID End Get End Property Public ReadOnly Property LastName() As String Get LastName = m_LastName End Get End Property End Class

With this class available, you can delete the SqlDataAdapter and DataSet objects that work with the Employees table. Instead, modify the forms Load procedure to create an array of Employee objects, as shown in Listing 4. Because an array supports the IList interface, you can bind the array directly to the ListBox control.

Listing 4

Revised Form Load procedure

Private Sub Form2_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Fill the Customers DataSet daCustomers.Fill(DsCustomers1, Customers) Create and bind a list of employees Dim aEmployees() As Employee = _ {New Employee(1, Davolio), _ New Employee(2, Fuller), _ New Employee(3, Leverling), _ New Employee(4, Peacock), _ New Employee(5, Buchanan), _ New Employee(6, Suyama), _ New Employee(7, King), _ New Employee(8, Callahan), _ New Employee(9, Dodsworth)} With lboEmployeeID

396

ADO.NET Solutions

.DataSource = aEmployees .DisplayMember = LastName .ValueMember = EmployeeID End With End Sub

The other code behind the form can remain unchangedthe only difference is that now the Employees ListBox is being filled from code rather than from the database. This approach has both pros and cons, of course. On the one hand, avoiding the trip to the database speeds up your application. On the other, the list of employees is now hard-coded directly into your application, making it much more difficult to change. In general, this technique is best suited for lookup data that seldom or never changes. For example, it would be a good way to put a list of state abbreviations or months of the year into a ComboBox control.

Using Bound Controls


I like to think of the ListBox and ComboBox controls as little pumps that move data from one part of my application to another. The DataSource property of the control tells the control where to get the list of items to use. The DisplayMember property tells the control which information to show to the user, and the ValueMember property tells the control which information to pass to the rest of the application. By properly using these controls, you can make life simpler for your applications users. The ability of these controls to prevent data-entry errors and to display friendly information instead of arbitrary keys makes them a great addition to many data-centric applications.

SOLUTION

30
SOLUTION

Advanced DataBinding
PROBLEM

In VB6, I used the events of the ADO Data Control to react to events in bound data. But VB.NET doesnt have data controls! How can I run my own code as a user moves around in a DataSet or modifies the data displayed in bound controls?

Its true that the Data Control no longer exists, but VB.NET does provide objects that synchronize data access for bound controls. Youll need to understand the BindingContext and CurrencyManager classes. Then you can work with CurrencyManager events to intercept user actions before the data changes.

Solution 30 Advanced DataBinding

397

Sometimes code from VB6 will run almost unchanged in .NETand sometimes you need to learn an entirely new way of thinking about a problem. Interacting with bound data is definitely in the latter category. In VB6, the ADO Data Control provided a handy way for you to intercept data events. In the .NET world, this control has been replaced by the CurrencyManager and BindingContext objects.

.NET Data-Binding Architecture


The .NET Framework implements data binding on forms using a part of objects, both of which are in the System.Windows.Forms namespace: the CurrencyManager object and the BindingContext object. A form with a single data-bound control will instantiate one of each of these objects. A more complex form may require multiple CurrencyManager and BindingContext objects to allow it to keep track of several data sources at the same time. Each data source on a form has its own CurrencyManager object. The CurrencyManager object is responsible for keeping track of which piece of data from a data source is currently displayed on the user interface. If all controls on a form are bound to a single data source (for example, if you have multiple text boxes all drawing data from a single DataSet), then they can all share a CurrencyManager object. But a single form can also use multiple data sources (and hence multiple CurrencyManager objects). For example, a form that displays data from the Customers table on a DataGrid control, and data from the Orders table on a second DataGrid control, would use one CurrencyManager for each table. Any form with bound controls will also have at least one BindingContext object. The BindingContext object keeps track of the various CurrencyManager objects for the forms controls. The Item property of the BindingContext object returns a CurrencyManager object. For example, if you are binding controls to a DataTable named dtCustomers, you could write this line of code:
Me.BindingContext(dtCustomers).Position += 1

That tells the BindingContext for the current form to return a CurrencyManager object for the dtCustomers data source, and then to increment the Position property of the CurrencyManager object. The result is to display the next customer in the bound controls on the form. Knowledge of how to move the current record pointer within the DataTable is encapsulated within the CurrencyManager object, and the form uses this knowledge to display the correct record when you change the Position property. If the form always has a single BindingContext object, when might there be more than one active BindingContext? The answer is that container controls (for example, the GroupBox, Panel, or TabControl controls) will have their own BindingContext object for the data sources used within those container controls. By placing Panel controls or other container controls on a form and using these separate BindingContext objects, you can create forms that have two or more independently scrolling views of the same data.

398

ADO.NET Solutions

Figure 1 shows schematically how these objects fit together. In this particular case, a form displays bound information from the Customers table and the Orders table, while a Panel control on the form also displays information from the Customers table in its own bound controls.

A Data-Binding Example
Figure 2 shows a sample form that makes use of the CurrencyManager and BindingContext objects. In this particular form, the buttons manipulate the Position property of the CurrencyManager object, while the ListBox intercepts events from the same object. FIGURE 1:
Windows Form databinding architecture
Form Panel

BindingContext

BindingContext

CurrencyManager

CurrencyManager

CurrencyManager

Orders

Customers

FIGURE 2:
Using the CurrencyManager object

Listing 1 shows the code that enables the functionality on this form.

Solution 30 Advanced DataBinding

399

Listing 1

A simple data-binding example

Dim dsCustomers As DataSet = New DataSet Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Load Customers Dim cmdCustomers As SqlCommand = cnn.CreateCommand Dim daCustomers As SqlDataAdapter = New SqlDataAdapter cmdCustomers.CommandType = CommandType.Text cmdCustomers.CommandText = SELECT * FROM Customers daCustomers.SelectCommand = cmdCustomers daCustomers.Fill(dsCustomers, Customers) Bind data to the user interface txtCustomerID.DataBindings.Add(Text, dsCustomers, _ Customers.CustomerID) txtCompanyName.DataBindings.Add(Text, dsCustomers, _ Customers.CompanyName) txtContactName.DataBindings.Add(Text, dsCustomers, _ Customers.ContactName) Add event handlers Dim cm As CurrencyManager = _ Me.BindingContext(dsCustomers, Customers) AddHandler cm.CurrentChanged, _ AddressOf CurrencyManager_CurrentChanged AddHandler cm.ItemChanged, _ AddressOf CurrencyManager_ItemChanged AddHandler cm.PositionChanged, _ AddressOf CurrencyManager_PositionChanged End Sub Private Sub CurrencyManager_CurrentChanged( _ ByVal sender As Object, ByVal e As EventArgs) lbEvents.Items.Add(CurrentChanged) End Sub Private Sub CurrencyManager_ItemChanged( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.ItemChangedEventArgs) lbEvents.Items.Add(ItemChanged) End Sub Private Sub CurrencyManager_PositionChanged( _ ByVal sender As Object, ByVal e As System.EventArgs) lbEvents.Items.Add(PositionChanged) End Sub Private Sub btnNext_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnNext.Click

400

ADO.NET Solutions

Me.BindingContext(dsCustomers, Customers).Position += 1 End Sub Private Sub btnPrev_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnPrev.Click Me.BindingContext(dsCustomers, Customers).Position -= 1 End Sub

The code starts by retrieving a DataSet containing a list of customers. (The cnn object in the code is a SqlConnection object to the local copy of the Northwind sample database.) After the DataSet is loaded with data, that data can be bound to the user interface. While you can bind data using the Properties window at design time (expand the (DataBindings) property of the target control to do this), Ive chosen to use runtime data binding instead in this instance. In the .NET world, any control has a DataBindings collection that specifies all of the bound properties for the control. To set up a new binding, you add an object to the DataBindings collection of the control. For example:
txtCustomerID.DataBindings.Add(Text, dsCustomers, _ Customers.CustomerID)

This binds the CustomerID column from the Customers table in the dsCustomers DataSet object to the Text property of the txtCustomerID control. Similar code binds data from two other columns to the other TextBox controls on the form. The next step in this code is to add event handlers for the three events that the CurrencyManager supports. First, the code must retrieve the proper CurrencyManager. Note that in this case I must supply a DataMember as well as a DataSource to identify the CurrencyManager that I want to work with, because the dsCustomers DataSet could contain more than one bindable entity. The code makes use of VB.NETs ability to dynamically add event handlers at runtime. For example, this line of code adds a handler for the CurrentChanged event:
AddHandler cm.CurrentChanged, _ AddressOf CurrencyManager_CurrentChanged

To use the AddHandler keyword, you specify the object and event that you wish to handle, as well as the address of a procedure that will handle the event. The procedure must have exactly the event signature that the object expects, or youll get a runtime error. The final piece of the puzzle here is the code for moving around in the DataSet. This again makes use of the CurrencyManager object by manipulating its Position property. Incrementing the value of the Position property moves to the next record; decrementing the value moves to the previous record. Note that this is definitely not production-quality code; it lacks even rudimentary error handling.

Solution 30 Advanced DataBinding

401

CurrencyManager events
The CurrencyManager object exposes three events:

CurrentChanged ItemChanged PositionChanged

The names of the CurrencyManager events may be a bit confusing. The CurrentChanged event fires whenever the data on the user interface changes. Thats true whether the change is a result of the user typing new data or because the position in the data source has changed. The PositionChanged event fires whenever the Position property of the CurrencyManager changes. In practice, youll see a CurrentChanged event whenever you see a PositionChanged event, but you can also get CurrentChanged events without a change of Position. Finally, the ItemChanged event is fired when the data itself is changed by an external factor. For example, if you were to reload the DataSet and the new data differed from the old data, the CurrencyManager would fire an ItemChanged event.

Other CurrencyManager Details


The CurrencyManager class offers a fairly rich interface for programmatically working with data, in addition to the events that youve already seen. Table 1 lists some of the members of the CurrencyManager class.
TA B L E 1 : Members of the CurrencyManager Class

Member
AddNew Bindings CancelCurrentEdit Count Current CurrentChanged EndCurrentEdit GetItemProperties

Type
Method Property Method Property Property Event Method Method

Description
Adds a new object to the underlying list (inherited from BindingManagerBase) Gets the collection of Bindings being managed by this class (inherited from BindingManagerBase) Cancels any edit in progress (inherited from BindingManagerBase) Gets the number of rows managed by the class (inherited from BindingManagerBase) Gets the current object (inherited from BindingManagerBase) Occurs when the bound value changes (inherited from BindingManagerBase) Commits any edit in process (inherited from BindingManagerBase) Gets a list of item properties for the current object in the list (inherited from BindingManagerBase)
Continued on next page

402

ADO.NET Solutions

TA B L E 1 C O N T I N U E D : Members of the CurrencyManager Class

Member
ItemChanged List Position PositionChanged Refresh RemoveAt ResumeBinding SuspendBinding

Type
Event Property Property Event Method Method Method Method

Description
Occurs when the current item has been altered Gets the IList interface from the data source Gets or sets the position in the underlying list that is bound with this class (inherited from BindingManagerBase) Occurs when the Position property changes (inherited from BindingManagerBase) Repopulates the bound controls Removes the object at the specified position from the underlying list (inherited from BindingManagerBase) Resumes data binding (inherited from BindingManagerBase) Suspends data binding (inherited from BindingManagerBase)

The BindingManagerBase class is an abstract class that is implemented in both the CurrencyManager class and the PropertyManager class. Youve already seen the CurrencyManager class. The PropertyManager class is used to manipulate the current value of an individual property rather than the property of the current object in a list; youll seldom have any reason to use the PropertyManager class yourself. Youve already seen how to use the events and the Position property of the CurrencyManager class. Here are some possibilities for other members of the class:

The CancelCurrentEdit method can be used to implement an Undo facility for a particular control or form. The Count property gives you a way to tell whether youre looking at the last record in a data source (if you are, the Position and Count properties will return the same value). The EndCurrentEdit method can be used to implement a Save button. The Refresh method can be used to make sure that the user interface is showing the most current data from your data model. The RemoveAt method can be used to implement a Delete button for the current record.

Data Binding the .NET Way


The BindingContext and CurrencyManager classes can be very happy when youre migrating a record-oriented data-bound application forward from VB6. In these applications, youre generally operating with a cursor of data and manipulating the current record.

Solution 31 Synchronizing DataSets with DiffGrams

403

But for new applications, you should keep in mind that the DataSet object is not a cursor, even though you can write code to make it act like one. The .NET Framework is designed around the concept of disconnected data and having an entire set of data available on the client at once. That is, although you can pretend theres only a single record current at any given time, in fact every record in a DataSet was retrieved at the same time, and all changes will be pushed back to the server in a single update operation. To make your own applications work more like the rest of .NET, you might want to consider validating data at update time for the entire DataSet, rather than forcing code to run whenever the user changes a record.

SOLUTION

31
SOLUTION

Synchronizing DataSets with DiffGrams


PROBLEM

I want to ship a DataSet from my application to a remote client application, and then get edits back from the remote application. Because the link is slow, Id like to minimize the amount of data thats returned after editing.

In .NET, DataSets support DiffGrams, which are condensed XML messages listing only changes to data. By sending and then merging a DiffGram, you can keep the amount of data transferred to a minimum.

Weve mentioned DiffGrams several times in earlier solutions, but this is a problem for which they are ideally suited. Data access in the .NET Framework is designed to offer excellent support for disconnected, distributed architectures. That is, you can retrieve data from one computer, move it to another computer to edit, and then send the changes back to the first computer to be saved. DiffGrams are an essential part of this architecture; they offer a lightweight format for moving data changes between components. In this solution, youll see how to enable distributed editing between client and server applications. The sample code for this solution has two parts. First, theres a Web service that can send a DataSet to the client or receive a DiffGram back from the client. Second, theres a client application that can work with the data provided by the Web service.

404

ADO.NET Solutions

Creating the Server Application


Weve chosen to use a Web service to provide data and accept edits from distributed clients. A Web service is ideal for this purpose, because it offers a standard interface and good scalability. For the sample code, well build both the Web service and the client application on the same computer. Keep in mind, though, that they could be on different machines connected only by the Internet. To get started, launch Visual Studio .NET and create a new, blank solution. Right-click on the solution in Solution Explorer and select Add New Project. This will open the Add New Project dialog box; select ASP.NET Web Service, as shown in Figure 1. Name the new service CustomerService and create it on the development computer by using localhost as the server name. After you click OK and Visual Studio .NET creates the project, right-click on the Service1.asmx component in Solution Explorer and rename it Customers.asmx. NOTE
These instructions assume that youre running Microsofts Internet Information Services (IIS) on your development computer. If thats not the case, youll need to replace localhost with the name of a Web server to which you have access.

TIP

You should use a connection that includes a SQL Server user ID and password for this example. Integrated security, while more secure in general, is difficult to get working in a Web service.

To complete the Web service, open Server Explorer and locate a connection to the Northwind sample database. Drag this connection and drop it on the design surface for the Web service. Then double-click the background area of the design surface to open the code editor. Enter the code shown in Listing 1 to complete the Web service. FIGURE 1:
Creating a new Web service

Solution 31 Synchronizing DataSets with DiffGrams

405

NOTE

You wont enter the line of code generated by the Web Services Designer, of course.

Listing 1

A Web service for working with customer data

Imports System.Web.Services Imports System.Data.SqlClient Imports System.IO <System.Web.Services.WebService( _ Namespace:=http://tempuri.org/CustomerService/Service1)> _ Public Class Customers Inherits System.Web.Services.WebService +[ Web Services Designer Generated Code ] <WebMethod()> _ Public Function GetCustomers() As DataSet Fill a local DataSet with customer info Dim dsCustomers As DataSet = New DataSet Dim cmdCustomers As SqlCommand = _ SqlConnection1.CreateCommand cmdCustomers.CommandType = CommandType.Text cmdCustomers.CommandText = _ SELECT * FROM Customers Dim da As SqlDataAdapter = _ New SqlDataAdapter da.SelectCommand = cmdCustomers da.Fill(dsCustomers, Customers) And serialize it to the calling client GetCustomers = dsCustomers End Function <WebMethod()> _ Public Function UpdateCustomers(ByVal DiffGram As String) Fill a local DataSet with customer info Dim dsCustomers As DataSet = New DataSet Dim cmdCustomers As SqlCommand = _ SqlConnection1.CreateCommand cmdCustomers.CommandType = CommandType.Text cmdCustomers.CommandText = _ SELECT * FROM Customers Dim da As SqlDataAdapter = _ New SqlDataAdapter da.SelectCommand = cmdCustomers Use the CommandBuilder to build the update commands Dim cb As SqlCommandBuilder = New SqlCommandBuilder(da) da.Fill(dsCustomers, Customers) Apply the DiffGram to the DataSet Dim sr As StringReader = New StringReader(DiffGram) Dim dsChanges As DataSet = dsCustomers.Clone()

406

ADO.NET Solutions

dsChanges.ReadXml(sr, XmlReadMode.DiffGram) dsCustomers.Merge(dsChanges) Save changes da.Update(dsCustomers, Customers) End Function End Class

Select Build Build Solution to compile the server project. Well dig into this code in detail later in the solution, but at first inspection you should be able to see that theres one method that sends data out and another that takes data in. Both are marked with the WebMethod attribute so that theyre available to clients of the Web service. The next step is to build such a client.

Creating the Client Application


To create the client application, first add a new Visual Basic .NET Windows application to the solution. We named ours CustomerClient. The next step is to tell the client about the interface provided by the server. To do this, youll need to set a Web reference. Right-click on the References node for the project in Solution Explorer and select Add Web Reference. In the Add Web Reference dialog box, click the link to browse Web services on the local machine. This will display hyperlinks for all Web services on the server, as shown in Figure 2. FIGURE 2:
Adding a Web reference

Solution 31 Synchronizing DataSets with DiffGrams

407

Click on the hyperlink for the Customers service to display the operations supported by the Web service. Enter CustomerService as the Web Reference Name and click Add Reference to add a Web reference to the service. Add two controls to the default form in the new project: a DataGrid control named dgCustomers and a Button control named btnSaveChanges. Then, switch to the code editor and add the client code shown in Listing 2.

Listing 2

Client for distributed editing

Imports System.Data.SqlClient Imports System.IO Public Class Form1 Inherits System.Windows.Forms.Form +[ Windows Form Designer generated code ] Customer data from the Web Service Dim dsCustomers As DataSet Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Connect to the Web Service Dim customers As CustomerService.Customers = _ New CustomerService.Customers Retrieve the data to a local DataSet dsCustomers = customers.GetCustomers And display it on the user interface dgCustomers.DataSource = dsCustomers dgCustomers.DataMember = Customers End Sub Private Sub btnSaveChanges_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSaveChanges.Click Extract a DiffGram of changes in the DataSet Dim dsChanges As DataSet = dsCustomers.GetChanges() Dim sw As System.IO.StringWriter = _ New System.IO.StringWriter dsChanges.WriteXml(sw, XmlWriteMode.DiffGram) Connect to the Web Service Dim customers As CustomerService.Customers = _ New CustomerService.Customers Update the changes customers.UpdateCustomers(sw.ToString()) And refresh the user interface dsCustomers = customers.GetCustomers dgCustomers.DataSource = dsCustomers dgCustomers.DataMember = Customers End Sub End Class

408

ADO.NET Solutions

Testing the Solution


To test the solution, begin by right-clicking on the client project and selecting Set As Startup Project. Then, select Debug Start or press F5 to launch the client. When you run the application, the client will contact the Web service for a DataSet of customer information and then display this data on the user interface, as shown in Figure 3. With the data displayed, you can carry out any of the fundamental editing operations:

To change existing data, click in the appropriate cell in the grid and type the new value. To delete an existing record, click on the record selector to the left of the data and then press the Delete key. To add a new record, scroll to the empty row at the end of the grid and type in the new data.

You can make as many edits as you like before saving any changes. Remember, the data youre working with here is disconnected from the database. In fact, its doubly disconnected: the Web service loads the data from the database, and then the client application creates its own local DataSet from that data. When youve finished editing, click the Save Changes button. This will transmit your changes to the Web service by sending a DiffGram. After the changes have been applied, the client application will reload the grid to show the current data. Remember, in a multi-user setting other users could have been changing records at the same time that you were. FIGURE 3:
Working with customer data

Solution 31 Synchronizing DataSets with DiffGrams

409

Walking Through the Code


Lets follow the process from start to finish. Although its simple, this little application will give you practice in seeing how a distributed .NET application can work. The process begins when the user loads the client form. This launches the code in that forms Load event:
Customer data from the Web Service Dim dsCustomers As DataSet Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Connect to the Web Service Dim customers As CustomerService.Customers = _ New CustomerService.Customers Retrieve the data to a local DataSet dsCustomers = customers.GetCustomers And display it on the user interface dgCustomers.DataSource = dsCustomers dgCustomers.DataMember = Customers End Sub

You should notice two general things about this code snippet. First, the dsCustomers DataSet is a persistent local variable. Its filled from elsewhere, but the data remains in this part of the application for as long as the user cares to work with it. This sort of disconnected editing is quite common in .NET applications. Its easy to move data around from tier to tier because the DataSet was designed to hold schema information as well as to keep track of which rows have been edited. The second thing to notice is that theres no way to tell from this bit of code that customers .GetCustomers is a call to a Web method. As far as the client code is concerned, customers is just another object. The fact that its an object instantiated from a Web service is buried deep inside the plumbing that setting the Web reference creates for you. Behind the scenes, the .NET Framework knows to turn this method call into a Simple Object Access Protocol (SOAP) request and send it to the Web services server. There it is routed to the GetCustomers method:
<WebMethod()> _ Public Function GetCustomers() As DataSet Fill a local DataSet with customer info Dim dsCustomers As DataSet = New DataSet Dim cmdCustomers As SqlCommand = _ SqlConnection1.CreateCommand cmdCustomers.CommandType = CommandType.Text cmdCustomers.CommandText = _

410

ADO.NET Solutions

SELECT * FROM Customers Dim da As SqlDataAdapter = _ New SqlDataAdapter da.SelectCommand = cmdCustomers da.Fill(dsCustomers, Customers) And serialize it to the calling client GetCustomers = dsCustomers End Function

The WebMethod attribute tells .NET that this method should be available to clients of the Web service. Once again, just looking at this piece of code gives you no other hint that this is meant for distributed data access. The DataSet full of customers is the return value from the function. Its the behind-the-scenes plumbing that converts that DataSet to XML and sends it out as part of the SOAP response to the client. When the code calls the Fill method of the DataAdapter, the DataAdapter opens a connection to the database for just long enough to load the data. After that, it closes the connection again, and the data has become disconnected from the database. Its held briefly in the memory of the Web service machine as a DataSet before being converted to an XML message and being squirted back to the client. On the client side, the Load event handler finishes its job by taking the reconstituted DataSet and displaying it on the DataGrid control on the user interface. The user can then work with the data on the user interface as long as he or she cares to. The client application keeps the data displayed on the DataGrid and the data in the DataSet synchronized as these edits are happening. When the user clicks the Save Changes button, the return trip is put into motion. This button has its own event handler:
Private Sub btnSaveChanges_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnSaveChanges.Click Extract a DiffGram of changes in the DataSet Dim dsChanges As DataSet = dsCustomers.GetChanges() Dim sw As System.IO.StringWriter = _ New System.IO.StringWriter dsChanges.WriteXml(sw, XmlWriteMode.DiffGram) Connect to the Web Service Dim customers As CustomerService.Customers = _ New CustomerService.Customers Update the changes customers.UpdateCustomers(sw.ToString()) And refresh the user interface dsCustomers = customers.GetCustomers dgCustomers.DataSource = dsCustomers dgCustomers.DataMember = Customers End Sub

Solution 31 Synchronizing DataSets with DiffGrams

411

The WriteXml method of the DataSet object has several purposes, depending on the value you supply for the XmlWriteMode argument. Table 1 shows the possible values for this argument.
TA B L E 1 : XmlWriteMode Values

Value
DiffGram IgnoreSchema WriteSchema

Meaning
Writes the DataSet as a DiffGram Writes the DataSet as XML with no schema Writes the DataSet as XML with an inline XML Schema Definition (XSD) schema

Note that the XmlWriteMode.DiffGram value instructs the WriteXml method to write the entire DataSet out as a DiffGram, including any unchanged rows. This isnt consistent with the original goal of sending only the minimum amount of data back from the user interface to the server when the user is done editing. Thats where the GetChanges method of the DataSet comes into play. This method returns a DataSet with the identical schema but that contains only data that has changed since the DataSet was loaded. So the code first calls GetChanges to create a new DataSet containing only changed rows, and then calls WriteXml to convert those changed rows to a DiffGram. The target of the WriteXml method is a StringWriter, which provides a way to easily turn the XML stream into a string. That string is then used as an argument to the customers.UpdateCustomers method. At this point, of course, the Web services plumbing takes over and wraps the string containing XML up in a SOAP request and ships it off to the Web service. At the Web service end of things, the UpdateCustomers Web method takes over:
<WebMethod()> _ Public Function UpdateCustomers(ByVal DiffGram As String) Fill a local DataSet with customer info Dim dsCustomers As DataSet = New DataSet Dim cmdCustomers As SqlCommand = _ SqlConnection1.CreateCommand cmdCustomers.CommandType = CommandType.Text cmdCustomers.CommandText = _ SELECT * FROM Customers Dim da As SqlDataAdapter = _ New SqlDataAdapter da.SelectCommand = cmdCustomers Use the CommandBuilder to build the update commands Dim cb As SqlCommandBuilder = New SqlCommandBuilder(da) da.Fill(dsCustomers, Customers) Apply the DiffGram to the DataSet

412

ADO.NET Solutions

Dim sr As StringReader = New StringReader(DiffGram) Dim dsChanges As DataSet = dsCustomers.Clone() dsChanges.ReadXml(sr, XmlReadMode.DiffGram) dsCustomers.Merge(dsChanges) Save changes da.Update(dsCustomers, Customers) End Function

This function begins by loading a fresh DataSet with the current data from the Customers table. It then uses the Clone method to return a second DataSet with the identical structure but no data. This empty DataSet has the proper schema to hold the contents of the DiffGram that came back from the user interface tier of the application. The ReadChanges method loads the DiffGram into the previously empty DataSet. Finally, the Merge method merges the changes back into the original DataSet, which is then written back to the database by calling the Update method of the SqlDataAdapter object. The story ends back on the client tier, where the SaveChanges method reloads the local DataSet with the current data from the server and displays it once again on the user interface.

Distributed Possibilities
This application shows you one possible pattern for passing data between distributed tiers in a .NET application. If you work through the code, youll see that the methods of the DataSet are the key to making this work efficiently. The ability to create XML DiffGrams, clone an empty DataSet with the proper schema, and merge two DataSets with identical schemas is built into the design of the DataSet class. If youre going to be designing distributed applications, the time you spend learning the capabilities of this class will be well rewarded.

SOLUTION

32
SOLUTION

The 10-Minute Guide to Paging Data


PROBLEM

I need to display a substantial amount of data in an ASP.NET application. Id like to break the data up into multiple pages to improve performance. What are my options?

Use a DataGrid control to display the data. The DataGrid Web server control allows you to use either built-in or custom paging logic to spread its contents across multiple pages.

Solution 32 The 10-Minute Guide to Paging Data

413

ASP.NET applications can display a grid of data just as well as Windows Forms applications can. But the ASP.NET architecture imposes some limitations on the process. For example, shipping a huge amount of data to the browser is usually a bad idea, because it will have a serious impact on performance. What are you to do, then, when you have many database records to display? One answer is to use the built-in paging capabilities of the ASP.NET DataGrid control. With paging, you can retrieve a large number of records and then send them to the client in batches. This approach improves performance and makes your users happier.

Data Without Paging


Before looking at the solution, lets dig into the problem. Follow these steps to create an ASP.NET application that displays a large amount of data without paging: 1. Launch Visual Studio .NET and create a new ASP.NET Web application named Paging on the local server. Im assuming that both Internet Information Services (IIS) and SQL Server are available on your development machine; if not, you may need to alter some paths. 2. Drag the Order Details table from the Northwind sample database out of Server Explorer and drop it on the default Web Form in the project to create a SqlConnection1 object and a SqlDataAdapter1 object. 3. Select the SqlDataAdapter1 object and click the Generate Dataset link in the Properties window. Enter dsOrderDetails as the name of the new DataSet and click OK. 4. Place a DataGrid control on the Web Form and name it dgOrderDetails. Set the DataSource property of dgOrderDetails to DsOrderDetails1 and set the DataMember property to Order Details. 5. Double-click the Web Form and add this code to the Load event:
Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load If Not IsPostBack Then SqlDataAdapter1.Fill(DsOrderDetails1, _ Order Details) DataBind() End If End Sub

TIP

ASP .NET does not perform automatic data binding. When you want data to appear on the user interface, you must call the DataBind method in your code.

6. Run the project. Youll see the Web page shown in Figure 1. As you can tell from the scroll bar, this page is quite extensive. In fact, if you save the source it weighs in at a whopping 700 KB. Thats probably not something your users will want to download over their Internet connection.

414

ADO.NET Solutions

FIGURE 1:
Too much data on a Web Form

Using Automatic Paging


Microsoft realized that paging would be a common demand for ASP.NET applications, so it built that feature right into the DataGrid control. To see paging in action, you need to change a few properties of the control. The easiest way to set up paging is to open the Web Form in design view in Visual Studio .NET. Then select the DataGrid control and click the Property Builder link at the bottom of the Properties window. This will open the DataGrid Properties dialog box, as shown in Figure 2. Select the Paging category on the left to work with the properties that control paging. To turn on automatic paging, check the Allow Paging checkbox and enter a page size. In most cases, youll also want to show the navigation buttons so that users can move to other pages of data. Click OK when youve set the paging properties. Youll also need to add a bit of code behind the page:
Private Sub dgOrderDetails_PageIndexChanged( _ ByVal source As Object, ByVal e As _ System.Web.UI.WebControls.DataGridPageChangedEventArgs) _ Handles dgOrderDetails.PageIndexChanged dgOrderDetails.CurrentPageIndex = e.NewPageIndex SqlDataAdapter1.Fill(DsOrderDetails1, Order Details) DataBind() End Sub

Solution 32 The 10-Minute Guide to Paging Data

415

FIGURE 2:
Setting up automatic paging

Each time the user clicks one of the paging controls, this executes a postback and triggers the PageIndexChanged event. Inside this event, you need to tell the DataGrid which page youd like it to display (generally the same page that was passed into the event) and then rebind the data. Press F5 to run the application again. Figure 3 shows the sample application with automatic paging turned on. Note that setting up paging made two changes here. First, only 10 records are displayed on the DataGrid, rather than the entire contents of the Order Details table. Second, there are greater-than and less-than characters at the lower-left corner of the page. These characters are hyperlinked to allow the user to move forward and back through the pages. You can customize the appearance of the paging controls by setting several properties. The major customizations are in the Paging section of the DataGrid Properties dialog box:

The Position setting lets you place the navigation controls at the top of the DataGrid, at the bottom of the DataGrid, or at both the top and the bottom. The Mode setting lets you choose between next and previous buttons or page numbers. If you choose next and previous buttons, you can specify the HTML you want to use for each of these buttons. If you choose page numbers, you can specify how many page numbers you want to show.

416

ADO.NET Solutions

FIGURE 3:
The Web Form with automatic paging

TIP

You can also use the Format section in the DataGrid Properties dialog box to alter the font, color, and other visual aspects of the paging controls.

Figure 4 shows the DataGrid with 15 page numbers at the top and the bottom of the grid. Weve also added a background color to the paging controls and used bold font for their text.

Using Custom Paging


The logic for automatic paging is built right into the DataGrid control. For maximum generality, this logic assumes that the entire set of potential data is available, and the DataGrid just finds the correct rows to display. For example, if youve set a page size of 10 and the user goes to the 15th page, the DataGrid retrieves and displays records number 141 through 150 from the data. This method has a drawback: it can take a long time when there are many records in the original data. In the example so far, ASP.NET still downloads every record in the Order Details table each time the page is posted back, even though its displaying only 10 records.

Solution 32 The 10-Minute Guide to Paging Data

417

FIGURE 4:
A different look for automatic paging

Although this strategy does lower the size of the page and the amount of time that it takes to download, it doesnt do anything to ease the load on the database server. The DataGrid provides a second paging mode, custom paging, that solves this problem. With custom paging, the logic in the DataGrid isnt used at all. Instead, youre responsible for incorporating the logic into your own code. When you set custom paging and tell the DataGrid to display 10 records per page, it simply displays the first 10 records that you hand it. Its up to you to find the right 10 records.

Retrieving the Data


This is an ideal opportunity to use a stored procedure. By performing the data selection in a stored procedure, you can limit the communication between the database and ASP.NET to just the desired records, optimizing that part of the application architecture. To create the stored procedure within Visual Studio .NET, follow these steps: 1. Open Server Explorer and expand the Northwind sample database until you can see the stored procedures node. Right-click on this node and select New Stored Procedure.

418

ADO.NET Solutions

2. Replace the text of the new stored procedure in the editor window with this code:
CREATE PROC GetDetails @Page integer AS CREATE TABLE #TempDetails (ID int identity PRIMARY KEY, OrderID int, ProductID int, UnitPrice money, Quantity smallint, Discount real) INSERT INTO #TempDetails SELECT * FROM [Order Details] SELECT OrderID, ProductID, UnitPrice, Quantity, Discount FROM #TempDetails WHERE ID BETWEEN (@Page * 10) - 9 AND (@Page * 10)

3. Right-click the stored procedure and select Run Stored Procedure to create the stored procedure. This stored procedure starts by creating a temporary table whose structure is nearly the same as that of the Order Details table. The difference is that the temporary table also has an identity column. When new rows are inserted in this table, SQL Server assigns them sequential identity values, starting at 1. The code inserts the entire Order Details table into the temporary table, which has the effect of numbering the rows. It then selects just those rows from the desired page (assuming a page size of 10) and returns only those records to the calling code. Drag the new stored procedure from Server Explorer and drop it on the Web Form. This will create a SqlCommand object in the tray area of the form. Rename this object cmdGetDetails. Now select the DataGrid control on the Web Form and return to the DataGrid Properties dialog box. In the Paging section, select the Allow Custom Paging checkbox. Then, click OK to close the dialog box. At this point, youve unhooked the built-in automatic paging and are ready to install the custom paging. To use custom paging, you must rewrite the event handlers for the page, as shown in Listing 1. You should also add an Imports statement at the top of the module to reference the System.Data.SqlClient namespace.

Listing 1

Code for custom paging

Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load If Not IsPostBack Then cmdGetDetails.Parameters(@Page).Value = 1 Dim dr As SqlDataReader SqlConnection1.Open() dr = cmdGetDetails.ExecuteReader() dgOrderDetails.DataSource = dr DataBind() SqlConnection1.Close()

Solution 32 The 10-Minute Guide to Paging Data

419

End If End Sub Private Sub dgOrderDetails_PageIndexChanged( _ ByVal source As Object, ByVal e As _ System.Web.UI.WebControls.DataGridPageChangedEventArgs) _ Handles dgOrderDetails.PageIndexChanged cmdGetDetails.Parameters(@Page).Value = e.NewPageIndex Dim dr As SqlDataReader SqlConnection1.Open() dr = cmdGetDetails.ExecuteReader() dgOrderDetails.DataSource = dr DataBind() SqlConnection1.Close() End Sub

Youll also need to clear the DataSource and DataMember properties of the DataGrid control. With these changes, you can run the project again and see the first 10 rows of data on the user interface.

Handling the User Interface


Theres a problem with the solution as it stands, though. If you run it at this point, the correct data will be displayed on the first page of resultsbut the paging controls will be disabled. When you select the custom-paging approach, your code becomes responsible for the entire user interface, including those controls. In fact, the code in the PageIndexChanged event handler will never get called at all in the custom-paging scenario, because the DataGrid control wont even raise the PageIndexChanged event. To fix this, you need to first get rid of the controls supplied by the DataGrid. Open the DataGrid Properties dialog box once again, navigate to the Paging section, and deselect the Show Navigation Buttons checkbox. Click OK to dismiss the dialog box. Now place two Button controls on the Web Form: btnPrevious and btnNext. The final step is to rewrite the code to use the new buttons for paging, as shown in Listing 2.

Listing 2

Adding controls for custom paging

Private Sub Page_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load If Not IsPostBack Then Session(DisplayedPage) = 1 BindData(Session(DisplayedPage)) End If End Sub

420

ADO.NET Solutions

Private Sub btnPrevious_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnPrevious.Click Session(DisplayedPage) -= 1 BindData(Session(DisplayedPage)) End Sub Private Sub btnNext_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles btnNext.Click Session(DisplayedPage) += 1 BindData(Session(DisplayedPage)) End Sub Private Sub BindData(ByVal CurrentPage As Integer) cmdGetDetails.Parameters(@Page).Value = CurrentPage Dim dr As SqlDataReader SqlConnection1.Open() dr = cmdGetDetails.ExecuteReader() dgOrderDetails.DataSource = dr DataBind() SqlConnection1.Close() End Sub

There are quite a few differences between this code and the automatic paging code. First, the data-binding portion of the code, which actually calls the stored procedure, has been moved to its own procedure. Thats because this code now gets used in three separate places (when the form loads, when the user clicks the Previous button, and when the user clicks the Next button), and theres no point in duplicating the code three times. Second, the code keeps track (in a session variable) of the current page. This variable is initialized when the form is first loaded, and is updated whenever the user clicks one of the buttons. Each time the user clicks a button, the updated value of this session variable is used to retrieve the correct records. Figure 5 shows the Web Form after weve paged forward a few times. WARNING Nothing in the code prevents the user from moving to a page before the first page of
records or a page after the last page. You could add code to solve this problem by checking that the session variable has never decreased to less than 1 or increased to more than the total number of pages.

Where Should You Do the Work?


This solution demonstrates one of the common tradeoffs that you need to consider when moving data between tiers. The more code youre willing to write, the less data will need to be moved around:

Without paging, all of the data is moved to the users browser, and the user can scroll through it to find the records he or she wants to see.

Solution 32 The 10-Minute Guide to Paging Data

421

With automatic paging, all of the data is moved to the Web server, and the server decides which records to send to the user. With custom paging, the data is filtered on the database server, and only the required records are passed to the Web server and then to the user interface.

As you can see, the more work youre willing to do in code, the less data you need to move around between tiers. Thats typical of the tradeoffs involved in moving business logic from one tier to another. The closer to the database you set your logic, the more efficient you can make your application. Automatic and custom paging provide easy ways to increase the efficiency of your ASP.NET applications. FIGURE 5:
Using custom paging

Index
Note to the Reader: Page numbers in bold indicate the principle discussion of a topic or the definition of a term. Page numbers in italic indicate illustrations.

A
Activator class, 168169 Add New Project dialog box, 404, 404 ADO versus ADO.NET, 337 Data Control. See data binding database connections, 330331 RecordSets, 336337, 342, 343, 346 ADO.NET database connections, 326336 connection pooling auto garbage collection and, 331332 customizing, 333 defined, 332333 Max Pool Size property, 334 monitoring, 334336, 335 open connections and, 331 overview of, 330331 warning, 335 overview of, 326, 336 using Server Explorer GUI accessing, 326327 adding connection monitors, 336 creating connections, 327, 327, 330 drilling into connections, 328, 329 reusing connection strings, 330 testing connections, 328, 330 viewing code behind, 329330 versus in VB6 and classic ADO, 330331 ADO.NET DataSets, 336368 versus ADO RecordSets, 336337, 342, 343, 346 ADO.NET, overview, 339 calculated DataColumns adding columns, 356, 356 aggregating data, 357358, 357 bug, 359 defined, 353 expression syntax, 358359 loading data, 354356, 356 normalization and, 353

performance, 359 warning, 358 when to use, 354, 359 distributed applications and, 412 Merge method creating test tables, 361363 defined, 360 using with GetChanges, 366368 merging schemas with data, 364366, 365 merging two tables, 362364, 365 minimizing data transfers, 366368 MissingSchemaAction values, 366 overloads for, 360361 PrimaryKey property and, 362363 saving merged data, 366 uses for, 368 overview of, 336337, 346, 403 ReadXml method, 387 versus relational databases, 354, 360 synchronizing with DiffGrams code behind, 409412 creating client applications, 406407, 406 creating Web services, 404406, 404 defined, 403 disconnected editing, 408, 409 GetChanges method, 411 minimizing data transfers, 403, 411 overview of, 380 saving changes, 408, 410, 412 SQLXML library support, 370 testing, 408, 408 Web references and, 406407, 406 WriteXml method, 411 typed DataSets benefits of, 352353 creating via drag and drop, 347348, 349 creating with Schema Designer, 347, 348351, 350 defined, 347 versus untyped DataSets, 353 untyped DataSets adding data, 344

424

API INI functionsASP.NET applications

in ADO.NET architecture, 338, 338 CommandBuilder object and, 345346 creating table relations, 341342, 343 DataAdapter object and, 345, 346 defined, 337 deleting data, 344345 editing data, 343345 late-bound syntax, 347 loading data tables, 339341, 341 navigating, 342343, 343 saving changes, 345346 versus typed DataSets, 353 warning, 346 WriteXml method, 387, 411 XmlDataDocuments and creating DataSets from, 134135 creating from DataSets, 132134, 386 creating schema-only DataSets and, 386387 defined, 132, 380, 384 displaying as DataSets on DataGrids, 385, 386 overview of, 380 versus ReadXml method, 387 relational data and, 132, 387 using synchronized objects, 387 synchronizing, 384387, 386 when to use, 132 versus WriteXml method, 387 XmlTextReader/Writer classes and, 385 in XPath data queries, 387 in XSLT data formatting, 387 XmlDocuments and Document Object Model, 380382, 382 loading XML into, 382384, 383384 overview of, 380 XmlNode class and, 382383, 383384 XmlTextReader/Writer classes and, 384 API INI functions, 2837, 38 API ShellExecute function, 187189, 196 application configuration files, 151152, 153, See also storing Aprea, Victor Garcia, 318 ArrayLists adding events to, 174177 versus ValueTypeCollections, 180, 186 wrapping in CollectionBase class, 172174 wrapping in custom classes, 170172 arrays. See conversions; sort articles online, 42, 58, 113 ASCII values in conversions, 157, 160, 161 ASP.NET applications, 254324, 412421 custom configuration handlers configuration sections and, 255256, 255 for custom tags, 258261 in machine.config file, 256, 257, 264 NewLine values shortcut, 264

Response.Output.Writeline method, 264 System handlers for custom tags, 256258 testing CustomItemHandlers, 262264, 263 warnings, 259, 262 in web.config file, 254259, 255, 262, 263, 264 data binding, 413 defining modal message box controls adding to Toolbox, 321 client-side message boxes, 321322, 321, 323 in client-side script, 307309 at design time, 309313, 320322, 321 goals to be met by, 309 how they work, 314316 initializing, 316320 in JavaScript, 308309, 314 message box features, 306307, 307 overview of, 306, 309310, 324 properties, 310314 Render method workaround, 318 rendering design-time HTML, 312, 315, 320 at runtime, 310, 314, 322, 323 server-side message boxes, 322, 323 using showModalDialog method, 309, 313, 314, 316, 317 in VBScript, 307308, 309, 313, 314, 317 versus Windows Forms message boxes, 306307, 307 internationalizing caching article content, 285286 creating images dynamically, 287290, 288, 291 improving culture matching, 292293 language/culture issues, 266267 letting users choose languages, 274276, 276 localizing database content, 291 localizing dates, 290 localizing numbers/currencies, 290291 localizing static images, 286287, 287288, 291292 localizing text resources, 277280, 277278, 280, 292 managing content, 293 managing resource changes, 292 mapping content to classes, 267274, 270, 273274 overview of, 265 ResourceManager objects and, 292 specifying cultures, 281284, 284 paging data in DataGrid controls adding paging controls, 419420 using automatic paging, 414416, 415417, 420, 421 using custom paging, 416420, 421, 421 customizing paging controls, 415416 data-binding code, 420 versus not paging, 413, 414, 420 overview of, 412413, 420421 stored procedures in, 417418 warning, 420 Web Forms custom FocusManager control

base Object classconfiguration handlers

425

adding properties, 296297 adding to Toolbox, 299300, 300 creating, 294296 overriding Render method, 297299 overview of, 294 testing, 300304, 305

B
base Object class, To String method, 159, 167 base type array sorting, 100101, 101 base types, conversions between SqlDataTypes and, 162163 binary file I/O. See file I/O BindingContext objects, See also data binding in data-binding example, 398400, 398 defined, 397 PropertyManager class and, 402 browser applications. See ASP.NET buffer size, setting for StreamReader, 8586 byte arrays avoiding conversions of, 7071, 72 converting strings to, 160, 161 converting to strings, 161162 byte-to-char/char-to-byte conversions, 157, 157159

C
calculated DataColumns, See also ADO.NET DataSets adding columns, 356, 356 aggregating data, 357358, 357 bug in, 359 defined, 353 expression syntax, 358359 loading data, 354356, 356 normalization rules and, 353 performance issues, 359 warning, 358 when to use, 354, 359 characters, See also regular expressions character sets, 157 converting strings to Char arrays, 159 converting to/from bytes, 157159, 157 classes, adding sort capabilities to built-in QuickSort method, 100, 109 considering efficiency, 109 using IComparable interface, 100104 using IComparer interface, 104106 sorting base type arrays, 100101, 101 custom class arrays, 102105 custom collections, 106107

multidimensional arrays, 107109 partial arrays, 105 typed arrays, 105106 warning, 101 cloning objects, 79, 80 CollectionBase class, 172174 collections, See also objects custom, sorting, 106107 deserializing, 77 persisting, 7677 serializing, 7677 collections, building custom type-safe, 170185 accessible by index or key, 172 case-sensitive string collections, 172 using containment, 170172 inheriting from CollectionBase, 172177 CollectionsUtil, 172 DictionaryBase, 172, 177179 NameObjectCollectionBase, 172 ReadOnlyCollectionBase, 172 Object type and, 170, 179 overview of, 170 that sort by keys, 172 value type collections, 179186 color objects, creating from strings, 167168 columns. See calculated DataColumns ComboBoxes, drawing custom, See also data-bound displaying font names/styles, 2527, 27 listing files/folders/icons, 1723, 17 overview of, 16 with variable width/height items, 2325, 23 command lines, parsing/validating, 212234, See also console applications command lines defined, 212 flag parameters, 212213 optional parameters, 212, 213 splitting, 214 extending parsers, 220234 generic guidelines for, 214215 how parsers work, 215217, 217 overview of, 212 setting up parsers, 217219, 219220 warning, 216 CommandBuilder object, 345346 comments, in upgrading INI files to XML, 47 compatibility syntax. See file I/O configuration files, 151152, 153, See also storing configuration handlers, creating in ASP.NET, 254264 configuration sections and, 255256, 255 custom handlers for custom tags, 258261 in machine.config file, 256, 257, 264 NewLine values shortcut, 264

426

console applicationsDisplayRules

Response.Output.Writeline method and, 264 System handlers for custom tags, 256258 testing CustomItemHandlers, 262264, 263 warnings, 259, 262 in web.config file, 254259, 255, 262, 263, 264 console applications, See also command lines InstallUtil.exe, 244245, 245 using touch utilities from, 205211, 205 containment, 170172 conversions, 154169 using Activator class, 168169 ASCII values in, 157, 160, 161 byte arrays to strings, 161162 byte-to-char/char-to-byte, 157159, 157 using Convert class, 154156, 156, 169 using IConvertible interface, 165 Integer arrays to strings, 162 numeric values in, 157158 between reference types, 163167 rules for, 169 between SqlDataTypes and base types, 162163 strings to byte arrays, 160, 161 strings to Char arrays, 159 strings to objects, 167169 using TypeConverter class, 165166, 167168, 169 Unicode values in, 157, 158, 160, 161 value types to strings, 159 Cornes, Ollie, 265 CurrencyManager objects, See also data binding class members, 401402 in data-binding example, 398400, 398 defined, 397 events, 401 custom class array sorting, 102105 custom file formats, storing data in, 141143, 153

D
data binding in ASP.NET, 420 BindingContext objects defined, 397 example, 398400, 398 PropertyManager class and, 402 CurrencyManager objects class members, 401402 defined, 397 events, 401 example, 398400, 398 .NET architecture of, 397398, 398 the .NET way, 396397, 402403 Data Control, ADO. See data binding data transfers, minimizing, 366368, 403, 411

data-bound data-input forms adding ComboBox controls, 391392, 392 adding ListBox controls, 392393, 393 binding to data coded into, 394396 binding to DataSets, 391392, 392, 393 using bound ListBoxes/ComboBoxes, 388, 396 building, 389390, 390 overview of, 388 revising code behind, 393394 using Server Explorer, 389390, 390 stored procedures, 389, 390 testing, 391, 391, 394 DataAdapter object, 345, 346, 410 databases, See also ADO.NET normalization, 353 relational, versus DataSets, 354, 360 relational, XmlDataDocuments and, 132, 387 storing data in, 153 DataGrids, paging ASP.NET data in, 412421 adding paging controls, 419420 using automatic paging, 414416, 415417, 420, 421 using custom paging, 416420, 421, 421 customizing paging controls, 415416 data-binding code, 420 versus not paging, 413, 414, 420 overview of, 412413, 420421 stored procedures in, 417418 warning, 420 DataLink Properties dialog box, 327, 327 DataSets. See ADO.NET DataSets dates, finding in strings. See regular expressions dates/times, altering. See touch utilities default namespace prefixes, creating, 125 deleting DataSet data, 344345 deserializing collections, 77 deserializing objects, 72, 76, 77 DictionaryBase collections, 172, 177179 DiffGrams, synchronizing with DataSets, See also ADO.NET DataSets code behind, 409412 creating client applications, 406407, 406 creating Web services, 404406, 404 defined, 403 disconnected editing, 408, 409 GetChanges method, 411 minimizing data transfers, 403, 411 overview of, 380 saving changes, 408, 410, 412 SQLXML library support, 370 testing, 408, 408 Web references and, 406407, 406 WriteXml method, 411 DisplayRules, See also TreeView creating, 5455 customizing TreeViewXml, 6062, 6061

distributed applicationsIComparer interface

427

properties, 54 types of, 53 XSLT option, 5455, 56, 58, 6062, 62 distributed applications. See DiffGrams DOM (Document Object Model), 380382, 382 downloading Internet files, 7778, 78

E
editing DataSets, 343345 DiffGrams and, 408, 409 resource files, 277, 277 EmbeddedResourceReader, 112113 embedding resource files in .NET code, 58, 110113 enumerations, 23, 68 evidence, 146 expressions. See regular expressions extending command line parsers, 220234 extending TreeViewXml control, 64 extensibility in upgrading INI to XML, 4647 external programs, launching/monitoring, 186199 overview of, 186187 using Process class example, 189190 launching invisible processes, 194 launching processes, 192193 monitoring launched processes, 192 overview of, 190191 redirecting process IO, 196199 retrieving invisible process output, 194195 setting parameters at design time, 191, 191 waiting for launched process exit, 192193, 193 in VB6, 187 using VB.NET Shell function, 187, 199 using Win32 ShellExecute function, 187189, 196

compatible random access support, 6768 fileMode enumeration, 68 using streams and formatters avoiding byte array conversions, 7071, 72 binary file uses, 74 BinaryFormatter class, 72, 7577 BinaryReader/Writer classes, 67, 7071, 72 cloning objects, 79, 80 converting text files to XML, 73, 73, 75 deserializing collections, 77 deserializing objects, 72, 76, 77 downloading Internet files, 7778, 78 using FileStream class, 6769, 70 using MemoryStream class, 79, 80 for more flexibility, 7172, 72 opening files, 6869 optimizing StreamReader speed, 8586 overview of, 66 persisting collections, 7677 persisting objects, 73, 73, 75 reading/writing text files, 67, 70, 71, 74 serializing collections, 7677 serializing objects, 7173, 7273, 75 setting StreamReader buffer size, 8586 SoapFormatter class, 73, 73, 75 StreamReader/Writer classes, 67, 70, 71, 74 types of streams, 6667 XmlFormatter object, 73, 73, 75 FileSystemWatcher component, 235, 236240 finding data in strings. See regular expressions flag parameters for command lines, 212213 font names, displaying in font styles, 2527, 27 formatters. See file I/O

G
Generate DataSet dialog box, 328, 330, 391, 392 GetChanges method, 366368, 370, 411 Glenwright, Anthony, 58, 113

F
file dates/times, altering. See touch utilities file formats, custom, storing data in, 141143, 153 file I/O in VB.NET, 6686 avoiding compatibility syntax beware of convenience, 8586 FileStream vs. StreamReader/Writer tests, 85 overview of, 8081 VB6 versus VB.NET compatibility test, 8182 VB6 versus VB.NET FileStream test, 8283 VB.NET compatibility vs. FileStream test, 84, 84 VB.NET compatibility vs. StreamReader/ Writer test, 84, 84 why compatibility code is slow, 8485

H
handlers. See configuration handlers HTML format, 131132, 312, 315, 320

I
I/O file. See file I/O IComparable interface, 100104, See also sort IComparer interface, 104106

428

iconsmonitoring file changes with Windows services

icons, adding to file/folder lists, 17, 1723 IConvertible interface, 165 INI files applications, 38 defined, 28 items, 3738 section/key/values, 3738 storing data in, 137139, 138, 152 as ubiquitous, 3738 upgrading to XML, 2847 API INI functions, 2837 comments and, 47 extensibility, 4647 getting original files back, 47 moving beyond COM Interop, 37 not using .NET configuration files, 3839 overview of, 28 retrieving values, 4243 updating/adding values, 4445, 45 XML case sensitivity and, 4344 XML INI file structure, 3942 InstallUtil.exe console application, 244245, 245 Integer arrays, converting to strings, 162 Integer value type collections, 179186 Internet files, downloading, 7778, 78 Isolated Storage, 146148, 153 ItemData. See ListBoxes

M
machine.config file, 256, 257, 264 Max Pool Size property, 334 MemoryStream class, 79, 80 Merge method of DataSets, See also ADO.NET DataSets creating test tables, 361363 defined, 360 using with GetChanges, 366368 merging schemas with data, 364366, 365 merging two tables, 362364, 365 minimizing data transfers, 366368 MissingSchemaAction values, 366 overloads for, 360361 PrimaryKey property and, 362363 saving merged data, 366 uses for, 368 message boxes, defining in ASP.NET, 306324 adding to Toolbox, 321 client-side message boxes, 321322, 321, 323 in client-side script, 307309 at design time, 309313, 320322, 321 features, 306307, 307 goals to be met, 309 how they work, 314316 initializing, 316320 in JavaScript, 308309, 314 overview of, 306, 309310, 324 properties, 310314 Render method workaround, 318 rendering design-time HTML, 312, 315, 320 at runtime, 310, 314, 322, 323 server-side message boxes, 322, 323 using showModalDialog method, 309, 314, 316, 317 in VBScript, 307308, 309, 313, 314, 317 versus Windows Forms message boxes, 306307, 307 minimizing data transfers, 366368, 403, 411 MissingSchemaAction values, 366 modal dialogs. See message boxes monitoring database connection pooling, 334336, 335 monitoring file changes with Windows services, 234252 adding files to monitored folders, 250252 from console applications, 244245, 245 controlling from other applications, 246250 controlling what to monitor, 236239, 245, 248250 and creating reports, 246248, 250251, 251 FileSystemWatcher component, 235, 236240 installation warning, 245 installing in design mode, 243244, 243244 installing using InstallUtil.exe, 244245, 245 interacting with, 248250 launching, 245246, 246

J
JavaScript in ASP.NET message boxes, 308309, 314

L
late-bound syntax, 347 launching external programs. See external programs ListBoxes, 227, See also data-bound drawing custom displaying font names/styles, 2527, 27 listing files/folders/icons, 1723, 17 overview of, 16 with variable width/height items, 2325, 23 ItemData gone solution assigning display methods to instances, 1415, 15 class creators have control, 611, 8, 1011 delegating control to consumers, 1114 getting data back, 56 mimicking classic VB ListBoxes, 311, 8, 1011 overview of, 23 populating ListBoxes, 35

monitoring/launching external programsregular expressions, building custom

429

overview of, 234235, 252 processing file changes, 235236, 240243 sending custom commands to, 247250 ServiceController component, 235, 248250 starting monitoring, 239 monitoring/launching external programs, 186199 overview of, 186187 using Process class example, 189190 launching invisible processes, 194 launching processes, 192193 monitoring launched processes, 192 overview of, 190191 redirecting process IO, 196199 retrieving invisible process output, 194195 setting parameters at design time, 191, 191 waiting for launched process exit, 192193, 193 in VB6, 187 using VB.NET Shell function, 187, 199 using Win32 ShellExecute function, 187189, 196 multidimensional array sorting, 107109

P
paging ASP.NET data in DataGrids, 412421, See also ASP.NET adding paging controls, 419420 using automatic paging, 414416, 415417, 420, 421 using custom paging, 416420, 421, 421 customizing paging controls, 415416 data-binding code, 420 versus not paging, 413, 414, 420 overview of, 412413, 420421 stored procedures in, 417418 warning, 420 parsing/validating command lines, 212234, See also validating command lines defined, 212 flag parameters, 212213 optional parameters, 212, 213 splitting, 214 extending parsers, 220234 generic guidelines for, 214215 how parsers work, 215217, 217 overview of, 212 parsing, defined, 143 setting up parsers, 217219, 219220 warning, 216 Performance Monitor, 334336, 335 persisting object collections, 7677 persisting objects, 73, 73, 75 Petroutsos, Evangelos, 66, 81 PrimaryKey property, 362363 Process class. See external programs processing file changes, 235236, 240243, See also monitoring PropertyManager class, 402

N
NameObjectCollectionBase, 172 namespace prefixes, creating, 125 navigating DataSets, 342343, 343 .NET classes. See classes .NET code, embedding resource files in, 58, 111113 .NET InstallUtil.exe, 244245, 245 .NET Unicode support, 282 newline, 87 NewLine values shortcut, 264 normalization, database, 353 numbers, validating, 9699 numeric values in conversions, 157158

O
objects, See also collections cloning, 79, 80 converting strings to, 167169 creating Type instances of, 168169 deserializing, 72, 76, 77 serializing, 7173, 7273, 75 opening files, 6869 Option Strict On, importance of, 165 optional command line parameters, 212, 213 overloads for Merge method, 360361

R
reading/writing files. See file I/O; parsing ReadOnlyCollectionBase, 172 ReadXml method, 387 Recordsets, 336337, 342, 343, 346, See also ADO.NET DataSets reference type conversions, 163167 RegisterClientScriptBlock method, 318 regular expressions, building custom, 8999 for finding dates in strings alternation constructs and, 92 capturing groups, 9596

430

relational databasesstoring user and application data

character classes and, 90, 93 character escapes and, 93 creating character classes, 9396 creating named groups, 95 implementing rules, 9093 overview of, 8990 quantifiers and, 91 word boundaries and, 93 overview of, 8687 using Regex class avoiding writing full class names, 87 compiling expression patterns, 88 expression symbols, 87, 89 matching individual characters, 87 newlines and, 87 overview of, 87 for validating numbers, 9699 white space warning, 87 relational databases. See databases relations between DataSet tables, 341342, 343 Render method overriding in message box output, 318320 overriding in Web Forms output, 297299 workaround, 318 rendering design-time HTML, 312, 315, 320 resource files (.resx), 277280, 277278, 280 resource files (.xml), 58, 111113 ResourceManager objects, 292 Response.Output.Writeline method, 264

S
saving DataSet changes to data sources, 345346 DataSet changes to Web services, 408, 410, 412 merged data back to databases, 366 Schema Designer, 347, 348351, 350 schemas creating schema-only DataSets, 386387 generating with SQL, 374375 merging with DataSet data, 364366, 365 MissingSchemaAction values, 366 validating XML against, 121123 XML Schema Web site, 351 XSD schema in creating DataSets, 350351 XSD schema, creating in VS, 120121 serializing collections, 7677 serializing objects, 7173, 7273, 75 Server Explorer GUI tool, See also ADO.NET database connections accessing, 326327 adding connection monitors, 336 creating connections, 327, 327, 330

creating stored procedures, 389390, 417418 drilling into connections, 328, 329 reusing connection strings, 330 testing connections, 328, 330 viewing code behind, 329330 Shell function in VB.NET., 187, 199 shell processes online resource, 187 ShellExecute function in Win32 API, 187189, 196 showModalDialog method, 309, 314, 316, 317 SoapFormatter class, 73, 73, 75 sort capabilities, adding to classes built-in QuickSort method, 100, 109 considering efficiency, 109 using IComparable interface, 100104 using IComparer interface, 104106 sorting base type arrays, 100101, 101 custom class arrays, 102105 custom collections, 106107 multidimensional arrays, 107109 partial arrays, 105 typed arrays, 105106 warning, 101 splitting command lines, 214 SQL Query Analyzer, 371 SqlDataType/base type conversions, 162163 SQLXML library, 369379, See also XML defined, 369 DiffGrams support, 370 using ExecuteXmlReader, 375377, 377, 379 features, 369370 generating XML with SQL statements FOR XML AUTO clauses, 372373 FOR XML EXPLICIT clauses, 373374 FOR XML problem, 377 FOR XML RAW clauses, 370372 FOR XML warning, 370 and getting into .NET applications, 375377, 377 schema/data information, 374375 using SqlXmlCommand object, 377379, 379 turning FOR XML results into valid XML, 377379, 379 XMLDATA option, 374375 stored procedures, creating, 389, 390, 417418 storing user and application data, 136153 in application configuration files, 151152, 153 using custom file formats, 141143, 153 in database tables, 153 guidelines for, 152153 in INI files, 137139, 138, 152 in Isolated Storage, 146148, 153 overview of, 135136 using Web services, 148151, 153 in Windows Registry, 139141, 153 in XML files, 143145, 153

streamsWeb references in DiffGrams

431

streams. See file I/O strings, converting to byte arrays, 160, 161 byte arrays to, 161162 to Char arrays, 159 to objects, 167169 value types to, 159 synchronizing DataSets with DiffGrams code behind, 409412 creating client applications, 406407, 406 creating Web services, 404406, 404 defined, 403 disconnected editing, 408, 409 GetChanges method, 411 minimizing data transfers, 403, 411 overview of, 380 saving changes, 408, 410, 412 testing, 408, 408 Web references and, 406407, 406 WriteXml method, 411 synchronizing DataSets with XML documents, 384387, 386 System handlers for custom tags, 256258 System.Xml namespace, 382

display capabilities needed, 5152 using DisplayRules, 5355, 5658, 6062, 6162 embedded XML resource files and, 58 extending TreeViewXml, 64 finding XML node paths, 5558 how TreeViewXml works, 6264 iterating through XML nodes, 4851, 51 overview of, 48 using XSLT option, 5455, 56, 58, 6062, 62 TypeConverter class, 165166, 167168, 169 typed array sorting, 105106

U
Unicode in conversions, 157, 158, 160, 161 defined, 157 .NET support for, 282

V
validating data, See also command lines using regular expressions, 9699 with XmlValidatingReader, 120123 value types, converting to strings, 159 ValueTypeCollections, 179186 VB (Visual Basic) launching external programs in, 187 ListBoxes, mimicking, 311, 8, 1011 versus VB.NET speed tests, 8183 VB.NET compatibility syntax. See file I/O compatible random file access support, 6768 overview of, 81 Shell function, 187, 199 versus VB6 speed tests, 8183 VBScript MsgBox method, 307308, 309, 313, 314, 317 Visual Studio, creating XSD schema in, 120121

T
tables. See ADO.NET DataSets; databases tag handlers. See configuration handlers testing CustomItemHandlers, 262264, 263 data-bound data-input forms, 391, 391, 394 database connections, 328, 330 DiffGrams, 408, 408 Web Forms FocusManager control, 300304, 305 text files, See also file I/O converting to XML, 73, 73, 75 localizing in ASP.NET, 277280, 277278, 280, 292 reading/writing, 67, 70, 71, 74 third normal form rule, 353 times/dates, altering. See touch utilities To String method, 159, 167 Toolbox, adding controls to, 299300, 300, 321 touch utilities, building for altering file dates/times, 200201 defined, 200 using from console applications, 205211, 205 using from Windows forms, 211 overview of, 200 using TouchUtility class, 201205 TreeView controls adding sorted lists, 107109 building XML-enabled controls, 4864 customizing XML displays, 6062, 6061

W
Web Forms custom FocusManager control, See also ASP.NET adding properties, 296297 adding to Toolbox, 299300, 300 creating, 294296 overriding Render method, 297299 overview of, 294 teting, 300304, 305 Web references in DiffGrams, 406, 406407

432

Web servicesXML

Web services, See also DiffGrams saving DataSet changes to, 408, 410, 412 storing data using, 148151, 153 in synchronizing DataSets/DiffGrams, 404406, 404 Web site addresses Babel Fish, 267 BBC News Online, 267 DevX.com, 78, 78, 81 resource articles, 42, 58, 113, 187 Victor Garcia Apreas Web log, 318 Win32 API INI functions, 38 XML Schema, 351 Web sites, internationalizing in ASP.NET, 265293, See also ASP.NET caching article content, 285286 creating images dynamically, 287290, 288, 291 improving culture matching, 292293 language/culture issues, 266267 letting users choose languages, 274276, 276 localizing database content, 291 localizing dates, 290 localizing numbers/currencies, 290291 localizing static images, 286287, 287288, 291292 localizing text resources, 277280, 277278, 280, 292 managing content, 293 managing resource changes, 292 mapping content to classes, 267274, 270, 273274 overview of, 265 ResourceManager objects and, 292 specifying cultures, 281284, 284 web.config file, 254259, 255, 262, 263, 264 Weber, Phil, 81 white space in expressions, 87 Win32 API INI functions, 2837, 38 Win32 API ShellExecute function, 187189, 196 Windows forms, 264 building XML-enabled TreeView controls customizing XML displays, 6062, 6061 default controls, 5859, 59 display capabilities needed, 5152 using DisplayRules, 5355, 5658, 6062, 6162 embedded XML resource files and, 58 extending TreeViewXml, 64 finding XML node paths, 5558 how TreeViewXml works, 6264 iterating through XML nodes, 4851, 51 overview of, 48 using XSLT option, 5455, 56, 58, 6062, 62 drawing custom ListBoxes/ComboBoxes displaying font names/styles, 2527, 27 listing files/folders/icons, 1723, 17 overview of, 16 with variable width/height items, 2325, 23 ListBox ItemData gone

assigning display methods to instances, 1415, 15 class creators have control, 611, 8, 1011 delegating control to consumers, 1114 getting data back, 56 mimicking classic VB ListBoxes, 311, 8, 1011 overview of, 23 populating ListBoxes, 35 message boxes, 306307, 307 using touch utilities from, 211 upgrading INI files to XML, See also INI API INI functions, 2837 comments, 47 extensibility, 4647 INI sections/keys/values, 3738 moving beyond COM Interop, 37 .NET configuration files and, 3839 overview of, 28 retrieving values, 4243 ubiquitous INI files, 3738 updating/adding values, 4445, 45 XML case sensitivity, 4344 XML INI file structure, 3942 Windows Registry, storing data in, 139141, 153 Windows services, monitoring file changes with, 234252 adding files to monitored folders, 250252 from console applications, 244245, 245 controlling from other applications, 246250 controlling what to monitor, 236239, 245, 248250 and creating reports, 246248, 250251, 251 FileSystemWatcher component, 235, 236240 installation warning, 245 installing in design mode, 243244, 243244 installing using InstallUtil.exe, 244245, 245 interacting with, 248250 launching, 245246, 246 overview of, 234235, 252 processing changes, 235236, 240243 sending custom commands to, 247250 ServiceController component, 235, 248250 starting monitoring, 239 warning, 245 WriteXml method, 387, 411 writing/reading files. See file I/O

X
XML, 2864, See also SQLXML building XML-enabled TreeView controls customizing XML displays, 6062, 6061 default controls, 5859, 59 display capabilities needed, 5152 using DisplayRules, 5355, 5658, 6062, 6162 embedded XML resource files and, 58

XML classesXSD (XML Schema Direct)

433

extending TreeViewXml, 64 finding XML node paths, 5558 how TreeViewXml works, 6264 iterating through XML nodes, 4851, 51 overview of, 48 using XSLT option, 5455, 56, 58, 6062, 62 converting text files to, 73, 73, 75 embedding as resources in .NET code, 58, 111113 storing data in, 143145, 153 System.Xml namespace, 382 upgrading INI files to API INI functions, 2837 comments, 47 extensibility, 4647 getting original files back, 47 moving beyond COM Interop, 37 .NET configuration files and, 3839 overview of, 28 retrieving values, 4243 ubiquitous INI files, 3738 updating/adding values, 4445, 45 XML case sensitivity, 4344 XML INI file structure, 3942 XML classes, 109134 overview of, 109110, 135 XmlDataDocuments, See also ADO.NET DataSets creating DataSets from, 134135 creating from DataSets, 132134, 386 creating schema-only DataSets and, 386387 defined, 132, 380, 384 displaying as DataSets on DataGrids, 385, 386 overview of, 380 versus ReadXml method, 387 relational data and, 132, 387 using synchronized objects, 387 synchronizing, 384387, 386 when to use, 132 versus WriteXml method, 387 XmlTextReader/Writer classes and, 385 in XPath data queries, 387 in XSLT data formatting, 132, 387 XmlDocuments default namespace prefixes and, 125 Document Object Model, 380382, 382 loading XML into, 382384, 383384 specific XPath queries, 124126 when to use, 124 XmlNode and, 382383, 383384 versus XmlPathDocuments, 127 versus XmlTextReader, 124, 126

XmlTextReader/Writer classes and, 384 versus XmlTextWriter, 124 XmlPathDocuments precompiling XPath queries, 128 specific XPath queries, 127 when to use, 127 versus XmlDocuments, 127 XPathNavigators, 128129 XPathNodeIterators, 128129 XmlTextReader in adding data to existing files, 117 createEmbeddedFiles method and, 110113 EmbeddedResourceReader and, 112113 error-trapping warning, 110 extracting/displaying specific values, 113115 in modifying existing file data, 119 reading/displaying files, 110113 retrieving stored XML data using, 145 when to use, 110 XmlDataDocuments and, 385 versus XmlDocuments, 124, 126 XmlDocuments and, 384 XmlTextWriter adding data to existing files, 117118 constructing new documents, 115117 modifying existing file data, 119120 storing data in XML files using, 143145 when to use, 115 XmlDataDocuments and, 385 versus XmlDocuments, 124 XmlDocuments and, 384 XmlValidatingReader creating schema before calling, 120121, 122 handling validation errors, 123 validating XML against schema, 121123 when to use, 121 XslTransform stylesheets customizing XML displays, 5455, 56, 58, 6062, 62 displaying XML sorted lists in HTML, 131132 displaying XML in unsorted lists, 129131 when to use, 129 XmlDataDocuments and, 132, 387 XPath data queries in XmlDataDocuments, 387 in XmlDocuments, 124126 in XmlPathDocuments, 127, 128 XSD (XML Schema Direct) in creating typed DataSets, 350351 creating in VS, 120121

Das könnte Ihnen auch gefallen