8.5 Project: MySQL, DBI, and mod_perl

We now have enough information to put all the ideas we have discussed into practice by combining mod_perl, MySQL, and DBI.

We start by developing a mod_perl program that displays to the visitor a list of available books about interesting computer subjects. Each book in the list will have a link taking the user to a page describing the details about the book, including the publisher and the price.

The collection will number approximately 75 books. With a database of such a small number of books, a possible solution would be to use a flat file to store the data, such as can be found in the file /var/www/misc/book_data.txt . A line of the file is a tab-delimited record containing the following:

  • ISBN

  • Author

  • Title

  • Publisher

  • Date

  • Description

  • Price

This is an example line of the file:

 0596000278 <tab> Larry Wall, Tom Christiansen, Jon Orwant <tab>  Programming Perl (3rd Edition) <tab> O'Reilly & Associates <tab>  July 2000 <tab> The best book about Perl  written by Larry Wall, the creator of Perl. <tab> .95 

The fields are separated by a single tab character, represented here by <tab> because invisible tab characters are hard to see in a book.

For this program, we will create two pages. The first page will show a brief listing of all the books from the file in groups of ten. When a user clicks on one of the books, a second page will load that shows a verbose listing of that book's information.

We will also show how to customize Apache's logging phase for each of these pages. The information will be logged to access_log as normal, but we will add functionality to send an e-mail for each access.

The first page is a listing of all the books in our database, showing ten books at a time. Since we can have more than ten books (in our example file, we will have more than 75 books), we want to give the user the ability to navigate to another page of ten, so we'll provide links to the remaining pages. This sort of display should be familiar to anyone who's used a search engine, so this example should show how useful this type of code is.

Although implementing this program with MySQL would be the best approach in the long term , especially if our database grows to thousands of books or more, this program can be implemented by reading the data directly from the text file. This flat-file approach is OK for small databases because it is simple to code ”just read from a file and you are ready to go. If you want to see this flat-file solution, check out /var/www/mod_perl/BookListing.pm and /var/www/mod_perl/BookDetail.pm . You can view these files at http://localhost/mod_perl/BookListing.pm , www.opensourcewebbook.com/mod_perl/BookListing.pm, http://localhost/mod_perl/BookDetail.pm , or www.opensourcewebbook.com/mod_perl/BookDetail.pm.

To make these work, the following must be added to httpd.conf :

 <Location /booklisting>    SetHandler        perl-script    PerlHandler       BookListing  </Location>  <Location /bookdetail>    SetHandler        perl-script    PerlHandler       BookDetail  </Location> 

To see it in action, go to one of these URLs: www.opensourcewebbook.com/booklisting, http://localhost/booklisting , www.opensourcewebbook.com/bookdetail?isbn=0072127732, or http://localhost/bookdetail?isbn=0072127732 .

Because we recommend using MySQL, we won't discuss the flat-file solution any further but leave it as an exercise for the reader.

The first step in implementing this project in MySQL is to create the tables that will hold the information. Log in to the MySQL server as root and create a new database and table:

 #  mysql -u root -p  Enter password:  Welcome to the MySQL monitor. Commands end with ; or \g.  Your MySQL connection id is 114 to server version: 3.23.36  Type help; or \h for help. Type \c to clear the buffer  mysql>  CREATE DATABASE books;  Query OK, 1 row affected (0.00 sec)  mysql>  USE books;  Reading table information for completion of table and column names  You can turn off this feature to get a quicker startup with -A  Database changed  mysql>  CREATE TABLE book_information (  ->  isbn        CHAR(10) PRIMARY KEY NOT NULL,  ->  author      CHAR(100),  ->  title       CHAR(100),  ->  publisher   CHAR(100),  ->  date        CHAR(20),  ->  description TEXT,  ->  price       FLOAT  ->  );  Query OK, 0 rows affected (0.00 sec)  mysql>  DESCRIBE book_information;  +-------------+--------------+------+-----+---------+-------+   Field        Type          Null  Key  Default  Extra   +-------------+--------------+------+-----+---------+-------+   isbn         varchar(10)         PRI                    author       varchar(100)  YES        NULL              title        varchar(100)  YES        NULL              publisher    varchar(100)  YES        NULL              date         varchar(20)   YES        NULL              description  text          YES        NULL              price        float         YES        NULL             +-------------+--------------+------+-----+---------+-------+  7 rows in set (0.00 sec) 


We created the table book_information with a key, or a field that has a unique value relative to all other values for this field. We decided to use ISBNs as the key ( isbn ) because they are unique and so are quite suitable for this purpose, almost as if they were designed for it.

Grant the apache user permission for this new database:

 mysql>  USE mysql;  Reading table information for completion of table and column names  You can turn off this feature to get a quicker startup with -A  Database changed  mysql>  GRANT SELECT,INSERT,UPDATE,DELETE  ->  ON books.*  ->  TO apache@localhost;  Query OK, 0 rows affected (0.00 sec) 

Note that the GRANT command does not need IDENTIFIED BY to set the password this time ”we already set the apache MySQL user password the first time we executed GRANT back in Chapter 5.

OK, now that the tables are ready, they need to be populated . We already have a flat file of all the books, which we went to quite a bit of trouble to type in, so it would be nice to be able to transfer that data to the database. Never fear ”Perl and DBI to the rescue! Reformatting and transferring data is one thing that Perl excels at. [6]

[6] We could have entered the data using the mysqlimport command because book data.txt is a well- formed , tab-delimited file. But using Perl is much more fun. See mysqlimport --help for details.

The program book.pl puts the contents of the flat file of book information in the database:

 #!/usr/bin/perl -w  # book.pl  use strict;  use DBI;  # connect to the database  my $dbh = DBI->connect(DBI:mysql:books, apache, LampIsCool)          or die "Can't connect: " . DBI->errstr();  # open the data file  open FH, /var/www/misc/book_data.txt or die $!;  my($line, $sth);  # loop through each line of the file  while ($line = <FH>) {      chomp($line);      # split the line into some variables      my($isbn, $author, $title, $publisher, $date,         $description, $price) = split /\t/, $line;      # remove the $ from $price      $price =~ s/  ^  $//;      # prepare SQL for insert      $sth = $dbh->prepare(INSERT INTO book_information                  (isbn,                      author,                      title,                      publisher,                      date,                      description,                      price)                  VALUES                  (?,                      ? ,                      ?,                      ?,                      ?,                      ?,                      ?))              or die "Can'tprepareSQL: " . $dbh->errstr();      # execute the query, passing the variable values so they      # can fill the ? placeholders in the query      $sth->execute($isbn, $author, $title, $publisher, $date,                    $description, $price)              or die "Can't execute SQL: " . $sth->errstr();  }  # close the file, finish and disconnect  close FH;  $sth->finish();  $dbh->disconnect(); 

The script first tells Perl it needs the DBI module; then it connects to the books database and die() s if it can't. It opens the file containing the book information with the open() function, and if the open fails, it die() s.

The script then loops through the input file, inserting the data into the database after chomp() ing the newline character and split ing on a tab character. Because the price has a leading dollar sign, it is removed with the s/// statement.

The script then constructs the SQL query to insert the data into the database and calls execute() with the proper variables to plug into the " ? " placeholders in the query.

Finally, in good style, the input file is closed, the statement handle finished, and the database disconnected. Run the program now:

 $  book.pl  $  ./book.pl  

The database should now be populated, so it is time to talk about the mod_perl programs to display the database information.

First, look at the code to generate the list of books in the file /var/www/mod_perl/BookListingMysql.pm . For the complete code, see http://localhost/mod_perl/BookListingMysql.pm or www.opensourcewebbook.com/mod_perl/BookListingMysql.pm.

The purpose of this code is to display the data in a nice, readable fashion in a table. The program does so by first connecting to the MySQL server, querying the database to read the information for a subset of the book data (because there are too many books to display at once, we will show ten books at a time), and building a table of the information. We will also add a row of links allowing us to navigate to different subsets of book data, just for fun.

First, at the top, put the appropriate package name :

 package BookListingMysql;  # file: BookListingMysql.pm 

We must use the DBI module, so add this among other use pragmas:

 use DBI; 

A simple error-handling routine is added to this module, much like the one you saw in Chapter 7, age.cgi :

 ####  #  # handle_error() - subroutine to handle any errors  #  ####  sub handle_error {      my $r     = shift;      my $msg   = shift;      my $dbh   = shift;      my $sth   = shift;      $r->print(<<EOHTML);  ERROR: <font color="#ff0000">$msg</font>  EOHTML      print bottom_html();      $sth->finish()     if $sth;      $dbh->disconnect() if $dbh;      return OK;  } 

This handle_error() subroutine is similar to the ones used previously, but this time it returns OK instead of exiting so that Apache can be given a return value that tells it to continue processing the request ”this way, Apache handles failure gracefully. The subroutine takes four arguments: $r , the request object; $msg , the error message; $dbh , the database handle object; and $sth , the statement handle object. Then some helpful HTML code is printed, followed by what bottom_html() returns (the bottom HTML code, natch). The statement is finished, if necessary, and the database connection is disconnected, if necessary. Then OK is returned.

The next bit of code defines the top_html() function. In this function, one big here document is returned:

 ####  #  # top_html()  #  # a subroutine that will return the HTML for the top of  # the page - this will create a look and feel like the  # rest of the website  #  ####  sub top_html {      return <<EOHTML;  <html>  ...  EOHTML  } 

The top_html() function returns the HTML for the top of the page (clever name, eh?). It includes the HTML for the top and left rail.

Following is the bottom_html() function, which prints, as you might guess, the HTML for the bottom of the page:

 ####  #  # bottom_html()  #  # a subroutine that will return the HTML for the bottom of  # the page  #  ####  sub bottom_html {      return <<EOHTML;  </td>  <td width="65">&nbsp;</td>  </tr>  </table>  </body>  </html>  EOHTML  } 

We then see the definition of the handler() function, which works as follows :

 ####  #  # handler()  #  ####  sub handler {      # shift the argument and pass it into      # the new() method in the Apache::Request      # class      my $r = new Apache::Request(shift); 

The first thing this function does is create an Apache::Request by passing the first argument of handler() to the Apache::Request constructor (using shift ). We create an Apache::Request object so that we can obtain the ISBN of the first book passed in with $r->param( first ) . This first ISBN tells us which book to start displaying ”the first book ( first is 1), the eleventh book ( first is 11), etc.

We then see this code:

 # set the number of books to 10, and  # grab the value posted with first  # (defaulted to 0)  my $NUM_BOOKS = 10;  my $first_book = $r->param(first)  0; 

The variable $NUM_BOOKS is assigned the value 10. This variable specifies the number of books to show at one time. A different number could be shown by choosing something else; it's not too difficult to imagine that this number might be chosen by the client as a parameter, but this is left as an exercise for the student. The variable $first_book has the value of the first parameter passed in. This value is the first book in the list of books shown, and it defaults to 0. We do this because we only want to show ten books per page; otherwise , our table would show 70-plus books ”way too many!

 # set the content type and send the header  $r->content_type(text/html);  $r->send_http_header();  # print the initial HTML  $r->print(top_html()); 

Next, the content type is set to text/html , the header is sent, and the top_html() function is called to build the HTML for the top and left rail; then that HTML is printed.

Now comes the code where we connect to the database, and we get all the ISBNs:

 # connect to the database, and if the connect fails,  # call handle_error(), returning what it returns: OK  my $dbh = DBI->connect(DBI:mysql:books, apache, LampIsCool)          or return handle_error($r, Connecting to db failed..                                 DBI->errstr());  # first, we need to get all the ISBN numbers so we can  # grab our page of them - # prepare the SQL, handling the error if failure - we are  # passing handle_error the variable $dbh so it can disconnect  # from the database  my $sth = $dbh->prepare(SELECT isbn FROM book_information                          ORDER BY isbn)          or return handle_error($r, Prepare of SQL failed .                                 $dbh->errstr(), $dbh);  # execute the query, and it if fails, call handle_error()  # passing it $dbh and $sth  $sth->execute()          or return handle_error($r, Execute failed . $sth->errstr(),                                 $dbh, $sth);  # declare some variables  my(@isbn) = ();  my($isbn);  # fetch each row  while (($isbn) = $sth->fetchrow()) {      push @isbn, $isbn;  } 

The first DBI action is to connect to the database and retrieve the ISBNs for all the books. All of them are stored in @isbn so that a range of them (such as numbers 30 “39) can be grabbed.

 # this code prints the navigation for all the pages  # of book information - the HTML will resemble:  #     1  2  3  4  5  # if the user clicks on `3, they will go to the third  # page of books  my $bgcolor = ;  for (my $i = 0; ($i+1)*$NUM_BOOKS <= $#isbn; $i++) {      if ($i != 0) {          $r->print();      }      if ($i * $NUM_BOOKS == $first_book) {          $r->print($i + 1);      } else {          $r->print("<a href=\"/booklistingmysql?first=",                    $i*$NUM_BOOKS, "\"><font color=\"#999966\"><b>",                    $i + 1, "</b></font></a>");      }  } 

This code prints the HTML for page navigation. These will be links to other pages of book information (such as books 1 “10, 11 “20, etc.). In this example, we query the database for the ISBNs, loop through the result of the query, and push each ISBN onto @isbn .

 # print the intial HTML for our table      $r->print(<<EOHTML);  <table border="0" cellspacing="0" cellpadding="3">  <tr><th>Title</th><th>Author(s)</th><th>Price</th></tr>  EOHTML 

The beginning of the table that holds the information for all the books can be printed, and all the book information for this range (such as 1 “10) processed :

 # this for loop loops through this page of book information - # if the user has asked for the 3rd page of books, we start  # with book 30 (0-based) in @isbn and loop for 10 books  for (my $i = $first_book;              $i < ($first_book + $NUM_BOOKS) and $i <= $#isbn;                    $i++) {      # get this book's information based on the ISBN      $sth = $dbh->prepare(SELECT title, author, price                           FROM book_information WHERE isbn = ?)           or handle_error($r, Prepare of SQL failed .                           $dbh->errstr(), $dbh);      $sth->execute($isbn[$i])      or handle_error($r, "</table>Execute failed: $isbn[$i]" .                      $dbh->errstr(), $dbh, $sth);  # fetch the title, author, price from the row  my($title, $author, $price) = $sth->fetchrow();  # this sets the background color for the row - # even rows are grayish, odd rows are white - # this makes reading easier  if ($i%2==0){      $bgcolor = #DDDDDD;          } else {              $bgcolor = #FFFFFF;          }          # print the HTML for the row          $r->print(<<EOHTML);  <tr bgcolor="$bgcolor"><td valign="top"><i>  <a href="/bookdetailmysql?isbn=$isbn[$i]"><font  color="#999966"><b>$title</b></font></a></i></td>  <td>$author</td><td valign="top">$$price</td></tr>  EOHTML      } 

It first prints the HTML for the beginning of the table. Then, for each of the books in the range of the array requested , the database is queried for each book's specific information, and the HTML to build a table row for each book is printed. For readability, we set the row background colors to alternate between gray and white.

 # print the end of the table of books      $r->print("</table>");      # print the last of the HTML      $r->print(bottom_html());      # finish, disconnect, return      $sth->finish();      $dbh->disconnect();      return OK;  } 

Finally, the script finishes the necessary HTML for the table and the bottom of the page, finishes the statement handle, disconnects from the database, and returns OK .

To configure Apache to use this new module, add the following to httpd.conf :

 <Location /booklistingmysql>    SetHandler        perl-script    PerlHandler       BookListingMysql  </Location> 

Then restart Apache with /etc/init.d/httpd graceful . To view the page, go to http://localhost/booklistingmysql/ or www.opensourcewebbook.com/booklistingmysql/. If all works well, you should see a page that resembles Figure 8.8. Feel free to cruise through the different groups of books by clicking on the link to page 2 or 3 or any others.

Figure 8.8. Book listing using MySQL and mod_perl


Notice that for each book there is a link to that book's details; now look at the code for generating the details. This code is in /var/www/mod_perl/BookDetailMysql.pm . To see the complete code, look at either http://localhost/mysql/BookDetailMysql.pm or www.opensourcewebbook.com/mysql/BookDetailMysql.pm.

First, at the top, is the appropriate package name:

 package BookDetailMysql;  # file: BookDetailMysql.pm 

Then, of course, there is the database interface module, among other use pragmas:

 use DBI; 

The same as in BookListingMysql.pm , there is an identical error-handling function:

 ####  #  # handle_error() - subroutine to handle any errors  #  ####  sub handle_error {      my $r     = shift;      my $msg   = shift;      my $dbh   = shift;      my $sth   = shift;      $r->print(<<EOHTML);  ERROR: <font color="#ff0000">$msg</font>  EOHTML      print bottom_html();      $sth->finish() if $sth;      $dbh->disconnect() if $dbh;      return OK;  } 

The top and bottom HTML is generated in top_html() and bottom_html() , as before.

As usual, the handler() function is where the action is. First, here is the top of the function:

 ####  #  # handler()  #  ####  sub handler {      # shift the argument and pass it into      # the new() method in the Apache::Request      # class      my $r = new Apache::Request(shift);      # get the ISBN number posted      my $isbn = $r->param(isbn)  0; 

Again, the request object is obtained first. The variable $isbn is set to the ISBN sent to the program through the isbn parameter (which defaults to 0). This variable is the book for which the detailed information will be displayed.

 # set the content type and send the header      $r->content_type(text/html);      $r->send_http_header();      $r->print(top_html()); 

The content type is sent, the header sent, and the beginning HTML printed. These are the goods:

 # connect to the database, and if the connect fails,  # call handle_error(), returning what it returns: OK  my $dbh = DBI->connect(DBI:mysql:books, apache, LampIsCool)          or return handle_error($r, Connecting to db failed.                                 .DBI->errstr());  # prepare the SQL, handling the error if failure - we are  # passing handle_error the variable $dbh so it can disconnect  # from the database  my $sth = $dbh->prepare(SELECT                          author, title, publisher, date, description,                          price FROM book_information WHERE isbn = ?)          or return handle_error($r, Prepare of SQL failed .                                 $dbh->errstr(), $dbh);  # execute the query, and if it fails, call handle_error()  # passing it $dbh and $sth  $sth->execute($isbn)          or return handle_error("Execute failed for $isbn" .                                 $sth->errstr(), $dbh, $sth);  # fetch the row and assign the data to some variables  my($author, $title, $publisher, $date, $desc, $price)                        = $sth->fetchrow(); 

This time, after connecting to the database, the script creates a query asking for the record of the ISBN passed in and executes the query. The correct row is fetched and the data stored in variables.

 # this if is one way to check to see that we recevied some      # data from our query, if so, print the information,      # if not, call handle_error()      if (defined $author) {          $r->print(<<EOHTML);  <table align="center">  <tr><th align="left">ISBN</th><td>$isbn</td></tr>  <tr><th align="left" valign="top">Author</th><td>$author</td></tr>  <tr><th align="left" valign="top">Title</th><td><i>$title</i></td></tr>  <tr><th align="left" valign="top">Publisher</th><td>$publisher</td></tr>  <tr><th align="left">Date</th><td>$date</td></tr>  <tr><th align="left" valign="top">Description</th><td>$desc</td></tr>  <tr><th align="left">Price</th><td>$$price</td></tr>  </table>   <hr>   Click  <a href="/booklistingmysql/"><font color="#999966"><b>here</b></font></a>  to find another book.  EOHTML      } else {          return handle_error($r, "ISBN $isbn not found...\n",                              $dbh, $sth);      } 

If the data is good ( $author is defined), the data is printed in a table; otherwise, the error is dealt with.

 # print the final HTML, finish, disconnect, return OK  $r->print(bottom_html());  $sth->finish();  $dbh->disconnect();  return OK; 

Exiting gracefully, the script prints the last bit of HTML to finish the page and returns OK so that Apache will move on to the next thing.

We need to tell Apache about the new modules, so add the following to the Apache configuration file /etc/httpd/conf/httpd.conf , and reload the new configuration file:

 <Location /bookdetailmysql>    SetHandler        perl-script    PerlHandler       BookDetailMysql  </Location> 

To see this code, drill down on one of the book titles. If you click the first title, Open Source Web Development with LAMP , you should see Figure 8.9.

Figure 8.9. Book detail using MySQL and mod_perl


We also want to add some functionality to the normal logging phase of Apache. In our example, we want to not only log but also send an e-mail each time either the /booklistingmysql/ or /bookdetailmysql/ page is hit. We need to write a module to do the work. Of course, the server must be configured to use the new module.

Create the file /var/www/mod_perl/BookMailLog.pm . Its contents can be found at http://localhost/mod_perl/BookMailLog.pm or www.opensourcewebbook.com/mod_perl/BookMailLog.pm. The beginning of the file looks like our other examples. The handler() method begins as follows:

 sub handler {      # shift the argument into $r      my $r = shift;      # get the date/time      my $date         = localtime();      # get some of the request information      # including the remote host, the URI,      # the query string and the number of      # bytes sent to the mod_perl program      my $remote_host  = $r->get_remote_host();      my $uri          = $r->uri();      my $query_string = $r->query_string();      my $num_bytes    = $r->bytes_sent(); 

To e-mail information about the request, the program calls some rather obvious methods to learn about who and what asked for the information. These methods include get_remote_host() ; uri() ; query_string() (gets the query string ”all the stuff after the " ? "); and bytes_sent() .Most of these are self-explanatory.

 # check for nastiness - first, is  # the query string too big?  if (length($query_string) > 100) {      return OK;  }  # now check to see if the query string  # contains anything besides word characters  # ([a-zA-Z0-9_]) or the equal sign (=)  unless ($query_string =~ /  ^  [\w=]+$/) {      return OK;  } 

The program then does something crucial ”it does a sanity check of the data to make sure someone is not calling our program with less than honorable intent. First it checks that the query string is not huge ”this makes sure a devious person hasn't invoked this program with a giant query string (which could fill our e-mail spool file quite rapidly ). Then it checks to see that the query string contains valid characters (word characters or " = "). These measures are important! Even though we haven't put them in every example we've discussed, every working program you write should contain similar precautions .

 # ok so far, so open a pipe to sendmail,      # and if it opens ok, send text into it -     # this text will be an e-mail message      if (open SM,  /usr/sbin/sendmail -t -oi) {          print SM <<EOM;  From: mod_perl email log <log\@opensourcewebbook.com>  To: log_notification\@opensourcewebbook.com  Subject: Book Listing/Detail was accessed  Time:              $date  Remote host:       $remote_host  Requested URI:     $uri  Query string:      $query_string  Bytes transferred: $num_bytes  EOM          close SM;      }      return OK;  }  1; 

If everything is fine with this data, the program opens a pipe into the sendmail program. A pipe is a connection to a program, such as sendmail, that reads from its standard input. [7] For this application, sendmail is invoked with a few options: -t means the To: list will be read from the header, and -oi means that lines with a single period on them will not terminate the input. The program then builds the mail header, followed by an all-important blank line, before the body of the e-mail is built. The e-mail is sent to the recipient(s) when the pipe is closed ( close() ).

[7] Sendmail is an Open Source program that comes with Linux that manages e-mail. It happens to be the program that routes more than 75 percent of all the e-mail on the Internet, so we are happy to use it here.

Apache needs to be configured to use this code during the logging phase. To do that, the /booklisting/ and /bookdetail/ sections of /etc/httpd/conf/httpd.conf must be modified to have the following content:

 <Location /booklisting>    SetHandler        perl-script    PerlHandler       BookListing    PerlLogHandler    BookMailLog  </Location>  <Location /bookdetail>    SetHandler        perl-script    PerlHandler       BookDetail    PerlLogHandler    BookMailLog  </Location> 

The addition of the PerlLogHandler lines tells Apache to use this module during the logging phase. Normally, the logging phase simply logs to the access_log file, but we are adding functionality to send an e-mail. We can add just about anything in the logging phase: log to another file, execute system programs, or add data to an SQL database.

Remember to reload the new Apache configuration file by restarting the server. When this page is requested, e-mail is sent. Here is an example of what might be sent by this program:

 From apache@www.opensourcewebbook.com Wed Jun 20 16:13:48 2001  Date: Wed, 20 Jun 2001 16:14:19 -0500  From: mod_perl email log <log@opensourcewebbook.com>  To: log_notification@opensourcewebbook.com  Subject: Book Listing/Detail was accessed  Time:              Wed Jun 20 16:14:19 2001  Remote host:  Requested URI:     /bookdetail  Query string:      isbn=1565922433  Bytes transferred: 720 

Having an e-mail sent every time a page is requested is a questionable activity for a web page that is accessed often. Imagine, if your web page were hit a million times a day, you would get a million e- mails telling you so! Of course, if your web page had a million hits a day, you could make some serious money selling banner ad space to all those companies that like to advertise on the Web (fewer today than in years past, we are sorry to say ”or are we?). So, if a web page gets a lot of traffic, don't send yourself e-mail. This is just an example of what's possible.

Open Source Development with Lamp
Open Source Development with LAMP: Using Linux, Apache, MySQL, Perl, and PHP
ISBN: 020177061X
EAN: 2147483647
Year: 2002
Pages: 136

Similar book on Amazon

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net