Node.js backend kūrimas: geriausia praktika

Kodėl verta kalbėti apie gerus įpročius

Kai prieš kelerius metus pradėjau kurti backend’us su Node.js, maniau, kad svarbiausia – kad veiktų. Express serveris paleidžiamas, duomenys keliauja iš taško A į tašką B, vartotojas gauna atsakymą. Kas dar reikia? Pasirodo, labai daug ko. Po kelių mėnesių grįžti į savo kodą buvo kaip bandyti perskaityti svetima kalba parašytą laišką. O kai projektas augo ir reikėjo pridėti naujų funkcijų, viskas virto košmaru.

Node.js backend kūrimas nėra vien techninis procesas – tai architektūros, organizavimo ir ilgalaikio mąstymo derinys. Galite sukurti veikiantį API per valandą, bet ar jis bus lengvai prižiūrimas po metų? Ar kitas programuotojas supras, ką čia veikėte? Ar sistema atlaikys didesnį apkrovimą? Būtent dėl šių priežasčių verta kalbėti apie geriausią praktiką – ne tam, kad būtų „gražu”, o tam, kad projektas gyventų ir vystytųsi.

Projekto struktūra, kuri nevirsta chaosu

Vienas didžiausių klausimų pradedantiesiems – kaip organizuoti failus? Daugelis pradeda nuo vieno `server.js` failo, kuriame telpa viskas. Tai veikia, kol projektas mažas. Bet kai kodas peržengė 500 eilučių ribą, pradeda skaudėti galvą.

Gera projekto struktūra turėtų atspindėti jūsų aplikacijos logiką. Populiariausias būdas – suskirstyti pagal funkcionalumą:


src/
├── controllers/
├── services/
├── models/
├── routes/
├── middleware/
├── config/
├── utils/
└── app.js

Bet čia ne dogma. Jei kuriate mikroservisų architektūrą, galbūt prasmingiau organizuoti pagal domenus – kiekvienas modulis turi savo controllers, services ir models. Pavyzdžiui, `users/`, `orders/`, `payments/` katalogai su pilna vidine struktūra.

Esmė ne konkretūs katalogų pavadinimai, o nuoseklumas. Jei nusprendėte, kad verslo logika gyvena `services/` kataloge, tai ji turi būti ten visada, ne kartais `services/`, kartais `helpers/`, o kartais tiesiog `controllers/` viduje. Kitas programuotojas (ar jūs po pusės metų) turi intuityviai suprasti, kur ieškoti kodo.

Dar vienas patarimas – vengti per gilios įdėjimo struktūros. Jei kelias iki failo atrodo kaip `src/modules/user/domain/services/implementations/UserAuthenticationService.js`, greičiausiai persistengėte. Trys-keturi lygiai paprastai pakanka.

Klaidos valdymas, kuris neužmuša aplikaciją

Node.js yra viengijis (single-threaded), o tai reiškia, kad neapdorota klaida gali nugriauti visą serverį. Mačiau projektus, kur vienas netikėtas `undefined` gali išjungti aplikaciją visiems vartotojams. Tai nepriimtina.

Pirmiausia – niekada, NIEKADA nepalikite promise’ų be `.catch()` arba `try-catch` blokų, jei naudojate `async/await`. Štai klasikinė klaida:


app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});

Kas nutinka, jei duomenų bazė nepasiekiama? Jūsų aplikacija tiesiog nutrūksta. Teisingas variantas:


app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
next(error);
}
});

Bet tai tik pradžia. Reikia centralizuoto klaidų valdymo middleware:


app.use((error, req, res, next) => {
console.error(error.stack);

if (error.name === 'ValidationError') {
return res.status(400).json({ error: error.message });
}

if (error.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Invalid token' });
}

res.status(500).json({ error: 'Something went wrong' });
});

Dar geriau – sukurti custom klaidų klases skirtingiems scenarijams. Tuomet galite tiksliai valdyti, kaip kiekviena klaida apdorojama, kokį status kodą grąžinti, ar loginti kaip error ar warning, ir panašiai.

Nepamirškit ir process-level klaidų:


process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Čia galite siųsti pranešimą į monitoring sistemą
});

process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Gracefully shutdown
process.exit(1);
});

Aplinkos kintamieji ir konfigūracija

Jei jūsų kode matau hardcoded duomenų bazės slaptažodžius ar API raktus, noriu verkti. Tai ne tik saugumo problema – tai dar ir praktinio naudojimo košmaras. Kaip paleisti tą patį kodą development, staging ir production aplinkose?

Naudokite `.env` failus ir biblioteką kaip `dotenv`:


# .env
NODE_ENV=development
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-super-secret-key
API_KEY=xyz123

Kodas:


require('dotenv').config();

const config = {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL
},
jwt: {
secret: process.env.JWT_SECRET
}
};

module.exports = config;

Svarbu: `.env` failas NIEKADA neturi patekti į git repository. Įtraukite jį į `.gitignore`. Vietoj to, sukurkite `.env.example` su pavyzdiniais įrašais, kad kiti programuotojai žinotų, kokius kintamuosius reikia nustatyti.

Sudėtingesniems projektams galite turėti skirtingus konfigūracijos failus pagal aplinką: `config/development.js`, `config/production.js`. Tada dinamiškai įkeliate reikiamą:


const env = process.env.NODE_ENV || 'development';
const config = require(`./config/${env}`);

Duomenų validacija nėra pasirinkimas

Niekada nepasitikėkite duomenimis, kuriuos gaunate iš kliento. Niekada. Net jei frontend turi validaciją, backend turi savo. Frontend validacija – tai UX pagerinimas, backend validacija – tai saugumas ir duomenų vientisumas.

Bibliotekos kaip `joi` ar `yup` labai palengvina gyvenimą:


const Joi = require('joi');

const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
age: Joi.number().integer().min(18).max(120)
});

app.post('/register', async (req, res, next) => {
try {
const validated = await userSchema.validateAsync(req.body);
// Dabar validated objektas garantuotai atitinka schemą
const user = await createUser(validated);
res.status(201).json(user);
} catch (error) {
if (error.isJoi) {
return res.status(400).json({ error: error.details[0].message });
}
next(error);
}
});

Galite sukurti reusable validation middleware:


const validate = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
req.body = value;
next();
};
};

// Naudojimas
app.post('/register', validate(userSchema), registerController);

Validacija turėtų apimti ne tik body, bet ir query parametrus, URL parametrus, net headers, jei reikia. Ypač svarbu validuoti tipus – jei laukiate skaičiaus, patikrinkite, kad tai tikrai skaičius, ne string „123”.

Autentifikacija ir autorizacija be skylių

Saugumo tema galėtų būti atskiras straipsnis, bet bent pagrindiniai dalykai. JWT (JSON Web Tokens) tapo de facto standartu Node.js pasaulyje, bet dažnai matau juos naudojamus neteisingai.

Pirma, JWT secret turi būti tikrai slaptas ir pakankamai ilgas. Ne „secret123”, o kažkas panašaus į 256 bitų random string. Antra, naudokite HTTPS production aplinkoje – kitaip token’ai keliauja internetu kaip atvirukas.

Trečia – refresh token’ai. Negalite duoti access token’o, kuris galioja amžinai. Bet negalite ir versti vartotojo prisijungti kas 15 minučių. Sprendimas:


// Trumpalaikis access token
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);

// Ilgalaikis refresh token
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);

Refresh token’us geriausia saugoti duomenų bazėje, kad galėtumėte juos invaliduoti (pavyzdžiui, kai vartotojas atsijungia ar keičia slaptažodį).

Autorizacija – tai ne tas pats kas autentifikacija. Autentifikacija patikrina „kas tu esi”, autorizacija – „ką tau leidžiama daryti”. Sukurkite middleware skirtingiems vaidmenims:


const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}

if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}

next();
};
};

// Naudojimas
app.delete('/users/:id',
authenticateToken,
requireRole(['admin']),
deleteUserController
);

Asinchroninio kodo organizavimas

Node.js stiprybė – asinchroniškumas. Bet tai ir didžiausias iššūkis pradedantiesiems. Callback hell’as buvo problema, kurią išsprendė Promise’ai, o vėliau async/await padarė kodą dar skaitomesnį.

Bet net su async/await galima sukurti neefektyvų kodą. Pavyzdžiui:


// Blogai - vykdoma nuosekliai
const user = await User.findById(userId);
const posts = await Post.findByUserId(userId);
const comments = await Comment.findByUserId(userId);

Jei šie užklausos nepriklausomos viena nuo kitos, kodėl laukti? Naudokite `Promise.all()`:


// Gerai - vykdoma lygiagrečiai
const [user, posts, comments] = await Promise.all([
User.findById(userId),
Post.findByUserId(userId),
Comment.findByUserId(userId)
]);

Tai gali sutaupyti daug laiko, ypač kai kalbame apie išorinius API kvietimus ar duomenų bazės užklausas.

Bet atsargiai su `Promise.all()` – jei bent vienas promise’as fail’ina, viskas fail’ina. Jei norite, kad klaidos būtų apdorojamos individualiai, naudokite `Promise.allSettled()`:


const results = await Promise.allSettled([
fetchFromAPI1(),
fetchFromAPI2(),
fetchFromAPI3()
]);

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`API ${index + 1} success:`, result.value);
} else {
console.error(`API ${index + 1} failed:`, result.reason);
}
});

Dar viena dažna klaida – naudoti async funkcijas ten, kur jos nereikalingos. Jei funkcija tiesiog grąžina Promise, nereikia jos wrap’inti į async:


// Nereikalingas async
async function getUser(id) {
return User.findById(id);
}

// Pakanka
function getUser(id) {
return User.findById(id);
}

Testavimas, kuris iš tiesų padeda

Testavimas dažnai lieka „padarysiu vėliau” kategorijoje, kuri niekada neįvyksta. Bet testai – tai ne prabanga, o investicija į ramybę. Kai refaktorinate kodą ar pridedate naują funkciją, testai parodo, ar nesulaužėte ko nors, kas veikė.

Node.js ekosistemoje populiariausi testing framework’ai – Jest, Mocha su Chai. Jest yra „batteries included” sprendimas, kuris veikia iš dėžės su minimalia konfigūracija.

Pradėkite nuo unit testų – testuokite individualias funkcijas izoliuotai:


// userService.test.js
const { createUser } = require('./userService');
const User = require('./models/User');

jest.mock('./models/User');

describe('createUser', () => {
it('should create user with hashed password', async () => {
const userData = {
email: '[email protected]',
password: 'password123'
};

User.create.mockResolvedValue({
id: 1,
email: userData.email
});

const result = await createUser(userData);

expect(result.email).toBe(userData.email);
expect(User.create).toHaveBeenCalledWith(
expect.objectContaining({
email: userData.email,
password: expect.not.stringContaining('password123')
})
);
});
});

Integration testai patikrina, kaip skirtingos sistemos dalys veikia kartu. Čia praverčia bibliotekos kaip `supertest`:


const request = require('supertest');
const app = require('./app');

describe('POST /api/users', () => {
it('should create new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'securepass123'
})
.expect(201);

expect(response.body).toHaveProperty('id');
expect(response.body.email).toBe('[email protected]');
});

it('should reject invalid email', async () => {
await request(app)
.post('/api/users')
.send({
email: 'notanemail',
password: 'securepass123'
})
.expect(400);
});
});

Nereikia siekti 100% test coverage bet kokia kaina, bet kritinės funkcijos – autentifikacija, mokėjimai, duomenų validacija – tikrai turi būti padengtos testais.

Kai viskas susiję į vieną paveikslą

Gera praktika Node.js backend kūrime nėra vien techninis checklist’as. Tai mąstymo būdas, kaip kurti sistemas, kurios gyvena ilgai ir vystosi be skausmo. Projekto struktūra, kuri atrodo perteklinė pradžioje, išgelbės jus po metų. Klaidų valdymas, kuris atrodo kaip extra darbas, apsaugos nuo 3 val. nakties skambučių. Testai, kurie atrodo kaip laiko švaistymas, leis jums drąsiai keisti kodą nebijant visko sulaužyti.

Svarbiausia – būti nuosekliems. Geriau pasirinkti vieną konvenciją ir jos laikytis, nei bandyti įgyvendinti visas „best practices” iš interneto ir gauti chaosą. Pradėkite nuo pagrindų: tvarkinga struktūra, teisingas klaidų valdymas, aplinkos kintamieji. Paskui pridėkite validaciją, testus, geresnius saugumo mechanizmus.

Ir nepamirškite – kodas rašomas ne tik mašinoms, bet ir žmonėms. Kitas programuotojas, kuris skaitys jūsų kodą (ar jūs pats po pusės metų) bus dėkingas už aiškius pavadinimus, komentarus sudėtingose vietose, nuoseklią struktūrą. Backend kūrimas – tai komandinis sportas, net jei šiuo metu komandoje tik jūs vienas.

HTML formatavimas, aiškūs pavyzdžiai, praktiniai patarimai – visa tai padeda, bet svarbiausia yra patirtis. Kiekvienas projektas išmokys kažko naujo, kiekviena klaida taps pamoka. Tad eksperimentuokite, klauskite, mokykitės iš kitų kodo. Node.js bendruomenė yra milžiniška ir pasidalijusi daugybe atviro kodo projektų, iš kurių galima pasimokyti. Pažiūrėkite, kaip organizuoti populiarūs framework’ai, kaip jie sprendžia sudėtingas problemas. Ir taikykite tai, kas veikia jūsų kontekste.

Daugiau

Directory traversal atakos: failų sistemos apsauga