π§πΎβπ»
prep
Overview description of the prep work for the sprint
β NodeJS
Learning Objectives
We know that that JavaScript is an interpreted language. Running it needs some interpreter to read our lines of code and execute them.
We’ve already seen that web browsers can run JavaScript. Web browsers provide a runtime environment for JavaScript.
NodeJS is another runtime environment for running JavaScript. It allows us to run JavaScript files from a terminal.
There are some similarities and differences between how NodeJS runs JavaScript, and how web browsers run JavaScript. For instance:
- Both support the same core language (e.g. defining variables, if statements, for loops, etc).
- Web browsers
expose π§Ά extra APIs that can be used from JavaScript, e.g. the DOM.π§Ά expose To expose an API means to provide functions or values to the programmer. Sometimes we expose these over the internet, using HTTP+JSON. Other times we expose them directly as symbols you can import into your program. - NodeJS exposes extra APIs that can be used from JavaScript, e.g. reading and writing files in the filesystem.
- Some APIs are implemented differently, e.g. if you call
console.log
in a web browser it will log to the web inspector console (hidden by default), whereas in NodeJS it will log to stdout (the default output of a program).
People use NodeJS so that they can run code they’ve written in a terminal. Some example reasons:
- Because they want to use NodeJS’s extra capabilities in their code (e.g. reading files).
- Because they want to use a JavaScript as part of a shell pipeline.
- Because they want their program to run for a long time on a server.
You’ve already written JavaScript programs and run them in the NodeJS runtime environment - every time you run a command like node index.js
or npm test
you’re running JavaScript with NodeJS.
Most of the programs you wrote and ran like this in the Introduction to Programming course were short-lived experiments (learning a concept and trying it out), or tests.
We’re going to start thinking about writing programs intended to be run like this.
π οΈ Writing a NodeJS program
Learning Objectives
Below we have a small NodeJS program. It is a bit like wc
. It counts words in a file which contain the letter e
.
Our program accepts one command line argument - the path of the file to read and count.
Our program’s output to stdout is just the number of words which contain an e.
Our program uses the same language (JavaScript) as we’ve written before, but uses some different APIs.
import process from "node:process";
import { promises as fs } from "node:fs";
const argv = process.argv.slice(2);
if (argv.length != 1) {
console.error(`Expected exactly 1 argument (a path) to be passed but got ${argv.length}.`);
process.exit(1);
}
const path = argv[0];
const content = await fs.readFile(path, "utf-8");
const countOfWordsContainingEs = content
.split(" ")
.filter((word) => word.includes("e"))
.length;
console.log(countOfWordsContainingEs);
Let’s play computer with this program - line by line:
import process from "node:process";
This import
is loading some code from somewhere that isn’t this file.
We’ve seen import
before. Here, instead of importing from a file we’ve written, we’re importing the process
module which is built into NodeJS.
This is an example of the same language features (import
) being used slightly differently (the "node:"
is a special prefix to say “specially from node”).
The process
module is built into NodeJS for managing our
import { promises as fs } from "node:fs";
We’re importing another module.
The fs
module is built into NodeJS for interacting with the filesystem.
This time, we’re not importing the whole module. We are
We can write const [first, second] = [3, 1];
to assign first = 3
and second = 1
.
We can write const {name, age} = {name: "Amir", age: 34};
to assign name = "Amir"
and age = 34
.
node:fs
module exposes an object, and we are saying “import the promises
property from the fs
module, and bind it to the name fs
”.
It is like writing import { promises } from "node:fs"; const fs = promises;
.
The fs
module exposes two alternate APIs with functions with the same name (like readFile
):
- The default
fs
module was written beforeasync
/await
was added to JavaScript, and requires using callbacks, which can be annoying. fs
has a submodule calledpromises
which can be used withasync
/await
, and is generally much more convenient.
We want to use the promises
submodule, because it’s much more convenient for us to use async
/await
. But if we just wrote import { promises } from "node:fs";
, we’d be binding the submodule to the name promises
, and everywhere we used it we’d need to write promises.readFile
. This is less clear than fs.readFile
, because promises
is a very general name. So we rename promises
to fs
, and can use it like fs.readFile
.
We are really doing this because we wish the async
/await
APIs were the default APIs exposed by the fs
module, and this lets us pretend that they are in the rest of our code.
π§ Think
fs
uses callbacks or promises because its operations are asynchronous.
Why would interacting with the filesystem (e.g. reading a file) be an asynchronous operation?
Explain on a Slack thread why you think this is. If you’re not sure, ask about it on Slack.
const argv = process.argv.slice(2);
We’re getting the argv
array from the process
module, and slicing it. We can see in the process.argv
documentation that process.argv[0]
will be the path to node
, and process.argv[1]
will be the path to this file. We don’t care about those, so we’ll skip them - as far as we’re concerned the arguments start at index 2.
Again, Array.slice
is exactly the same as we know from JavaScript, but process.argv
is a new API we can use to get the array we need.
Play computer with the rest of the program - read each line, and explain what you think that line does. After you make your predictions, expand the explanations below and compare them to your predictions.
if (argv.length != 1) {
console.error(`Expected exactly 1 argument (a path) to be passed but got ${argv.length}.`);
process.exit(1);
}
We always expect our program to be given exactly one argument. Here we check this using an `if` statement, just like we've seen before.if (argv.length != 1) {
console.error(`Expected exactly 1 argument (a path) to be passed but got ${argv.length}.`);
process.exit(1);
}
console.error
writes a message to stderr (which is where error messages should go).
process.exit
is a function which, when called, will stop our program running. Passing a non-zero number to it indicates that our program did not succeed. We can read more about it in the official NodeJS documentation for the process
module.
const path = argv[0];
const path = argv[0];
Giving a useful name to our argument.
const content = await fs.readFile(path, "utf-8");
const content = await fs.readFile(path, "utf-8");
Reading the file at the path passed as an argument. We’re using the fs
module here from node
, but everything else is just JavaScript - declaring a variable, using await
because fs.promises.readFile
is an async
function, calling a function.
You can read more about this in the documentation for fs.promises.readFile
.
const countOfWordsContainingEs = content
.split(" ")
.filter((word) => word.includes("e"))
.length;
const countOfWordsContainingEs = content
.split(" ")
.filter((word) => word.includes("e"))
.length;
Just some regular JavaScript. Taking a string, splitting it into an array, filtering the array, searching strings to see if they contain any e characters, and getting the length of an array.
console.log(countOfWordsContainingEs);
console.log(countOfWordsContainingEs);
console.log
in a NodeJS environment logs to stdout, so this outputs our result to stdout.
Exercise
Save the above program into a file. Run the file with node
, and count how many words contain “e"s in a few different files.
If you run into problems, ask for help.
π Using dependencies from npm
Learning Objectives
We’ve seen that we can use code that was built into NodeJS - we don’t need to write everything ourselves.
We can also use code that other people have written, which isn’t built into NodeJS. You’ve probably seen this before, e.g. using jest
for testing.
This can be really useful - it means we can benefit from work others have already done, and focus on just solving the part of a problem which is unique to us. It’s like making shell pipelines - instead of having to solve every problem from scratch, we can plug together different tools that other people have already made.
Let’s expand the functionality of our program. Rather than always searching for words containing the letter e, let’s allow the user to specify what character they’re searching for.
This means we want to introduce a flag. And programs that accept flags, should also document themselves. One common convention is that if you run a program with the flag --help
, it will tell you how to use it.
But writing all of this code to parse flags, to output information about the flags, and so on, is a lot of work.
So let’s use a commander
.
Exercise
Add import { program } from "commander";
to the top of your e-word-counting program.
This line imports the program
property from the object which is the commander
library (using object destructuring).
Try running your program. What happens? What does the output mean?
π Installing dependencies with npm
Learning Objectives
To use a library, we need to fetch the code we’re going to use. When using NodeJS, we use a tool called npm
for this.
First we need a package.json
file - this a file that npm
will read to understand your project. This is the same as the package.json
file you’ve seen when using npm
in the past.
Make this package.json
file in the same directory as your e-word-counting program:
{
"type": "module"
}
The package.json
contains a JSON object with information about your project. For now, we’re just telling npm
that our project is a module - this means we are allowed to use import
in our program.
From a terminal which is cd
’d to the same directory as your package.json
file, run npm install commander
.
This command does two things:
- Look in your
package.json
file - notice that now has adependencies
section listingcommander
. This means that if someone else downloads your program, they know they need to installcommander
to use it. - There’s now a
node_modules
directory alongside yourpackage.json
. Inside that is a directory namedcommander
which contains the code for thecommander
library. This meansnode
now knows how to find the code when you try to import it.
Exercise
Try running your program again.
What has changed since the last time you tried to run it (and it didn’t work)?
What has changed since the last time you successfully ran it?
Now that we have commander
installed, let’s try using it in our program:
import { program } from "commander";
import { promises as fs } from "node:fs";
import process from "node:process";
program
.name("count-containing-words")
.description("Counts words in a file that contain a particular character")
.option("-c, --char <char>", "The character to search for", "e");
program.parse();
const argv = program.args;
if (argv.length != 1) {
console.error(`Expected exactly 1 argument (a path) to be passed but got ${argv.length}.`);
process.exit(1);
}
const path = argv[0];
const char = program.opts().char;
const content = await fs.readFile(path, "utf-8");
const countOfWordsContainingChar = content
.split(" ")
.filter((word) => word.includes(char))
.length;
console.log(countOfWordsContainingChar);
Exercise
Try running this program with the --help
flag.
What do you see? Where do you think this behaviour and text came from?
We didn’t have to write all the code for this functionality - commander
did most of it for us.
Exercise
Try running the program with different values of the -c
flag. Try also specifying some other flags, like --count
.
Make sure you understand how it’s behaving, and why.
Let’s run through what we changed:
program
.name("count-containing-words")
.description("Counts words in a file that contain a particular character")
.option("-c, --char <char>", "The character to search for", "e");
We told commander
information about our program. We gave it a name, a description, and told it that it should allow a user to pass a flag name -c
(or equivalently --char
), and use a default value of -
for that flag if it’s not specified.
program.parse();
We asked commander
to interpret the command line arguments our program was given, based on what options we wanted to allow. If it sees something it doesn’t understand, it will error.
const argv = program.args;
Instead of asking NodeJS’s process module for all of the program’s arguments, we’re asking commander
to tell us “after you understood and removed all the flags, what arguments were left?”
Then our if
check about the number of arguments is exactly the same as before.
const char = program.opts().char;
We are getting the char
flag that commander
interpreted and storing it in a variable.
const countOfWordsContainingChar = content
.split(" ")
.filter((word) => word.includes(char))
.length;
console.log(countOfWordsContainingChar);
We have renamed our countOfWordsContainingEs
variable to countOfWordsContainingChar
because we’re no longer always looking for hyphens, and changed the includes
call to look for the value of the char
variable instead of always an e
.
We only needed to make a few small changes to get all of this new functionality:
- Support for accepting a new command line flag.
--help
support explaining how to use the program.- Detection for if someone passes flags that aren’t known, and warning them about this (and even suggesting what they maybe meant).
We could have written all of this code ourselves. But using a library meant we could focus on what’s unique about our problem, rather than spending time implementing flag parsing.
This is a very common task in software development in the real world, joining together libraries (written by other people) to create some new unique solution.
πNote
We also could have used the builtin util.parseArgs
function from NodeJS for most of this functionality, but it doesn’t support --help
like commander
does.
π» Operating systems
Learning Objectives
Reading
Read chapter 10 of How Computers Really Work.
Do every exercise listed in the chapters.
You only need to do the projects listed below (though are welcome to try any others that you want!)
Check you have achieved each learning objective listed on this page.
Project
Do project 23 from How Computers Really Work.
You can do this on any
- you do not need a Raspberry Pi.
Project
Do project 20 from How Computers Really Work.
You can do this on any Unix-family OS - you don’t need a Raspberry Pi.
Note: If you’re on macOS, ps -eH
doesn’t exist. You can use ps
or ps aux
to get a list of processes. To get parent-child relationships, you’ll need to install pstree
using brew
(brew install pstree
), then run pstree
.
Note: If you’re on macOS, process 1 will probably be launchd
not init
.
Project
If you’re on a Linux machine, do projects 21, 22, and 24.
If you’re on macOS, pair up with someone who has a Linux machine to do these projects.
Note: Several of these projects may not work inside Docker or virtual machines, you need to actually be using Linux.