- Mastering MeteorJS Application Development
- Jebin B V
- 3411字
- 2021-07-23 15:00:36
Generators for the application
The time has come to use the generators. We will follow the same flow as the previous chapter. We will develop the create travel section first and then the listing, followed by the search and finally the reservation.
Creating travel
Run the iron g:scaffold createTravel
command and you will see the following files created and updated:
created app/lib/collections/create_travel.js created app/client/templates/create_travel created app/client/templates/create_travel/create_travel.html created app/client/templates/create_travel/create_travel.js created app/client/templates/create_travel/create_travel.css created app/lib/controllers/create_travel_controller.js updated ../BookMyTravel2/app/lib/routes.js updated ../BookMyTravel2/app/server/publish.js
The generator has created a collection (create_travel.js
) and a publication (in publish.js
), which are not needed. We can ignore them. However, we will use the other components generated. Along with these files, we need a different layout for the creation screen. Let's create one by adding the create_travel
directory inside the layouts
directory. Add create_travel_layout.html
inside the directory and add the following code to it:
<template name="CreateTravelLayout"> <div class="create-container"> <header class="header"> <h1> {{#linkTo route="home"}} BookMyTravel2 {{/linkTo}} </h1> <ul class="nav nav-pills"> <li>{{#linkTo route="home"}}List{{/linkTo}}</li> </ul> </header> <section class="create-container__section"> {{> yield}} </section> <footer class="footer">Copyright @BookMyTravel2</footer> </div> </template>
We will add this layout to the CreateTravelController
in create_travel_controller.js
. Add the following piece of code as a property along with the subscriptions:
layoutTemplate: 'CreateTravelLayout',
Now, go to http://localhost:3001/create_travel
and you will find that the layout reflects in the screen. Let's create the collection and then the form to create the documents in the collection. Run the following generator command to generate the busservice
collection:
iron g:collection busservice
This command will create a busservice.js
file in app/lib/collections
. Go visit this file and you will find the busservice
collection creation code as follows:
Busservice = new Mongo.Collection('busservice');
Followed by this line, there will be allow
and deny
methods that give us the ability to perform Role-Based Access (RBA) checks. Say, for example, if you want to deny a guest user from creating a bus service, you can do the check in the deny
method's insert
part and return true
if positive. Doing so will deny the document insertion in the collection. You can use these methods to do extensive RBAC for your application. The skeleton is self-explanatory to explain the parameters we will get inside each operation. These insert
, update
, and remove
operation pre-handlers will be executed before doing the respective operations via the collection instance (in our case Busservice
). Different applications need different kinds of RBA conditions and so I leave this portion to you. Do not forget to make use of this in-built feature of MeteorJS as it is one of the important security features. We can perform authorization checks extensively using these handlers.
It is time to define the schema. If we cannot keep track of schema in a well-structured format, then it is going to be a big pain for maintenance. MeteorJS doesn't have built-in ways to create or define the schema. However, we have packages that can help us do this. We are going to use the aldeed: collection2
package, which will help us to attach a schema and do validation if required. The Git repo https://github.com/aldeed has some important packages that are very useful for development. Add the package by running the following command:
iron add aldeed:collection2
The schema for the busservice
collection is as follows:
// Validation keys and messages SimpleSchema.messages({ source_destination_same: "[label] cannot be same as Starting point", destination_source_same: "[label] cannot be same as Destination point", endDateTime_lessthan_startDateTime: "[label] cannot be past to start date and time", startDateTime_lessthan_endDateTime: "[label] cannot be past to start date and time" }); //Schema for busservice collection BusServiceSchema = new SimpleSchema({ name:{ type: String, label: "Name", max: 200 }, agency:{ type: String, label: "Agency", max: 1024 }, seats: { type: Number, label: "Total Seats", min: 10, max: 50 }, source: { type: String, label: "Starting Point", max: 200, custom: function() { if((this.value || "").toLowerCase() == (this.field("destination").value || "").toLowerCase()) { return "destination_source_same"; } } }, destination: { type: String, label: "Destination Point", max: 200, custom: function() { if((this.value || "").toLowerCase() == (this.field("source").value || "").toLowerCase()) { return "source_destination_same"; } } }, startDateTime: { type: Date, label: "Departure Time", min: moment().add(1, "days").toDate(), max: moment().endOf("year").toDate(), custom: function() { if(this.value >= this.field("startDateTime").value) { return "startDateTime_lessthan_endDateTime"; } } }, endDateTime: { type: Date, label: "Arrival TIme", min: moment().add(1, "days").toDate(), max: moment().endOf("year").toDate(), custom: function() { //custom validation if(this.value <= this.field("startDateTime").value) { //error message identifier added in SimpleSchema.messages api. return "endDateTime_lessthan_startDateTime"; } } }, fare: { type: Number, label: "Fare", min: 100 }, createdAt: { type: Date, label: "Created At", autoValue: function() { if (this.isInsert) { return new Date; } } }, updatedAt: { type: Date, label: "Updated At", autoValue: function() { if (this.isUpdate) { return new Date(); } }, denyInsert: true, optional: true }, available_seats: { type: Number, label: "Available Seats", autoValue: function(doc) { if (this.isInsert) { return doc.seats; } } }, createdBy: { type: String, optional: true, autoValue: function() { return this.userId } } }); Busservice.attachSchema(BusServiceSchema);
Add the preceding code after the busservice
collection instantiation. The code is self-explanatory. For more details on the meteor-collection2
package, visit https://github.com/aldeed/meteor-collection2. You will find a lot of useful information and methods that can help you to do a better job in schema definition and validation. We can define many kinds of validations right in the schema definition itself, and the error messages can be defined along with the schema. If you go through the schema definition, you can figure out the minimum and maximum validations for some fields and custom validation for the arrival date and time and the departure date and time, which compares the dates. Similarly, one can define any custom validation and can access the document data from the form inside the custom validation methods.
The best part of defining a schema is the form creation. The aldeed
repository has another package called AutoForm
, which can read schema and create forms without much effort. Let's install the AutoForm
package by running the following command:
iron add aldeed:autoform
Visit the AutoForm
package documentation at https://github.com/aldeed/meteor-autoform. There is a lot of information to craft your forms carefully with plenty of options.
Inside the create_travel.html
file in client/templates/create_travel
, add the following template code:
{{> quickForm collection="Busservice" id="CreateBusServiceForm" type="insert" omitFields="createdBy, updatedAt, createdAt, available_seats" buttonContent="Create"}}
Visit the browser and you will find that the form is created with less effort. In the preceding code, we have just specified the collection name, which is the instance we created (not the mongo collection name). Along with that, we have specified id
, type
. There is an attribute omitFields
that facilitates to omit fields that we don't want to show in the form. The buttonContent
attribute value will appear in the submit button of the form. A single line has created a form for us. Pretty timesaving, isn't it?
The styles are missing. Add the twbs:bootstrap
package to the application. This will add styles to some extent. The forms generated by AutoForm
are bootstrap complaints with bootstrap-related classes in the markups. We will add the rest of the styles from the previous chapter. Copy the custom styles we had created in the previous chapter to the main.css
file of this application. Now, the form is usable, but there are some more details we need to understand.
The schema that we had defined has the Date
type for the fields for arrival time, departure time, createdAt
, and updatedAt
, which you might have noticed. They are Date
and not DateTime
. There is no DateTime
type and so the AutoForm
package will also generate forms with the appropriate fields having the Date
type and not Datetime
. In our case, the arrival time and departure time must be Datetime
and not just Date
. So, we cannot use the quickform
component as it is. To solve this problem, when we need customization in the form, the AutoForm
package has given us ways to define the customized form in compliance with the schema. Replace {{quickform ...}}
with the following code:
{{#autoForm collection="Busservice" id="CreateBusServiceForm" type="method" class="container" meteormethod="createBusService"}} {{> afQuickField name='name'}} {{> afQuickField name='agency'}} {{> afQuickField name='seats' min="10" max="50"}} {{> afQuickField name='source'}} {{> afQuickField name='destination'}} {{> afQuickField name='startDateTime' type="datetime-local"}} {{> afQuickField name='endDateTime' type="datetime-local"}} {{> afQuickField name='fare'}} <button class="btn btn-lg btn-primary btn-block" type="submit">Create</button> {{/autoForm}}
Instead of using quickForm
, we are using the autoForm
and afQuickField
components to create our form. You can learn the syntax and other possible attribute options from the AutoForm
package documentation. What we have done here is a minimal usage. However, a notable thing is type
and meteormethod
. We have specified the type of the form to be method
and when we use method as type, it is mandatory to mention the method name in the meteormethod
attribute. What this means is, on submit, the meteor method createBusService
will be called by passing all the field values. The afQuickField
component takes the names that must be the keys defined in the schema and other form field attributes. We needed a datetime
input field and thus we are using the type="datetime-local"
attribute in the appropriate fields.
Another important feature of the AutoForm
package is validation and error display. Based on the schema definition, the form values are validated and an appropriate error message is displayed right below the fields. Try wrong inputs in the fields and submit the form. You will see the error messages below the fields. We don't need to wire the error handling and the error message display. They are taken care by the package itself. Also, AutoForm
provides hooks to perform any pre or post-submission operations, if necessary.
All the client-related work is done and we have to define the server method to insert the form. In our case, we just want to validate and insert the document. Create the createBusService
method in app/server/methods.js
as follows:
Meteor.methods({ createBusService: function(busService) { busService.createdAt = new Date(); busService.available_seats = parseInt(busService.seats, 10); check(busService, BusServiceSchema); //validates the form data against the schema in the server side Busservice.insert(busService); } });
If all goes well, you will be able to save the data to the collection. Check in the database to confirm.
Listing and search
Next is the listing part. Visit http://localhost:3000
and you will find the text Find me in app/client/templates/home/home. Our home page is going to be the listing and search page. Let's start adding the layout, then the list template, and wire the data. Things start from the route. Our route for home page points to HomeController
and action
method. Check the HomeController
and you will find that the layout is MasterLayout
and the action
method calls the render
of the Home
template.
In master_layout.html
, add the following code that builds the two columns layout for our home page:
<template name="MasterLayout"> <div class="home-container"> <header class="header"> <h1> {{#linkTo route="home"}} BookMyTravel2 {{/linkTo}}</h1> <ul class="nav nav-pills"> <li> {{#linkTo route="createTravel"}} Create {{/linkTo}} </li> </ul> </header> <section class="home-container__section"> <div class="home-container__section__left container-fluid"> {{> yield region="search"}} </div> <div class="main"> {{> yield}} </div> </section> <footer class="footer">Copyright @BookMyTravel2</footer> </div> </template>
This will create the two column layout. For maintainability purpose, instead of using the Home
template from home.html
, we will create the BusServiceList
template using the generator and call it in the Home
template. Run the following command in the root directory of the application:
iron g:template BusServiceList
The command will create the bus_service_list
directory inside app/client/templates
. We will use the bus_service_list.html
file to define our listing template. Add the following code inside the template tag:
<div class="container bus-list"> <div class="row bus-list__row bus-list__header"> <div class="bus-list__row__col col-md-3">Bus</div> <div class="bus-list__row__col col-md-1">Available seats</div> <div class="bus-list__row__col col-md-1">Start point</div> <div class="bus-list__row__col col-md-1">End point</div> <div class="bus-list__row__col col-md-2">Start time</div> <div class="bus-list__row__col col-md-2">Reaching time</div> <div class="bus-list__row__col col-md-1">Fare</div> <div class="bus-list__row__col last col-md-1">Book</div> </div> <div class="row bus-list__body"> {{#if hasItem}} {{#each list}} <div class="bus-list__row"> <div class="bus-list__row__col col-md-3">{{name}} <br />{{agency}}</div> <div class="bus-list__row__col col-md- 1">{{available_seats}}/{{seats}}</div> <div class="bus-list__row__col col-md-1">{{source}}</div> <div class="bus-list__row__col col-md-1">{{destination}}</div> <div class="bus-list__row__col col-md-2">{{humanReadableDate startDateTime}}</div> <div class="bus-list__row__col col-md-2">{{humanReadableDate endDateTime}}</div> <div class="bus-list__row__col col-md-1">{{fare}}</div> <div class="bus-list__row__col last col-md-1"> <a href="/book/{{_id}}">Book</a></div> </div> <div class="clear"></div> {{/each}} {{else}} <div class="row bus-list__row bus-list__row-empty"> <div class="bus-list__row__col last col-md-12 text-center"> No buses found</div> </div> {{/if}} </div> </div>
We have used some helper methods inside the template. Let's define them inside the bus_service_list.js
file. Replace the helper's skeleton with the following code:
Template.BusServiceList.helpers({ list: function() { return this.get(); }, hasItem: function() { return this.get().count(); }, humanReadableDate: function(date) { var m = moment(date); return m.format("MMM,DD YYYY HH:mm"); } });
One last thing to do is call the BusServiceList
template in the Home
template. Go to Home
in home.html
and replace the content with the following code:
<template name="Home"> {{> BusServiceList}} </template>
An empty list will be visible in the browser by this time. We have to wire the data. The busservice
collection has to be published first. Let us use the generator itself to create the publication. Run the following command in the terminal:
iron g:publish busservice
This will add the publication code to publish.js
in app/server
. However, we need a slight modification here. Our publication should publish data, by default, in sorted order. So, let us change the return statement of the publication as follows:
return Busservice.find({}, {sort: {createdAt: -1}});
We have a proper place to subscribe the data. In HomeController
, we have the subscriptions
method where we can subscribe the data by name. Add the following subscription line of code to the subscriptions
method:
this.subscribe("busservice", {});
Along with this, we will do the search as well. To give a small recap, because we are going to filter the same collection in the list template using the values from search template, we have used reactive variables. Whenever there is a search value, we filter the collection and update the reactive variable, which will update the list as per the search.
We will generate search-related templates and helpers using the generator. Run the following command:
iron g:template search
This will generate the search related files in the search
directory under app/client/templates
. In the Search
template inside search.html
, replace the existing content with the following code:
<div class="col-xs-12 col-sm-12 col-md-12 text-center top-space well well-sm">Search</div> <div class="col-xs-12 col-sm-12 col-md-12 well well-sm"> <div class="form" id="search-form"> {{#autoForm collection="Busservice" id="SearchBusServiceForm"}} {{> afQuickField name='source'}} {{> afQuickField name='destination'}} {{> afQuickField name='startDateTime' type="date"}} {{> afQuickField name='fare'}} {{/autoForm}} </div> </div>
The template is ready. To render this search template to the search
region in MasterLayout
, add the following code to the action
method in HomeController
:
this.render('Search', {to: 'search'});
This will render the Search
template to the search region. Visit the browser and you will see the search form.
We have to introduce the reactive variable. To use reactive variables, we have to install the reactive-var
package. Run the following command to add the package:
iron add reactive-var
Add the following initialization code to the beginning of the action
method:
this.ReactiveBusServices = new ReactiveVar([]);
The controller supports the data
method such as the subscriptions
method. In the data
method, we can prepare and format data that will be passed to the template to render. Add the following data preparation code to the HomeController
:
data: function() { this.ReactiveBusServices.set(Busservice.find({})); return this.ReactiveBusServices; },
From the code, you can figure out that we set the Busservice
collection to the reactive variable we had created in the preceding snippet and then return the reactive variable. Visit the browser and you will find the list of services you had created earlier.
It is time to put the search in place. In search.js
, inside the app/client/templates/search
directory, replace the events skeleton with the following code:
Template.Search.events({ "keyup input": _.throttle(function(e) { var source = $("[name='source']").val().trim(), destination = $("[name='destination']").val().trim(), date = $("[name='startDateTime']").val().trim(), fare = $("[name='fare']").val().trim(), search = {}; if(source) search.source = {$regex: new RegExp(source), $options: "i"}; if(destination) search.destination = {$regex: new RegExp(destination), $options: "i"}; if(date) { var userDate = new Date(date); search.startDateTime = { $gte: userDate, $lte: new Date(moment(userDate).add(1, "day").unix()*1000) } } if(fare) search.fare = {$lte: parseInt(fare, 10)}; if(Template.instance()) { Template.instance().data.set( Busservice.find(search, {sort: {createdAt: -1}})); } }, 200), "submit": function(e) { e.preventDefault(); } });
In the input field's keyup
handler, we collect the form data, prepare them to be a proper search query, and filter the collection. Then, we set the filtered collection into the reactive variable. If you notice, Template.instance().data
is the reactive variable we had returned from the data
method of the HomeController
. Perform a search and you will find that things are working as expected. Finally, listing and search is done.
Reservation
The last part of the application is blocking and reserving seats. We need the reservations
and blocked_seats
collections to store the information. Let's use the generator to generate the collection. Run the following commands to generate the collections:
iron g:collection reservations iron g:collection blockedSeats
These commands will create two files, reservations.js
and blocked_seats.js
under app/lib/collections
. Each file has its own instantiation to the collections, respectively. We will define the schema to each of these collections as we did for the busservice
collection.
Add the following schema definition to blocked_seats.js
:
BlockedSeats.attachSchema( new SimpleSchema({ bus:{ type: String, label: "Bus", max: 200 }, seat:{ type: Number, label: "Blocked Seat" }, createdAt: { type: Date, label: "Created At", autoValue: function() { if (this.isInsert) { return new Date; } } }, updatedAt: { type: Date, label: "Updated At", autoValue: function() { if (this.isUpdate) { return new Date(); } }, denyInsert: true, optional: true }, createdBy: { type: String, optional: true, autoValue: function() { return this.userId } } }) );
Again, we can define validations if needed. I will leave that to you. Similarly, we will add the schema definition for reservations
collection as well. Add the following code to reservations.js
:
Reservations.attachSchema( new SimpleSchema({ bus:{ type: String, label: "Bus", max: 200 }, seats_booked:{ type: [Object], label: "Seats Booked", minCount: 1, maxCount: 10 }, "seats_booked.$.seat": { type: Number, optional: false }, createdAt: { type: Date, label: "Created At", autoValue: function() { if (this.isInsert) { return new Date; } } }, updatedAt: { type: Date, label: "Updated At", autoValue: function() { if (this.isUpdate) { return new Date(); } }, denyInsert: true, optional: true }, createdBy: { type: String, optional: true, autoValue: function() { return this.userId } } }) );
Now, we have to define the route to reach the reservation part. We will use the generator to create the route. Run the following command to generate the route:
iron g:route book
The generator adds a route for us in the routes.js
file and creates a BookController
, templates, and helpers. The new route generated is not what we needed. Let's modify it to the way we want it to be. Change the route path from book
to book/:_id
. The route clearly says we need _id
, which we will get from the listing. Also, we need the bus service information of the concerned bus, reservation information of the bus, and the blocked seats information of the bus. In the listing, we already have the link to the reservation page. Let's wire the proper data and create the templates to show the seating information and other required information.
We have to register the required publications first. As we did for the busservice
collection, we will use generators to generate the publications for the reservations
and blocked_seats
collections. Run the following commands one after the other to generate them:
iron g:publish reservations iron g:publish blocked_seats
To the generated publications, we need to do small modifications to pull the data of only the concerned bus service. Modify the code to look like the code as follows:
Meteor.publish('busservice', function (query) { query = query || {}; return Busservice.find(query, {sort: {createdAt: -1}}); }); Meteor.publish('reservations', function (query) { return Reservations.find(query); }); Meteor.publish('blocked_seats', function (query) { return BlockedSeats.find(query); });
Note that we have also modified the busservice
publication to accommodate the query parameter. Similarly, other publications will also get an object, based on which the data is published.
We will also use CreateTravelLayout
for the reservation page. Add the layoutTemplate
property to the controller and put the value as CreateTravelLayout
.
The next step is subscribing to these data. In BookController
, add the following piece of code to subscribe the data in the subscriptions
method:
this.subscribe('busservice', { _id: this.params._id }); this.subscribe('reservations', { bus: this.params._id }); this.subscribe('blocked_seats', { bus: this.params._id });
Next, we have to pass the data to the template. Add the following code to the data
method:
var templateData = { _id: this.params._id, bus: Busservice.findOne({ _id: this.params._id }), reservations: Reservations.find({ bus: this.params._id }).fetch(), blockedSeats: BlockedSeats.find({ bus: this.params._id }).fetch() }; return templateData;
This is the data that is available in the Book
template.
Let us create the UI to show the seating arrangement. Replace the content of the Book
template in book.html
under app/client/templates/book
, with the following piece of code:
<div class="container busView"> <div class="row text-center busView__title"> {{bus.name}}<br />{{bus.agency}} </div> <div class="row col-md-4 busView__seats"> <div class="col-md-12 busView__left"> {{#each seatArrangement}} <div class="col-md-12 row-fluid"> {{#each this}} <div id="seat{{this.seat}}" class="busView__seat {{blocked}} {{reserved}}"> {{this.seat}} </div> {{#if middleRow}}<div class="busView__divider col-md-offset- 3"></div>{{/if}} {{/each}} </div> {{/each}} </div> </div> <div class="row text-center busView__book"><button id="book" class="btn btn-primary">Book My Seats</button></div> </div>
We need some helpers and event handlers to handle the interactions. Replace the skeleton events and helper methods with the following set of code:
Template.Book.events({ "click .busView__seat:not(.reserved):not(.blocked)": function (e) { e.target.classList.add("blocked"); var seat = { bus: Template.currentData().bus._id, seat: parseInt(e.target.id.replace("seat", ""), 10) }; Meteor.call("blockThisSeat", seat, function(err, result) { if(err) { e.target.classList.remove("blocked"); } else { var blockedSeats = Session.get("blockedSeats") || []; blockedSeats.push(seat); Session.set("blockedSeats", blockedSeats); } }); }, "click #book": function() { var blockedSeats = Session.get("blockedSeats"); if(blockedSeats && blockedSeats.length) { Meteor.call("bookMySeats", blockedSeats, function (error, result) { if(result) { Meteor.call("unblockTheseSeats", blockedSeats, function() { Session.set("blockedSeats", []); }); } else { alert("Reservation failed"); console.log(error); } }); } else { alert("No seat selected"); } } }); Template.Book.helpers({ seatArrangement: function() { var arrangement = [], totalSeats = (this.bus || {}).seats || 0, blockedSeats = _.map(this.blockedSeats || [], function(item) {return item.seat}), reservedSeats = _.flatten(_.map(this.reservations || [], function(item) {return _.map(item.seats_booked, function(seat){return seat.seat;});})), tmpIndex = 0; Session.set("blockedSeats", this.blockedSeats); arrangement[tmpIndex] = []; for(var l = 1; l <= totalSeats; l++) { arrangement[tmpIndex].push({ seat: l, blocked: blockedSeats.indexOf(l) >= 0 ? "blocked" : "", reserved: reservedSeats.indexOf(l) >= 0 ? "reserved" : "", }); if(l % 4 === 0 && l != totalSeats) { tmpIndex++; arrangement[tmpIndex] = arrangement[tmpIndex] || []; } } return arrangement; }, middleRow: function () { return (this.seat % 2) === 0; } });
I don't have to explain this code as you will be familiar with it from the previous chapter. Now, you will be able to see the seating arrangement of the bus in the browser. The only leftover portion is server-side handling.
Add the server methods to methods.js
in app/server
. We need three server methods that are called from the template handlers. Add the following code to Meteor.methods
:
bookMySeats: function(reservations) { var insertRes = reservations.map(function(res) { return { seat: res.seat } }); return Reservations.insert({ bus: reservations[0].bus, seats_booked: insertRes }, function (error, result) { if(result) { Busservice.update({_id: reservations[0].bus}, { $inc: { available_seats: -insertRes.length } }, function() {}); } }); }, blockThisSeat: function(seat) { debugger; BlockedSeats.insert(seat, function(error, result) { console.log(error); if(error) { throw Meteor.Error("Block seat failed"); } else { Meteor.setTimeout(function() { BlockedSeats.remove({_id: result}); }, 600000);// 10 mins } }); }, unblockTheseSeats: function(seats) { seats.forEach(function (seat) { BlockedSeats.remove({_id: seat._id}); }); }
Now, you can block and reserve seats from the application. This not much, but what we have learned so far is pretty interesting, isn't it?
We have recreated the same old application in a much more maintainable way like a pro-developer. We have used advanced scaffolding techniques to build the application and have increases maintainability and predictability. Also, we have learned to secure database operations with the help of allow
and deny
methods available in the collection instances.
Is that all for this chapter? No. To become a real pro-developer, two more things are essential. One is debugging and another one is testing. We are going to cover both of them in this chapter.
- Visual Basic .NET程序設(shè)計(jì)(第3版)
- Android和PHP開(kāi)發(fā)最佳實(shí)踐(第2版)
- 工程軟件開(kāi)發(fā)技術(shù)基礎(chǔ)
- 軟件界面交互設(shè)計(jì)基礎(chǔ)
- 算法零基礎(chǔ)一本通(Python版)
- JavaScript語(yǔ)言精髓與編程實(shí)踐(第3版)
- Reactive Programming with Swift
- 跟小海龜學(xué)Python
- Java程序員面試算法寶典
- 快速念咒:MySQL入門(mén)指南與進(jìn)階實(shí)戰(zhàn)
- IBM Cognos Business Intelligence 10.1 Dashboarding cookbook
- Android驅(qū)動(dòng)開(kāi)發(fā)權(quán)威指南
- Django 3.0入門(mén)與實(shí)踐
- Learning Modular Java Programming
- Buildbox 2.x Game Development