Skip to content

Latest commit

 

History

History
491 lines (352 loc) · 23.4 KB

File metadata and controls

491 lines (352 loc) · 23.4 KB

Package Manager

Meta Data

Node and Rust are both installed together with a package manager.

  • Node's package manager is called npm, its packages are called node modules and its official website is npmjs.com.
  • Rust's package manager is called Cargo, its packages are called crates and its official website is crates.io.

official npm website official Cargo website

If you followed my Setup and you use Node v10.14.2 your npm version is probably 6.4.1. You can check this by running the following command:

$ npm --version
6.4.1

If you want to update npm you can run this:

$ npm install -g npm

Let us check our installed Cargo version. I have 1.31.0:

$ cargo --version
cargo 1.31.0 (339d9f9c8 2018-11-16)

As you can see it prints the same version as our installed Rust compiler. It's best practice to update both tools in tandem with rustup:

$ rustup update

The manifest file - the file which contains meta-data of your project like its name, its version, its dependencies and so on - is called package.json in the Node world and Cargo.toml in Rust. We'll now add manifest files to our "Hello World" examples, we created earlier.

Lets have a look at a typical package.json without dependencies:

{
  "name": "hello-world",
  "version": "0.1.0",
  "author": "John Doe <[email protected]> (https://github.com/john.doe)",
  "contributors": [
    "Jane Doe <[email protected]> (https://github.com/jane.doe)"
  ],
  "private": true,
  "description": "This is just a demo.",
  "license": "MIT OR Apache-2.0",
  "keywords": ["demo", "test"],
  "homepage": "https://github.com/john.doe/hello-world",
  "repository": {
    "type": "git",
    "url": "https://github.com/john.doe/hello-world"
  },
  "bugs": "https://github.com/john.doe/hello-world/issues"
}

The Cargo.toml looks really similar (besides being a .toml and not .json):

[package]
name = "hello-world"
version = "0.1.0"
authors = ["John Doe <[email protected]>",
           "Jane Doe <[email protected]>"]
publish = false
description = "This is just a demo."
license = "MIT OR Apache-2.0"
keywords = ["demo", "test"]
homepage = "https://github.com/john.doe/hello-world"
repository = "https://github.com/john.doe/hello-world"
documentation = "https://github.com/john.doe/hello-world"

So what have we here? Both manifest formats offer name and version fields which are mandatory. Adding the authors of a project is slightly different between the modules, but optional for both. npm assumes a main author for every package and multiple contributors while in Cargo you just fill an authors array. The authors field is actually mandatory for Cargo. As a value you use a string with the pattern name <email> (url) in npm and name <email> in Cargo. (Maybe (url) will be added in the future, but currently it is not used by anyone in Cargo.) Note that <email> and (url) are optional and that name doesn't have to be a person. You can just use you company name as well or something like my cool team.

If you don't accidentally want to publish a module to a public repository you can do that with either "private": true in npm or publish = false in Cargo. You can optionally add a description field to describe your project. (While you technically could use MarkDown in your descriptions, the support is spotty in both ecosystems and it isn't rendered properly most of the time.)

To add a single license you write "license": "MIT" in npm and license = "MIT" in Cargo. In both cases the value needs to be an SPDX license identifier. If you use multiple licences you can use an SPDX license expression like "license": "MIT OR Apache-2.0" for npm or license = "MIT OR Apache-2.0" for Cargo.

You can also optionally add multiple keywords, so your package can be found more easily in the official repositories.

You can add a link to your homepage and repository in both files (with a slightly different format for repository). npm allows you to add a link to reports bugs while Cargo allows you to add a link to find documentation.

Build tool

Cargo can be used to build your Rust project and you can add custom build scripts to npm as well. (Remember that you don't need a build step in the Node ecosystem, but if you rely on something like TypeScript it is needed. I'll show this in more in-depth when I introduce TypeScript to our Node projects.)

For now I just added a main and scripts.start field to our package.json:

{
  // ...your previous code
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js"
  }
}

A main field points to your packages entry file. This is the file that will be loaded, if someone requires your package. scripts.start is a convention to point to the file which should be loaded, if you want to run your package by calling $ npm start:

$ npm start

> [email protected] start /Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/node
> node src

Hello world!

To ignore the npm output use -s (for --silent):

$ npm -s start
Hello world!

In this case the entry file to our package specified in main and the file which should be run if you call $ npm start point to the same file, but this doesn't have to be the case. Additionally you could specify multiple executable files in a field called bin.

Cargo on the other hand will look for a src/main.rs file to build and/or run and if it finds a src/lib.rs file, it will build a library which than can be required by a different crate.

You run your Rust project with Cargo like this:

$ cargo run
   Compiling hello-world v0.1.0 (/Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/rust)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/hello-world`
Hello world!

To ignore the Cargo output use -q (for --quiet):

$ cargo -q run
Hello world!

You'll see that Cargo created a new file in your directory: a Cargo.lock. (It also placed your compiled program in a target directory.) The Cargo.lock file basically works like a package-lock.json in the Node world (or a yarn.lock, if you use yarn instead of npm), but is also generated during builds. Just to be complete let us generate a package-lock.json as well:

$ npm install
npm notice created a lockfile as package-lock.json. You should commit this file.
up to date in 0.924s
found 0 vulnerabilities

This should be your package-lock.json:

{
  "name": "hello-world",
  "version": "0.1.0",
  "lockfileVersion": 1
}

This should be your Cargo.lock:

[[package]]
name = "hello-world"
version = "0.1.0"

Both files become more interesting if you use dependencies in your project to ensure everyone uses the same dependencies (and dependencies of dependencies) at any time.

Before we move on let us make a slight adjustment to our Cargo.toml by adding the line edition = "2018". This will add support for Rust 2018 to our package. Editions are a feature which allow us to make backwards incompatible changes in Rust without introducing new major versions. You basically opt-in into new language features per package and your dependencies can be a mix of different editions. Currently there are two different editions available: Rust 2015 (which is the default) and Rust 2018 (which is the newest).

Publishing

Before we learn how to install and use dependencies we will actually publish a package that we can require afterwards. It will just export a Hello world! string. I call both packages rfnd-hello-world (with rfnd as an abbreviation for "Rust for Node developers"). npm offers namespacing of modules called scoped packages. If I'd have used that feature our module could have looked like this: @rfnd/hello-world. Cargo doesn't support namespacing and this is an intended limitation. By the way... even if snake_case is preferred for files and directories in Rust the module names in Cargo should use kebab-case by convention. This is probably used most of time in npm world, too.

I'll introduce TypeScript for our Node module in this step. It isn't that necessary currently, but it'll simplify some comparisons between Node and Rust in the next chapters when I use types or modern language features like ES2015 modules. First we need to install TypeScript as a devDependency, which is a dependency we only need for development, but not for our package itself at runtime:

$ npm install --save-dev typescript

To build the project we need to call the TypeScript compiler (tsc) by adding a new build field in the scripts object of the package.json. We also add a prepublishOnly entry which always runs the build process before we'll publish our module:

{
  "scripts": {
    "build": "tsc --build src",
    "prepublishOnly": "npm run build"
  }
}

By using --build src the TypeScript will look for a tsconfig.json in the src/ directory which configures the actual output. It looks like this:

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "outDir": "../dist",
    "sourceMap": true,
    "declarationMap": true
  }
}

Note that we'll generate CommonJS modules (because this is a Node project), it will generate declaration files (so other TypeScript projects know our types and interfaces even when they use the generated JavaScript files), all JavaScript and declaration files will be placed in a dist folder and finally we generate Source Maps to map the generated JavaScript back to the original TypeScript code (useful for debugging).

This also means that the main field in our package.json now points to dist/index.js - our compiled JavaScript code. And we also add a typings field which shows other modules where our generated declarations are stored.

{
  "main": "dist/index.js",
  "typings": "dist/index.d.ts"
}

Note that we don't want to commit our node_modules and dist to our repository, because these directories contain external or generated code. But be warned! If you place these directories in a .gitignore npm will not include them in our published package. This okay for node_modules (which are never included anyway), but a package without dist is pointless. You'll actually need to add an empty .npmignore, so npm ignores the .gitignore. (A little bit tricky, I know.) You can use the .npmignore to ignore files and directories which are committed in your repository, but shouldn't be included in your published package. In our case it'd be fine to include everything. As an alternative you could also explicitly list all files which should be included in a files field in your package.json.

With this setup aside this is our actual package under index.ts:

export const HELLO_WORLD = 'Hello world!';

We export a const with the value 'Hello world!'. This is ES2015 module syntax and we write our exported variable name in UPPER_CASES which is a common convention for constants. Call $ npm run build to build your project.

This is how our generated dist/index.js looks:

'use strict';
exports.__esModule = true;
exports.HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.js.map

Nothing fancy. Basically the same code in a different module syntax. The second line tells other tools that it was originaly an ES2015 module. The last line links our file to the corresponding Source Map.

The generated declaration file dist/index.d.ts is also worth a look:

export declare const HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.d.ts.map

You see that TypeScript could infer the type of HELLO_WORLD as a 'Hello world!'. This is a value type which is in this case a special variant of the type string with the concrete value 'Hello world!'.

We didn't need to tell TypeScript the type explicitly, but we could have done that. It would have looked like that:

export const HELLO_WORLD: 'Hello world!' = 'Hello world!';

Or like this, if we'd just want to tell others that it is a string:

export const HELLO_WORLD: string = 'Hello world!';

Great. This is our module. Now it needs to be published. You need to create an account at npmjs.com. If you have done that you'll get a profile like this. Now call $ npm login and enter your credentials from your new account. After that you can just call $ npm publish;

$ npm publish

> [email protected] prepublishOnly .
> npm run build


> [email protected] build /Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/node
> tsc --build src

# some output from npm notice...

+ [email protected]

Congratulations! 🎉 You successfully created a package which can be seen here.

Time for Rust! We first create a .gitignore with the following content:

Cargo.lock
target

As pointed out earlier Cargo.lock behaves similar to package-lock.json, but while the package-lock.json can always be committed into your version control the Cargo.lock should only be committed for binary projects, not libraries. Npm ignores the package-lock.json in libraries, but Cargo doesn't do the same for Cargo.lock.

The target directory will contain generated code, so it is also ignored.

Actually this is all the setup we need. Now dive into our package (living in src/lib.rs, because this will be a library):

pub const HELLO_WORLD: &str = "Hello world!";

As you can see this line of code in Rust is really similar to our TypeScript code (when we excplicitly set the type to string) which looked like this:

export const HELLO_WORLD: string = 'Hello world!';

Let's go through the Rust line of code word for word:

  • pub makes our variable public - very much like export in JavaScript, so it can be used by other packages.
  • const in Rust is different than const in JavaScript though. In Rust this is a real constant - a value which can't be changed.
    • In JavaScript it is a constant binding which means we can't assign another value to the same name (in this case our variable name is HELLO_WORLD). But the value itself can be changed, if it is a non-primitive value. (E.g. const a = { b: 1 }; a.b = 2; is possible.)
  • Unlike TypeScript we need to declare the type of HELLO_WORLD here by adding &str or we'll get compiler errors. Rust also supports type inferring, but const always requires an explicit type annotation.
    • &str is pronounced as string slice (and as a reminder "Hello world!" is pronounced as a string literal).
    • Rust actually has another String type called just String. A &str has a fixed size and cannot be mutated while a String is heap-allocated and has a dynamic size. A &str can be easily converted to a String with the to_string method like this: "Hello world!".to_string();. We'll see more of that in later examples, but you can already see methods can be invoked in the same way as we do in JavaScript and that built-in types come with a couple of built-in methods (like 'hello'.toUpperCase() in JavaScript for example).

We only need to publish our new crate now. You need to login on crates.io/ with your GitHub account to do so. If you've done that visit your account settings to get your API key. Than call cargo login and pass your API key:

$ cargo login <api-key>

You can inspect what will be published by packaging your Crate locally like this:

$ cargo package

Much like npm Cargo ignores all your directories and files in your .gitignore, too. That is fine. We don't need to ignore more files (or less) in this case. (If you do need to change that, you can modify your Cargo.toml as explained in the documentation.)

Now we only need to publish the crate like this:

$ cargo publish
    Updating crates.io index
   Packaging rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
   Verifying rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
   Compiling rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust/target/package/rfnd-hello-world-1.0.1)
    Finished dev [unoptimized + debuginfo] target(s) in 1.48s
   Uploading rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)

Awesome! Your crate is now published and can be seen here.

Remember that you can publish your package in the same version only once. This is true for Cargo and npm as well. To publish your package again with changes you need to change the version as well. The quickest way which doesn't introduce additional tooling is by just changing the value of version in package.json or Cargo.toml manually. Both communities follow SemVer-style versioning (more or less).

This is probably the minimum you need to know to get started in publishing your own packages, but I only scratched the surface. Have a look at the npm documentation and Cargo documentation to learn more.

Now that we published two packages we can try to require them in other projects as dependencies.

Dependencies

Let us start with Node again to show you how using dependencies work. To be honest... we already used a dependency, right? TypeScript. We added it to the devDependencies and use it in every example now:

$ npm install --save-dev typescript

devDependencies are only needed when we develop our Node application, but not at runtime. We use our recently published package as a real dependency. Install it like this:

$ npm install --save rfnd-hello-world

You should see the following dependencies in your package.json:

{
  "devDependencies": {
    "typescript": "^3.2.2"
  },
  "dependencies": {
    "rfnd-hello-world": "^1.0.1"
  }
}

We should also change our start script so it behaves similar to $ cargo run - build the project and run it:

{
  "scripts": {
    "start": "npm run build && node dist",
    "build": "tsc --build src"
  }
}

The final package.json looks pretty much like our previous example, just with less meta data. I'll show it to you, so we are on the same page:

{
  "name": "use-hello-world",
  "version": "0.1.0",
  "private": true,
  "main": "dist/index.js",
  "typings": "dist/index.d.ts",
  "scripts": {
    "start": "npm run build && node dist",
    "build": "tsc --build src"
  },
  "devDependencies": {
    "typescript": "^3.2.2"
  },
  "dependencies": {
    "rfnd-hello-world": "^1.0.1"
  }
}

The tsconfig.json is just copy and pasted without modifications.

We installed our dependencies, now we can use them like this:

import { HELLO_WORLD } from 'rfnd-hello-world';

console.log(`Required "${HELLO_WORLD}".`);

Let's run our example:

$ npm start

> [email protected] start /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> npm run build && node dist


> [email protected] build /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> tsc --build src

Required "Hello world!".

Good. Now we switch to Rust. We can't add dependencies to our project with Cargo without additional tooling. That's why we need to add it to our Cargo.toml manually in a section called [dependencies]. (You can watch this issue about adding a $ cargo add <package-name> command which will work similar to $ npm install --save <package-name>.)

[dependencies]
rfnd-hello-world = "1.0.1"

The crate will be automatically fetched as soon as we compile our program. Note that using 1.0.1 actually translates to ^1.0.1! If you want a very specific version you should use =1.0.1.

This is how our src/main.rs looks like:

use rfnd_hello_world::HELLO_WORLD;

fn main() {
    println!("Required: {}.", HELLO_WORLD);
}

Note that even though our external crate is called rfnd-hello-world we access it with rfnd_hello_world. Aside from the import we do with the use keyword, you can see how the string interpolation works with the println!() macro where {} is a placeholder and we pass the value as the second parameter. (Printing to the terminal can be actually quite complex. Read this article to learn more.) In case you didn't know: console.log() in Node can behave quite similar. We could rewrite console.log(`Required "${HELLO_WORLD}".`); to console.log('Required "%s".', HELLO_WORLD);. Try it!

As we use HELLO_WORLD just a single time we could also have written the example like this:

fn main() {
    println!("Required: {}.", rfnd_hello_world::HELLO_WORLD);
}

If rfnd_hello_world would expose more than one constant we can use a syntax similar to ES2015 destructing.

use rfnd_hello_world::{HELLO_WORLD, SOME_OTHER_VALUE};

fn main() {
    println!("Required: {}.", HELLO_WORLD);
    println!("Also: {}.", SOME_OTHER_VALUE);
}

Nice. Now test your programm:

$ cargo run
    Updating registry `https://github.com/rust-lang/crates.io-index`
   Compiling use-hello-world v0.1.0 (file:///Users/donaldpipowitch/Workspace/rust-for-node-developers/package-manager/dependencies/rust)
     Running `target/debug/use-hello-world`
Required "Hello world!".

It works! 🎉

To summarize: use rfnd_hello_world::HELLO_WORLD; (or use rfnd_hello_world::{HELLO_WORLD}; for multiple imports) works similar to import { HELLO_WORLD } from 'rfnd-hello-world';, but we can also inline the "import" as println!("Required: {}.", rfnd_hello_world::HELLO_WORLD); which would be very similar to console.log(`Required "${require('rfnd-hello-world').HELLO_WORLD}".`);.


prev "Hello World" | next