Saturday, April 29, 2006

Setting up a C#/.NET Dev Environment with Mono

I have been interested in learning C# and applying it to a real application for a while. Since I work in a Java/J2EE shop, the closest I got were doing some toy example apps in my spare time. However, an opportunity came up recently to integrate a prebuilt C#/ASP.NET application into our site, and I had a chance to help out with it. This article describes a small C# application with ADO.NET to connect to a MySQL database and ASP.NET for the web pages that I set up and developed with MonoDevelop on Linux using Mono. I did this to refresh my understanding of how to use these components to stitch together a useful .NET application. Because my basic worldview is that of a Java programmer, the article draws parallels with Java frameworks. I am hoping that this would be of use to Java programmers who are looking to venture out into C# and .NET applications using Mono on Linux.

C#/.NET code should be quite easy to pick up for Java/J2EE developers since the concepts are quite similar, although the terminology is different. Overall, I think .NET provides a tighter stack with fewer moving parts, because they take an intrinsic "convention over configuration" approach with their components. I guess it's easier for Microsoft to do this because they provide all the components in the stack, unlike the plug-and-play approach taken by Java developers. But on to the terminology differences - here are some I found:

C#/.NET Term Java/J2EE Term Comment
CLR (Common Language Runtime) JVM (Java Virtual Machine) CLR targets multiple languages on a single platform (now multiple with Mono running on Linux, Windows and Mac OSX), and the JVM targeting one language on multiple platforms (although that may be changing too, with the scripting support JSR).
ASP.NET JSP This is only partially true, because ASP.NET has more functionality built in. The closest analogy on the Java side I can think of is Tapestry. Like Tapestry with its .java, .page and .html files, ASP.NET has the .aspx.cs and .aspx files. The .aspx.cs file is a C# subclass of Page which contains logic and references to we controls such as buttons, text boxes, etc. These controls and all methods in the .aspx.cs file can be accessed from the .aspx file.
ADO.NET JDBC Again, only partially true. The JDBC abstraction, ie the ability to write the same code regardless of the target database, which Java programs all know and love, is available in the form of an OLE database driver in .NET but not widely used because of speed issues. The standard practice in ADO.NET is to use drivers with native support for different databases, so your code will be different if you were using the ByteFX drivers for MySQL versus the Microsoft SqlClient driver for MS-SQL.
Assembly JAR/WAR The deployable unit for a .NET application is a .DLL or an .EXE, depending on whether they are libraries which will be included as references into another project, or standalone executable applications.
GAC (Global Assembly Cache) Your Maven2 repository, if you use one Some people use Maven, so this is roughly equivalent to the Maven2 repository. Redhat also includes a Java RPM with JARs for the Apache commons projects and some others.
mcs javac The C# compiler from Mono.
mono java -jar Allows you to run "executable" JAR files (the ones with the main method specified in the META-INF/MANIFEST.MF file).

There are other one-to-one mappings, such as monodoc for javadoc. For building .NET apps from the command line, you can use NAnt, which is Ant for .NET. Nunit, the unit testing framework for .NET is based on JUnit, but has much richer annotation support than the commonly used JUnit 3.8. JUnit 4.0, which was in beta last I looked, looks a lot like Nunit. I haven't used NAnt or Nunit myself, though.

As for the language itself, C# is also very similar to Java, although conventions are different, such as uppercasing the first character of all method calls. There is also a much greater reliance on operator overloading (using == instead of Java's .equals() for equality checks and map["key"] instead of Java's map.get(key), for example). There are also other little things such as the "using" keyword, which is kind of like the "static import" in Java 1.5, and "namespace" instead of "package". The get/set notation in C# is also quite cool, saves a lot of typing.

But enough generalities. Let me tell you what I did, which was the objective of this article anyway. I set out to develop a simple database backed web application with 2-3 ASPX pages. My database consisted of a single MySQL table of book reviews, with the following (non-normalized for simplicity) schema.

1
2
3
4
5
6
7
8
9
mysql> describe books;
+-------------+--------------+------+-----+---------+----------------+
| Field       | Type         | Null | Key | Default | Extra          |
+-------------+--------------+------+-----+---------+----------------+
| id          | bigint(20)   |      | PRI | NULL    | auto_increment |
| name        | varchar(255) |      |     |         |                |
| author      | varchar(255) |      |     |         |                |
| review      | text         |      |     |         |                |
+-------------+--------------+------+-----+---------+----------------+

My first step was to create a Console application in MonoDevelop to wrap the table with a DAO (Data Access Object). The DAO will provide methods to add rows to the table, return all rows, and find reviews based on reviewer. Note that creating an empty application in MonoDevelop is not a good idea, because then the Assembly.cs file will not be created. The Assembly.cs file is similar to the META-INF/MANIFEST.MF file, and is required. Here is the DAO code.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
// BookReviewDao.cs
using System;
using System.Data;
 
using ByteFX.Data.MySqlClient;
 
namespace BookReviews {
 
    /// <summary>
    /// This class interfaces with the MySQL database containing
    /// the book review data.
    /// </summary>
    public class BookReviewDao {
 
        string dbHost = "localhost";
        string dbName = "bookshelfdb";
        string dbUser = "root";
        string dbPass = "mysql";
         
        MySqlConnection conn;
 
        // Construct an instance of the BookReviewDao object.
        public BookReviewDao() {
            string connectionString = String.Format("Persist Security Info=False;Server={0};Database={1};User Id={2};Password={3}", dbHost, dbName, dbUser, dbPass);
            Console.Out.WriteLine(connectionString);
            conn = new MySqlConnection(connectionString);
        }
         
        // Returns all book review records from the database.
        public DataSet FindAll() {
            DataSet ds = new DataSet("FindAll");
            try {
                conn.Open();
                MySqlDataAdapter adapter = new MySqlDataAdapter("select name, author, review from books", conn);
                adapter.Fill(ds);
                conn.Close();
            } catch (Exception e) {
                Console.Error.WriteLine(e);
            } finally {
                if (conn.State == ConnectionState.Open) {
                    conn.Close();
                }
            }
            return ds;
        }
         
        // Returns all book review records by the specified author.
        public DataSet FindByUser(string authorName) {
            DataSet ds = new DataSet("FindByUser");
            try {
                String selectQuery = String.Format("select name, author, review from books where author='{0}'", authorName);
                conn.Open();
                MySqlDataAdapter adapter = new MySqlDataAdapter(selectQuery, conn);
                adapter.Fill(ds);
                conn.Close();
            } catch (Exception e) {
                Console.Error.WriteLine(e);
            } finally {
                if (conn.State == ConnectionState.Open) {
                    conn.Close();
                }
            }
            return ds;
        }
         
        // Inserts a single book review record into the database.
        public void AddData(String bookName, String authorName, String review) {            try {
                MySqlCommand command = new MySqlCommand("insert into books(name, author, review) values (@name, @author, @review)", conn);
                conn.Open();
                command.Parameters.Add("@name", bookName);
                command.Parameters.Add("@author", authorName);
                command.Parameters.Add("@review", review);
                command.ExecuteNonQuery();
                conn.Close();
            } catch (Exception e) {
                Console.Error.WriteLine(e);
            } finally {
                if (conn.State == ConnectionState.Open) {
                    conn.Close();
                }
            }
        }
 
        // Returns true if user is already in the system, else returns false.
        public bool IsValidUser(String authorName) {
            Int64 numRows = 0;
            try {
                MySqlCommand command = new MySqlCommand(String.Format("select count(*) from books where author='{0}'", authorName), conn);
                conn.Open();
                numRows = (Int64) command.ExecuteScalar(); // bug in ByteFX, returns Int64 instead of Int8 (int)
                conn.Close();
            } catch (Exception e) {
                Console.Error.WriteLine(e);
            } finally {
                if (conn.State == ConnectionState.Open) {
                    conn.Close();
                }
            }
            return (numRows > 0);
        }
    }
     
}

One thing to note is that ByteFX works with the authentication system for MySQL 4.0, so if you are using a higher version, you will need to downgrade its authentication to the "old-password" mode. Here is how this is done, I found this in a newsgroup posting on the web.

1
2
3
mysql> use mysql;
mysql> set password for root@localhost=old_password('mysql');
mysql> flush privileges;

Since my application is still a console application, I could call the MainClass.Main method to add my records and test the functioning of the other methods. Here is the MainClass.cs code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// Main.cs
using System;
using System.Data;
 
namespace BookReviews {
 
    class MainClass {
     
        private void Find(string authorName) {
            BookReviewDao dao = new BookReviewDao();
            DataSet ds = dao.FindByUser(authorName);
            Format(ds);
        }
         
        private void FindAll() {
            BookReviewDao dao = new BookReviewDao();
            DataSet ds = dao.FindAll();
            Format(ds);
        }
         
        private void Format(DataSet ds) {
            DataTable books = ds.Tables[0]; // bug in ByteFX, return "Table" for TableName.
            for (int curCol = 0; curCol < books.Columns.Count; curCol++) {
                Console.Out.Write(books.Columns[curCol].ColumnName.Trim() + "\t");
            }
            Console.Out.WriteLine();
            for (int curRow = 0; curRow < books.Rows.Count; curRow++) {
                for (int curCol = 0; curCol < books.Columns.Count; curCol++) {
                    Console.Out.Write(books.Rows[curRow][curCol].ToString().Trim() + "\t");
                }
                Console.Out.WriteLine();
            }
        }
                 
        private void Add() {
            for (;;) {
                Console.Out.Write("Book name:");
                string bookName = Console.In.ReadLine();
                Console.Out.Write("Author:");
                string authorName = Console.In.ReadLine();
                Console.Out.Write("Review:");
                string review = Console.In.ReadLine();
                BookReviewDao dao = new BookReviewDao();
                dao.AddData(bookName, authorName, review);
                Console.Out.Write("Add another (y/n)[y]?");
                string yorn = Console.In.ReadLine();
                if (yorn.StartsWith("n")) {
                    break;
                } else {
                    continue;
                }
            }
        }
         
        private bool IsValid(string authorName) {
            BookReviewDao dao = new BookReviewDao();
            return dao.IsValidUser(authorName);
        }
         
        private void Help() {
            Console.Out.WriteLine("Welcome to BookReviews");
            Console.Out.WriteLine("Valid options are:");
            Console.Out.WriteLine("--find: find by author");
            Console.Out.WriteLine("--findall: find all records");
            Console.Out.WriteLine("--add: add new record");
            Console.Out.WriteLine("--help: display this message");
        }
         
        public static void Main(string[] args) {
            MainClass bookReviews = new MainClass();
            if (args.Length == 0) {
                bookReviews.Help();
            } else {
                if (args[0] == "--find") {
                    string authorName = args[1];
                    bookReviews.Find(authorName);
                } else if (args[0] == "--findall") {
                    bookReviews.FindAll();
                } else if (args[0] == "--add") {
                    bookReviews.Add();
                } else if (args[0] == "--valid") {
                    string authorName = args[1];
                    bookReviews.IsValid(authorName);
                } else {
                    bookReviews.Help();
                }
            }
        }
    }
}

Using this, I populated the table with some test data. Here is how it was called.

1
2
$ cd bin/Debug
$ mono BookReviews.exe --add

Since I really want the DAO to be used by my ASP.NET web application, I converted this to be a library. You can do this by right clicking on project, selecting Configurations, change Compile_Target from Executable to Library. Code generated in bin/Debug/*.exe changes to bin/Debug/*.dll.

The next step is to create the ASPX pages and the corresponding ASPX.CS (called Code-behind) C# class code. I visualized a mini-app with a login page which shows a list of reviews made by you. This page has a link to all reviews. The all reviews page has a link back to the review by the particular logged in user. So I needed a Index.aspx page for the login page and the List.aspx page for the list pages. Mono has no template for a Web Project (unlike the Microsoft Visual Studio IDE on Windows). I copied some boilerplate code from Charlie Calvert's page for Global.asax.cs and Index.aspx.cs and List.aspx.cs, and added the required event handlers to the last two files. Here is the code for the .aspx pages and the corresponding .aspx.cs pages.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- Index.aspx -->
<%@ Page language="c#" Codebehind="Index.aspx.cs" AutoEventWireup="false" Inherits="BookReviews.Index" %>
<Html>
    <Head>
        <Title>BookReviews Login</Title>
    </Head>
    <Body>
        <center><asp:Label id="lblMessage" runat="server" text="Please login" /></center>
        <form id="loginForm" method="post" runat="server">
            <asp:Label id="lblUsername" style="Z-INDEX: 101; LEFT: 144px; POSITION: absolute; TOP: 164px" runat="server" text="Username:" />
            <asp:TextBox id="txtUsername" style="Z-INDEX: 101; LEFT: 256px; POSITION: absolute; TOP: 164px" runat="server" />
            <asp:Label id="lblPassword" style="Z-INDEX: 101; LEFT: 144px; POSITION: absolute; TOP: 228px" runat="server" text="Password:" />
            <asp:TextBox id="txtPassword" style="Z-INDEX: 101; LEFT: 256px; POSITION: absolute; TOP: 228px" runat="server" />
            <asp:Button id="btnLogin" style="Z-INDEX: 101; LEFT: 144px; POSITION: absolute; TOP: 292px" runat="server" text="Login" />
            <asp:Button id="btnCancel" style="Z-INDEX: 101; LEFT: 256px; POSITION: absolute; TOP: 292px" runat="server" text="Cancel" />
        </form>     </Body>
</Html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Index.aspx.cs
using System;
using System.Drawing;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
 
namespace BookReviews {
 
    // <summary>
    // Code-behind component for the Index.aspx page. Defines event
    // handlers for this page.
    // </summary>
    public class Index : Page {
     
        protected Label lblUsername;
        protected Label lblPassword;
        protected TextBox txtUsername;
        protected TextBox txtPassword;
        protected Button btnLogin;
        protected Button btnCancel;
        protected Label lblMessage;
     
        // generated code, if mono could do this. This is copied from this site:        // http://bdn.borland.com/article/0,1410,32057,00.html
         
        private void Page_Load(object sender, System.EventArgs e) {
            // user code to initialize the page
        }
         
        protected override void OnInit(System.EventArgs e) {
            InitializeComponent();
            base.OnInit(e);
        }
         
        protected void InitializeComponent() {
            this.btnCancel.Click += new System.EventHandler(this.BtnCancel_Click);
            this.btnLogin.Click += new System.EventHandler(this.BtnLogin_Click);            this.Load += new System.EventHandler(this.Page_Load);
        }
         
        // end of generated code
 
        // <summary>
        // Acts just like a reset, except that this is done in C#
        // code, on response to a click event on this cancel button.
        // </summary>
        private void BtnCancel_Click(object sender, System.EventArgs e) {
            txtUsername.Text = "";
            txtPassword.Text = "";
        }
 
        // <summary>
        // Fired in response to a click event on the login button. Sends
        // off to the BookReviewDao for authentication, and redirects to
        // the list page if successful, else, displays an error and re-prompts
        // for user name and password. If login is successful, then it
        // also puts the user name in the session. We later look it up as
        // needed for any personalization in the other pages.
        // </summary>
        private void BtnLogin_Click(object sender, System.EventArgs e) {
            BookReviewDao dao = new BookReviewDao();
            if (dao.IsValidUser(txtUsername.Text)) {
                // we dont do much password validation, all
                // we care about is that he is already in our
                // system
                Session["userName"] = txtUsername.Text;
                Response.Redirect("/List.aspx?author=" + txtUsername.Text);
            } else {
                lblMessage.Text = "Invalid Login, please try again";
                lblMessage.ForeColor = Color.Red;
            }
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- List.aspx -->
<%@ Page language="c#" Codebehind="List.aspx.cs" AutoEventWireup="false" Inherits="BookReviews.List" %>
<Html>
    <Head>
        <Title>BookReviews List</Title>
    </Head>
    <Body>
        <form runat="server">
            <asp:DataList runat="server" id="bookReviewDl">
                <HeaderTemplate>List of book reviews</HeaderTemplate>
                <ItemTemplate>
                    <%# DataBinder.Eval(Container.DataItem, "name") %></td>
                    <td><%# DataBinder.Eval(Container.DataItem, "author") %></td>
                    <td><%# DataBinder.Eval(Container.DataItem, "review") %>
                </ItemTemplate>
                <FooterTemplate />
            </asp:DataList>
            <a href="<%= GetOtherListHyperLink().NavigateUrl %>"><%= GetOtherListHyperLink().Text %></a>
        </form>
    </Body>
</Html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// List.aspx.cs
using System;
using System.Collections;
using System.Collections.Specialized;
using System.Data;
using System.Drawing;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
 
namespace BookReviews {
 
    // <summary>
    // Code-behind component of the List.aspx page.
    // </summary>
    public class List : Page {
     
        protected HyperLink lnkOtherList;
        protected DataList bookReviewDl;
     
        // generated code, if mono could do this. This is copied from this site:        // http://bdn.borland.com/article/0,1410,32057,00.html
         
        public void Page_Load(object sender, System.EventArgs e) {
            // user code to initialize the page
            if (!IsPostBack) {
                // scan the parameters looking for author
                NameValueCollection parameters = Request.Params;
                ICollection keys = (ICollection) parameters.Keys;
                string author = null;
                foreach (string key in keys) {
                    if (key == "author") {
                        author = (string) parameters["author"];
                        break;
                    }
                }
                BookReviewDao dao = new BookReviewDao();
                DataSet reviewDs;
                if (author == null) {
                    reviewDs = dao.FindAll();
                    lnkOtherList = new HyperLink();
                    lnkOtherList.Text = "Show book reviews by " + ((string) Session["userName"]);
                    lnkOtherList.NavigateUrl = "/List.aspx?author=" + ((string) Session["userName"]);
                } else {
                    reviewDs = dao.FindByUser(author);
                    lnkOtherList = new HyperLink();
                    lnkOtherList.Text = "Show all book reviews";
                    lnkOtherList.NavigateUrl = "/List.aspx";
                }
                bookReviewDl.DataSource = createDataSource(reviewDs);
                bookReviewDl.DataBind();
            }
        }
         
        protected override void OnInit(System.EventArgs e) {
            InitializeComponent();
            base.OnInit(e);
        }
         
        protected void InitializeComponent() {
            this.Load += new System.EventHandler(this.Page_Load);
        }
         
        // end of generated code
 
        // <summary>
        // Creates a in-memory Collection that can be used by the aspx
        // page based on the DataSet object returned from the Dao.
        // </summary>
        public ICollection createDataSource(DataSet ds) {
            DataTable books = ds.Tables[0]; // bug in ByteFX, return "Table" for TableName.
            bool debug = true;
            if (debug) {
                for (int curCol = 0; curCol < books.Columns.Count; curCol++) {
                    Console.Out.Write(books.Columns[curCol].ColumnName.Trim() + "\t");
                }
                Console.Out.WriteLine();
                for (int curRow = 0; curRow < books.Rows.Count; curRow++) {
                    for (int curCol = 0; curCol < books.Columns.Count; curCol++) {
                        Console.Out.Write(books.Rows[curRow][curCol].ToString().Trim() + "\t");
                    }
                    Console.Out.WriteLine();
                }
            }
            return new DataView(books);
        }
 
        // <summary>
        // Returns a reference to the HyperLink control.
        // </summary>
        public HyperLink GetOtherListHyperLink() {
            return lnkOtherList;
        }
    }
}

Basically, the Page.Page_Load method is called when the page is loaded and the *_Click events are triggered from the mouse clicks on the page. Not very hard to understand if you've already been on the Tapestry learning curve, but may require a bit of unlearning for classic J2EE programmers.

The Web.config file I used is minimal, copied from the demo web.config file from the xsp sample directory.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<!-- Web.config -->
<configuration>
 
    <system.web>
      <customErrors mode="Off"/>
 
      <authentication mode= "None">
      </authentication>
 
      <httpModules>
      </httpModules>
 
    </system.web>
 
    <appSettings>
      <add key="MonoServerDefaultIndexFiles" value="index.aspx, Default.aspx, default.aspx, index.html, index.htm" />
   </appSettings>
 
</configuration>

To run the pages, I used the xsp server, which is distributed as part of Mono. You can also add the mod_mono to the Apache httpd webserver to serve ASP pages in a production environment. To do this, make sure that the .DLL file generated by MonoDevelop when you build your project is in the bin/ directory of your project. It is usually in bin/Debug, but I would just move it before starting xsp. xsp should be started in your project directory.

1
2
3
4
5
6
7
8
$ pwd
/home/sujit/Projects/BookReviews
$ ls
AssemblyInfo.cs   BookReviews.mdp   Global.asax.cs  List.aspx     Web.config
bin               BookReviews.mds   Index.aspx      List.aspx.cs
BookReviewDao.cs  BookReviews.pidb  Index.aspx.cs   Main.cs
$ mv bin/Debug/BookReviews.dll bin/
$ xsp

The application is now browseable at http://localhost/Index.aspx. Since I was running on Linux, I used Mozilla Firefox. I mention this only because there is a general belief that ASP pages cannot be used from browsers other than MS Internet Explorer. This may be true if you were using client side Visual Basic scripts instead of Javascript, but since ASPX is all done server-side, pages can be served to any regular browser.

My final step was to import the project I was going to integrate into MonoDevelop on my Linux desktop. MonoDevelop has a "Import Visual Studio project" option. The important thing to note is that you will need to select the .csproj file, which is where Visual Studio keeps its project information. If the imported project consists of multiple subprojects, as mine did, each of them needs to be imported separately as solutions. The dependencies will be need to be set up manually by compiling the low level modules separately into DLLs, and then adding them as references to the main solution.

Here are some resources I found useful. There are a lot of resources on the internet dealing with .NET development, some of it served by Microsoft (MSDN), and some from the Code Project.

I also found the following books useful. Of course, I had bought these some time back, hoping to use them one day, but never did. So arguably, I used these since these were the ones I had lying around, but they are worth looking at if you want to start out with C#/.NET development.

  • Mono - A Developer's Notebook by Edd Dumbill and Niel M Bornstein - A great book if you want to get up and running with C# and .NET using Mono really quickly. It lacks depth though, so be prepared to do a little work looking up details on the web.
  • C# and the .NET Platform by Andrew Troelsen - The link is to the second edition. I have the first (beta) edition, and admittedly, there are some places where its dated (the code does not work). But it provides a very detailed introduction to the various components that make up the .NET framework.

Compared to Visual Studio, which I also installed on my Windows computer, MonoDevelop is faster to get up and running. But Visual Studio is much more slicker, although as an Eclipse user, I think it could benefit from some of the features available there.

I think Mono would be very useful as a simple (simpler than C/C++) language to build interfaces to system tools on Linux. Apparently the Mono developers seem to think so too, as evidenced by their greater support for Gtk# and Glade# project templates. RedHat seems to have standardized on Python for this work, but Novell is making some steps in that direction with SuSE.

Saturday, April 15, 2006

Jetty setup for serving web apps

My servlet/JSP container of choice is Resin. For those that are unfamiliar with Resin, it is a fast and extremely easy to use servlet container. However, of late, I have been experimenting with using Jetty as an in-place servlet container that I can start with Ant and run my JWebUnit tests.

While there is some documentation in the Jetty site that explains how to set up Jetty to serve JSP pages from a web application, the process is not exactly straightforward. Moreover, there was not much information available on the web about my desired setup. The Jetty documentation actually recommends that one should precompile the JSPs using the Jasper compiler before deployment, implying that JSP support may be flaky. I am happy to report, however, that I was successful in serving an application that uses Spring and Hibernate for the Model and Controller layers, and JSTL in JSPs for the View layer. So I decided to write up my experience hoping that it would be helpful to someone else with similar requirements.

My desired setup was to be able to start Jetty as an Ant target in a standard Web Application project. The Jetty server would work directly on the application. No packaging into a WAR and deploying should be necessary, and neither would the server be required to explode a WAR file. To minimize problems, I made sure that my webapp worked perfectly with Resin.

Interestingly, there are at least 3 ways to start up Jetty for any specific purpose. The first approach is to use the provided start.jar with an application specific XML configuration file. The second approach is to call the org.mortbay.jetty.Server with the appropriate classpath settings from within Ant or from a shell script, and the application specific XML configuration file. The third approach is to write a class that will instantiate the Server and configure it using Java code.

The first approach is closely tied to the directory structure of the Jetty distribution, so if your application has a different directory structure, as mine was, you would need to override the start.config with your own settings. I did not want to package the Jetty JAR files along with this start.jar in my application, and I did not really want to mess with the non-standard start.config file (unless I could not do it any other way), so that eliminated the first approach.

I started off with the third approach, but had some early successes, but since I was still trying to figure out the configuration that will work for me, this approach started me on a path of too many compile-test cycles, so I ultimately gave it up in favor of the second approach.

I used the latest stable full release of Jetty at the time of writing this, which is 5.1.10. I downloaded jetty-5.1.10-all.zip which contained the demo and the sources. I started reading through the documentation and found that there is an example web application XML configuration etc/jetty.xml file which worked with a built in Ant target "run". This target uses the second approach outlined above.

I started out with a copy of jetty.xml as my starting point. To find the classes that need to be in the Java classpath for the Server class to run correctly, I ran the following command in the root directory of the Jetty download distribution:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[sujit@cyclone jetty-5.1.10]$ ant -v run
Apache Ant version 1.6.5 compiled on June 2 2005
     [java] ... output snipped ...
     [java] Executing '/usr/java/jdk1.5.0_03/jre/bin/java' with arguments:
     [java] '-Djetty.home=/home/sujit/tmp/jetty-5.1.10'
     [java] '-classpath'
     [java] '/home/sujit/tmp/jetty-5.1.10/lib/org.mortbay.jetty.jar:             /home/sujit/tmp/jetty-5.1.10/lib/javax.servlet.jar:             /home/sujit/tmp/jetty-5.1.10/ext/jasper-runtime.jar:             /home/sujit/tmp/jetty-5.1.10/ext/jasper-compiler.jar:             /home/sujit/tmp/jetty-5.1.10/ext/ant.jar:             /home/sujit/tmp/jetty-5.1.10/ext/commons-el.jar:             /home/sujit/tmp/jetty-5.1.10/ext/commons-logging.jar:             /home/sujit/tmp/jetty-5.1.10/ext/mx4j-remote.jar:             /home/sujit/tmp/jetty-5.1.10/ext/mx4j-tools.jar:             /home/sujit/tmp/jetty-5.1.10/ext/mx4j.jar:             /home/sujit/tmp/jetty-5.1.10/ext/xercesImpl.jar:             /home/sujit/tmp/jetty-5.1.10/ext/xml-apis.jar:             /home/sujit/tmp/jetty-5.1.10/ext/xmlParserAPIs.jar'
     [java] 'org.mortbay.jetty.Server'
     [java] '/home/sujit/tmp/jetty-5.1.10/etc/admin.xml'
     [java] '/home/sujit/tmp/jetty-5.1.10/etc/jetty.xml'
     [java] ... more stuff snipped ...

Some of the JARs I already had in my WEB-INF/lib directory, the rest I copied from the Jetty distribution into my WEB-INF/lib directory of the webapp. I also made a copy of the supplied etc/jetty.xml file. The jetty.xml file is set up to start all applications under the context root, so I commented that portion and uncommented the next block which works with a single web application. I also changed the context root and the webapp name for my web application. This is the block that I changed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  <Call name="addWebApplication">
    <Arg>/prozac</Arg>
    <Arg>./webapps/prozac</Arg>
                                                                                
    <Set name="extractWAR">false</Set>
    <Set name="defaultsDescriptor">org/mortbay/jetty/servlet/webdefault.xml</Set>
    <Set name="classLoaderJava2Compliant">true</Set>
                                                                                
    <Set name="virtualHosts">
      <Array type="java.lang.String">
        <Item></Item>
        <Item>127.0.0.1</Item>
        <Item>localhost</Item>
      </Array>
    </Set>
  </Call>

I also uncommented the systemClasses and serverClasses section that prevents the webapp from reloading the classes listed under systemClasses and makes the serverClasses inaccessible from the web application. Since I was using the JSTL tag libraries, I also uncommented the TagLibConfiguration under WebApplicationConfigurationClassNames.

I also built a local "start-server" target that mimicked the "run" target of the Jetty distribution. When I ran this target, I got a log4j error, saying that log4j was not properly configured. A quick look at the log4j documentation pointed me to the answer, which was to add a system property "log4j.configuration" pointing to a file URL for the log4j.properties file.

One thing I want to mention here is that I created a specific log4j.properties file for the Jetty server, which was different from what I was using for the rest of the application. The reason for this is that I wanted to only log messages INFO and above for Jetty but DEBUG and above for the rest of the application. Setting the level to DEBUG for Jetty gives many messages which look like errors but is basically Jetty cycling through various alternatives. Also the DEBUG logging for Jetty is quite verbose and not very useful unless you are debugging Jetty.

The next roadblock I had was that Jetty complained that it could not find the class javax.servlet.jsp.jstl.fmt.LocalizationContext. I found this class in lib/jstl-11.jar of my Resin distribution, so I copied this to my local server classpath as well.

The next problem I had was that Jasper failed to compile my JSP because it could not find com.sun.javac.Main in my JDK. It gives a misleading message about possible bad setting of JAVA_HOME, but if you specifically include the tools.jar file of your Java distribution in your classpath, it is able to compile the JSP.

Another little side note. For those who are tempted to ignore the ant.jar in the original classpath, as I was, based on the understanding that ant.jar is already in the classpath since the target is being invoked by Ant, here is the reason why ant.jar is needed - Jasper uses Ant and the Java compiler javac to process and compile the JSPs in the application, and the ant.jar does need to be specifically included in the classpath.

The final problem before everything came together was the start-server target complaining that the log4j.properties file was not a zip file. This was because I had added the log4j.properties file to the classpath before I found out about the log4j.configuration system property. Removing the log4j.properties file allowed Jetty to start up without problems and serve my web application without any problems.

Here is my Ant target for starting the Jetty server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    <target name="start-server" depends="setup" description="Starts the built-in Jetty server">
        <java fork="yes" classname="org.mortbay.jetty.Server" dir="." failonerror="true">
            <classpath>
                <fileset dir="lib">
                    <include name="org.mortbay.jetty.jar" />
                    <include name="javax.servlet.jar" />
                    <include name="jasper-runtime*.jar" />
                    <include name="jasper-compiler*.jar" />
                    <include name="ant*.jar" />
                    <include name="commons-el*.jar" />
                    <include name="commons-logging*.jar" />
                    <include name="mx4j-remote*.jar" />
                    <include name="mx4j-tools*.jar" />
                    <include name="xercesImpl*.jar" />
                    <include name="xml-apis*.jar" />
                    <include name="xmlParserAPIs*.jar" />
                    <include name="log4j*.jar" />
                 </fileset>
                 <fileset dir="${env.JAVA_HOME}/lib">
                     <include name="tools.jar" />
                 </fileset>
            </classpath>
            <jvmarg line="-Djetty.home=${basedir}" />
            <arg value="WEB-INF/jetty.xml" />
            <sysproperty key="log4j.configuration" value="file://${basedir}/WEB-INF/classes/jetty-log4j.properties" />
        </java>
    </target>

And here is the contents of my jetty.xml file (commented out sections omitted for brevity), which is being passed as an argument to the org.mortbay.jetty.Server class in the "start-server" target:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://jetty.mortbay.org/configure.dtd">
 
<Configure class="org.mortbay.jetty.Server">
 
  <Call name="addListener">
    <Arg>
      <New class="org.mortbay.http.SocketListener">
        <Set name="Port"><SystemProperty name="jetty.port" default="8080"/></Set>
        <Set name="PoolName">P1</Set>
        <Set name="MinThreads">20</Set>
        <Set name="MaxThreads">200</Set>
        <Set name="lowResources">50</Set>
        <Set name="MaxIdleTimeMs">30000</Set>
        <Set name="LowResourcePersistTimeMs">2000</Set>
        <Set name="acceptQueueSize">0</Set>
        <Set name="ConfidentialPort">8443</Set>
        <Set name="IntegralPort">8443</Set>
      </New>
    </Arg>
  </Call>
 
  <Set name="WebApplicationConfigurationClassNames">
    <Array type="java.lang.String">
      <Item>org.mortbay.jetty.servlet.XMLConfiguration</Item>
      <Item>org.mortbay.jetty.servlet.JettyWebConfiguration</Item>
      <Item>org.mortbay.jetty.servlet.TagLibConfiguration</Item>
    </Array>
  </Set>
 
  <Call name="addWebApplication">
    <Arg>/prozac</Arg>
    <Arg>./webapps/prozac</Arg>
 
    <Set name="extractWAR">false</Set>
    <Set name="defaultsDescriptor">org/mortbay/jetty/servlet/webdefault.xml</Set>
    <Set name="classLoaderJava2Compliant">true</Set>
 
    <Set name="virtualHosts">
      <Array type="java.lang.String">
        <Item></Item>
        <Item>127.0.0.1</Item>
        <Item>localhost</Item>
      </Array>
    </Set>
  </Call>
 
  <Set name="RequestLog">
    <New class="org.mortbay.http.NCSARequestLog">
      <Arg><SystemProperty name="jetty.home" default="."/>/logs/yyyy_mm_dd.request.log</Arg>
      <Set name="retainDays">90</Set>
      <Set name="append">true</Set>
      <Set name="extended">false</Set>
      <Set name="LogTimeZone">GMT</Set>
    </New>
  </Set>
 
  <Set name="requestsPerGC">2000</Set>
  <Set name="statsOn">false</Set>
  <Set class="org.mortbay.util.FileResource" name="checkAliases" type="boolean">true</Set>
 
  <Set name="systemClasses">
    <Array type="java.lang.String">
      <Item>java.</Item>
      <Item>javax.servlet.</Item>
      <Item>javax.xml.</Item>
      <Item>org.mortbay.</Item>
      <Item>org.xml.</Item>
      <Item>org.w3c.</Item>
      <Item>org.apache.commons.logging.</Item>
    </Array>
  </Set>
 
  <Set name="serverClasses">
    <Array type="java.lang.String">
      <Item>-org.mortbay.http.PathMap</Item>
      <Item>org.mortbay.http.</Item>
      <Item>-org.mortbay.jetty.servlet.Default</Item>
      <Item>-org.mortbay.jetty.servlet.Invoker</Item>
      <Item>-org.mortbay.jetty.servlet.JSR154Filter</Item>
      <Item>org.mortbay.jetty.</Item>
      <Item>org.mortbay.start.</Item>
      <Item>org.mortbay.stop.</Item>
    </Array>
  </Set>
 
</Configure>

No other changes were required in the application. I have seen Jetty being used before as an embedded servlet container, and I know that JBoss used Jetty as its servlet container of choice at one point, so Jetty itself is not entirely new to me. However, this is the first time I have successfully used Jetty for anything. I found Jetty to be quite nimble and light on resources. I think that along with the many uses for Jetty as a lightweight, embeddable, high performance servlet container for moderate traffic (I am told that performance degrades with extremely high traffic volumes), it can also be generally useful for the use I am putting it to, that is, to serve as an in-place servlet container for JSP unit testing.