Основные команды bash, git, npm и yarn, а также немного о package.json и semver

от автора

Доброго времени суток, друзья!

Предлагаю вашему вниманию небольшую шпаргалку по основным командам bash, git, npm, yarn, package.json и semver.

Условные обозначения: [dir-name] — означает название директории, | — означает «или».

Рекомендую вводить каждую команду в терминале и внимательно изучать вывод, так вы быстро их запомните и определите, какие команды вам нужны, а какие нет.

Без дальнейших предисловий.

Оглавление:


bash

bash представляет собой инструмент командной строки, позволяющий выполнять некоторые распространенные действия.

Установка: в моем случае bash был установлен вместе с git.

Справка:

help 

История команд:

history 

Очистка терминала:

clear 

Выход из терминала:

exit 

Создание директории:

// make directory mkdir [dir-name] // пример mkdir my-app // несколько диреторий mkdir -p {dir1,dir2} // несколько вдложенных директорий mkdir -p my-app/{css,js} 

Смена директории:

// change directory cd [dir-name] // пример cd my-app // сразу после создания cd !$ // родительская директория cd .. // на два уровня выше cd ../.. // предыдущая директория cd - // домашняя директория cd ~ 

Путь к текущей директории:

// print work directory pwd 

Список файлов:

// list ls // включая скрытые файлы ls -a | -f // больше информации // например, права доступа ls -l 

Создание файла:

touch [file-name] // пример touch index.html // несколько файлов touch my-app/{index.html,css/style.css,js/script.js} 

Содержимое файла:

cat [file-name] // пример cat index.html // сортировка и выборка уникальных значений cat [file-name] | sort | uniq // меньше контента less [file-name] // q - exit // n строк с начала файла head -50 [file-name] // n строк с конца файла tail -50 [file-name] // поиск слова grep [string] [file-name]  // распаковка и просмотр содержимого архива unzip [achive-name]  // тип файла file [file-name] 

Копирование, перемещение и удаление файла:

// copy cp [file1] [file2]  // move mv [file1] [file2] // пример // перемещение всех файлов из одной директории в другую mv [dir1]/*.* [dir2]  // remove rm [file-name] // удаление пустой директории rmdir [dir-name] // удаление непустой директории rm -r [dir-name] // или rm -rf [dir-name] 

Вывод в терминал строки:

echo [string] // пример echo hello // создание или перезапись файла echo hello > greet.txt // добавление строки в файл echo hello >> greet.txt 

Загрузка файла:

wget [url] 

Коннекторы:

true && echo hello false || echo hello echo hello ; ls 

Конвейер:

// количество переносов строки - \n cat [file] | wc -l 

git

git представляет собой распределенную систему контроля версий, позволяющую контролировать процесс внесения изменений в проект.

Книга Pro Git.

Скринкаст Ильи Кантора.

Быстрый старт: Git How To.

Установка: git-scm.com.

Проверка установки:

git --version 

Справка:

git help git help [command-name] git [command-name] --help | -h 

Минимальные настройки:

// --local - настройки для текущего репо // --global - настройки для текущего пользователя // --system - настройки для всей системы, т.е. для всех пользователей git config --global user.name "My Name" git config --global user.email "myemail@example.com" 

Дополнительные настройки:

// список глобальных настроек git config --list | -l --global  // редактирование глобальных настроек git config --global --edit | -e 

Создание репозитория:

git init 

Очистка репозитория:

// -d - включая директории, -x - включая игнорируемые файлы, -f - принудительная git clean | -dxf 

Удаление файлов и директорий:

// remove git rm [file-name] git rm -r [dir-name]  git rm --force | -f 

Перемещение файлов:

// git add + git remove // move git mv [old-file] [new-file] 

Просмотр состояния репозитория:

git status 

Добавление изменений:

git add [file-name]  git add --force | -f  // все файлы git add . | --all | -A  // для добавления пустой директории можно создать в ней пустой файл .gitkeep 

Добавление сообщения (коммита):

// редактирование коммита git commit  // коммит для одного изменения, если не выполнялось git add . | -A // если выполнялось, сообщение будет добавлено для всех изменений git commit --message | -m "My Message"  // для всех изменений, если git add [file-name] выполнялось несколько раз git commit --all | -a -m | -am "My Message"  // исправление коммита git commit --amend "My Message" | --no-edit 

Просмотр коммита:

// последний коммит git show  // другой коммит git show [hash] // минимум первые 4 символа  // поиск изменений по сообщению или части сообщения git show :/[string]  // поиск коммита по тегу git show [tag-name] 

Просмотр разницы между коммитами:

git diff HEAD | @ // HEAD - как правило, текущая ветка; @ - алиас для HEAD  // staged git diff --staged | --cached  git diff [hash1] [hash2]  // разница между ветками git diff [branch1]...[branch2]  // просмотр разницы между коммитами при редактировании сообщения git commit --verbose | -v  // кастомизация выводимого сообщения git diff --word-diff | --color-words 

Просмотр истории изменений:

git log  // n - количество изменений git log -n // --since, --after - после // --until, --before - до  // разница git log -p  // быстрое форматирование git log --graph --oneline --stat  // кастомное форматирование git log --pretty=format // пример git log --pretty=format:'%C(red)%h %C(green)%cd %C(reset)| %C(blue)%s%d %C(yellow)[%an]' --date=short | format-local:'%F %R'  // поиск изменений по слову, файлу, ветке; i - без учета регистра git log --grep | -G [string] | [file] | [branch] & -i  // поиск по нескольким строкам git log --grep [string1] --grep [string2] --all-match  // поиск в определенном блоке файла git log -L '/<head>/','/<\/head>/':index.html  // поиск по автору git log --author=[name] 

Отмена изменений:

git reset // --hard - включая рабочую директорию и индекс // --soft - без рабочей директории и индекса // --mixed - по умолчанию: без рабочей директории, но с индексом  git reset --hard [hash] | @~ // @~ - последний коммит в HEAD  // аналогично git reset --hard ORIG_HEAD  // не путать с переключением ветки git checkout  git restore 

Работа с ветками:

// список веток git branch  // создание ветки git branch [branch-name]  // переключение на ветку git checkout [branch-name]  // branch + checkout git checkout -b [branch-name]  // переименование git branch -m [old-branch] [new-branch]  // удаление ветки git branch -d [branch-name]  // слияние веток git merge [branch-name] 

Разрешение конфликтов при слиянии:

// обычно, при возникновении конфликта, открывается редактор  // принять изменения из сливаемой ветки git checkout --ours  // принять изменения из текущей ветки git checkout --theirs  // отмена слияния git reset --merge git merge --abort  // получение дополнительной информации git checkout --conflict=diff3 --merge [file-name]  // продолжить слияние git merge --continue 

Удаленный репозиторий:

// клонирование git clone [url] & [dir]  // просмотр git remote git remote show git remote add [shortname] [url] git remote rename [old-name] [new-name]  // получение изменений // git fetch + git merge git pull  // отправка изменений git push 

Теги:

// просмотр git tag  // легковесная метка git tag [tag-name] //пример git tag v1-beta  // аннотированная метка git tag -a v1 -m "My Version 1"  // удаление git tag -d [tag-name] 

Отладка

git bisect  git blame  git grep 

Сохранение незакоммиченных изменений:

// сохранение git stash  // извлечение git stash pop 

Копирование коммита:

git cherry-pick | -x [hash]  // если возник конфликт // отмена git cherry-pick --abort  // продолжить git cherry-pick --continue  git cherry-pick --no-commit | -n  // --cherry = --cherry-mark --left-right --no-merges git log --oneline --cherry [branch1] [branch2] 

Перебазирование:

git rebase [branch]  // при возникновении конфликта // отмена git rebase --abort  // пропустить git rebase --skip  // продолжить git rebase --continue  // предпочтение коммитов слияния git rebase --preserve-merges | -p  // интерактивное перебазирование git rebase -i [branch] 

Автозавершение повторных конфликтов:

// rerere - reuse recorder resolution // rerere.enabled true | false // rerere.autoUpdate true | false // rerere-train.sh - скрипт для обучения rerere git rerere forget [file-name] 

Обратные коммиты:

git revert @ | [hash]  // отмена слияния // git reset --hard @~ не сработает git revert [hash] -m 1  // git merge [branch] не сработает // отмена отмены git revert [hash]  // повторное слияние с rebase git rebase [branch1] [branch2] | --onto [branch1] [hash] [branch2]  git merge [branch]  git rebase [hash] --no-ff 

Пример алиасов (сокращений) для .gitconfig:

[alias]     aa = add -A     co = checkout     ci = commit -m     st = status     br = branch 

Пример .gitconfig:

[user] 	name = [My Name] 	email = [myemail@example.com] 	username = [myusername] [core] 	editor = [myeditor] 	whitespace = fix,-indent-with-non-tab,trailing-space,cr-at-eol 	pager = delta [web] 	browser = google-chrome [instaweb] 	httpd = apache2 -f [rerere] 	enabled = 1 	autoupdate = 1 [push] 	default = matching [color] 	ui = auto [color "branch"] 	current = yellow bold 	local = green bold 	remote = cyan bold [color "diff"] 	meta = yellow bold 	frag = magenta bold 	old = red bold 	new = green bold 	whitespace = red reverse [color "status"] 	added = green bold 	changed = yellow bold 	untracked = red bold [difftool] 	prompt = false [delta] 	features = line-numbers decorations 	line-numbers = true [delta "decorations"] 	minus-style = red bold normal 	plus-style = green bold normal 	minus-emph-style = white bold red 	minus-non-emph-style = red bold normal 	plus-emph-style = white bold green 	plus-non-emph-style = green bold normal 	file-style = yellow bold none 	file-decoration-style = yellow box 	hunk-header-style = magenta bold 	hunk-header-decoration-style = magenta box 	minus-empty-line-marker-style = normal normal 	plus-empty-line-marker-style = normal normal 	line-numbers-right-format = "{np:^4}│ " [github] 	user = [username] 	token = token [gitflow "prefix"] 	versiontag = v [sequence] 	editor = interactive-rebase-tool [alias] 	a = add --all 	ai = add -i 	### 	ap = apply 	as = apply --stat 	ac = apply --check 	### 	ama = am --abort 	amr = am --resolved 	ams = am --skip 	### 	b = branch 	ba = branch -a 	bd = branch -d 	bdd = branch -D 	br = branch -r 	bc = rev-parse --abbrev-ref HEAD 	bu = !git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 	bs = !git-branch-status 	### 	c = commit 	ca = commit -a 	cm = commit -m 	cam = commit -am 	cem = commit --allow-empty -m 	cd = commit --amend 	cad = commit -a --amend 	ced = commit --allow-empty --amend 	### 	cl = clone 	cld = clone --depth 1 	clg = !sh -c 'git clone git://github.com/$1 $(basename $1)' - 	clgp = !sh -c 'git clone git@github.com:$1 $(basename $1)' - 	clgu = !sh -c 'git clone git@github.com:$(git config --get user.username)/$1 $1' - 	### 	cp = cherry-pick 	cpa = cherry-pick --abort 	cpc = cherry-pick --continue 	### 	d = diff 	dp = diff --patience 	dc = diff --cached 	dk = diff --check 	dck = diff --cached --check 	dt = difftool 	dct = difftool --cached 	### 	f = fetch 	fo = fetch origin 	fu = fetch upstream 	### 	fp = format-patch 	### 	fk = fsck 	### 	g = grep -p 	### 	l = log --oneline 	lg = log --oneline --graph --decorate 	### 	ls = ls-files 	lsf = !git ls-files | grep -i 	### 	m = merge 	ma = merge --abort 	mc = merge --continue 	ms = merge --skip 	### 	o = checkout 	om = checkout master 	ob = checkout -b 	opr = !sh -c 'git fo pull/$1/head:pr-$1 && git o pr-$1' 	### 	pr = prune -v 	### 	ps = push 	psf = push -f 	psu = push -u 	pst = push --tags 	### 	pso = push origin 	psao = push --all origin 	psfo = push -f origin 	psuo = push -u origin 	### 	psom = push origin master 	psaom = push --all origin master 	psfom = push -f origin master 	psuom = push -u origin master 	psoc = !git push origin $(git bc) 	psaoc = !git push --all origin $(git bc) 	psfoc = !git push -f origin $(git bc) 	psuoc = !git push -u origin $(git bc) 	psdc = !git push origin :$(git bc) 	### 	pl = pull 	pb = pull --rebase 	### 	plo = pull origin 	pbo = pull --rebase origin 	plom = pull origin master 	ploc = !git pull origin $(git bc) 	pbom = pull --rebase origin master 	pboc = !git pull --rebase origin $(git bc) 	### 	plu = pull upstream 	plum = pull upstream master 	pluc = !git pull upstream $(git bc) 	pbum = pull --rebase upstream master 	pbuc = !git pull --rebase upstream $(git bc) 	### 	rb = rebase 	rba = rebase --abort 	rbc = rebase --continue 	rbi = rebase --interactive 	rbs = rebase --skip 	### 	re = reset 	rh = reset HEAD 	reh = reset --hard 	rem = reset --mixed 	res = reset --soft 	rehh = reset --hard HEAD 	remh = reset --mixed HEAD 	resh = reset --soft HEAD 	rehom = reset --hard origin/master 	### 	r = remote 	ra = remote add 	rr = remote rm 	rv = remote -v 	rn = remote rename 	rp = remote prune 	rs = remote show 	rao = remote add origin 	rau = remote add upstream 	rro = remote remove origin 	rru = remote remove upstream 	rso = remote show origin 	rsu = remote show upstream 	rpo = remote prune origin 	rpu = remote prune upstream 	### 	rmf = rm -f 	rmrf = rm -r -f 	### 	s = status 	sb = status -s -b 	### 	sa = stash apply 	sc = stash clear 	sd = stash drop 	sl = stash list 	sp = stash pop 	ss = stash save 	ssk = stash save -k 	sw = stash show 	st = !git stash list | wc -l 2>/dev/null | grep -oEi '[0-9][0-9]*' 	### 	t = tag 	td = tag -d 	### 	w = show 	wp = show -p 	wr = show -p --no-color 	### 	svnr = svn rebase 	svnd = svn dcommit 	svnl = svn log --oneline --show-commit 	### 	subadd = !sh -c 'git submodule add git://github.com/$1 $2/$(basename $1)' - 	subrm = !sh -c 'git submodule deinit -f -- $1 && rm -rf .git/modules/$1 && git rm -f $1' - 	subup = submodule update --init --recursive 	subpull = !git submodule foreach git pull --tags origin master 	### 	assume = update-index --assume-unchanged 	unassume = update-index --no-assume-unchanged 	assumed = !git ls -v | grep ^h | cut -c 3- 	unassumeall = !git assumed | xargs git unassume 	assumeall = !git status -s | awk {'print $2'} | xargs git assume 	### 	bump = !sh -c 'git commit -am \"Version bump v$1\" && git psuoc && git release $1' - 	release = !sh -c 'git tag v$1 && git pst' - 	unrelease = !sh -c 'git tag -d v$1 && git pso :v$1' - 	merged = !sh -c 'git o master && git plom && git bd $1 && git rpo' - 	aliases = !git config -l | grep alias | cut -c 7- 	snap = !git stash save 'snapshot: $(date)' && git stash apply 'stash@{0}' 	bare = !sh -c 'git symbolic-ref HEAD refs/heads/$1 && git rm --cached -r . && git clean -xfd' - 	whois = !sh -c 'git log -i -1 --author=\"$1\" --pretty=\"format:%an <%ae>\"' - 	serve = daemon --reuseaddr --verbose --base-path=. --export-all ./.git 	### 	behind = !git rev-list --left-only --count $(git bu)...HEAD 	ahead = !git rev-list --right-only --count $(git bu)...HEAD 	### 	ours = "!f() { git checkout --ours $@ && git add $@; }; f" 	theirs = "!f() { git checkout --theirs $@ && git add $@; }; f" 	subrepo = !sh -c 'git filter-branch --prune-empty --subdirectory-filter $1 master' - 	human = name-rev --name-only --refs=refs/heads/* [filter "lfs"] 	clean = git-lfs clean -- %f 	smudge = git-lfs smudge -- %f 	process = git-lfs filter-process 	required = true 

Пример .gitignore:

### Node ###  # Logs logs npm-debug.log* yarn-debug.log* yarn-error.log*  # Optional npm cache directory .npm  # Dependency directories /node_modules /jspm_packages /bower_components  # Yarn Integrity file .yarn-integrity  # Optional eslint cache .eslintcache  # dotenv environment variables file(s) .env .env.*  #Build generated dist/ build/  # Serverless generated files .serverless/  ### SublimeText ### # cache files for sublime text *.tmlanguage.cache *.tmPreferences.cache *.stTheme.cache  # workspace files are user-specific *.sublime-workspace  # project files should be checked into the repository, unless a significant # proportion of contributors will probably not be using SublimeText # *.sublime-project   ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json  ### Vim ### *.sw[a-p]  ### WebStorm/IntelliJ ### /.idea modules.xml *.ipr *.iml   ### System Files ### *.DS_Store  # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db  # Folder config file Desktop.ini  # Recycle Bin used on file shares $RECYCLE.BIN/  # Thumbnails ._*  # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent 

npm

npm представляет собой пакетный менеджер, позволяющий устанавливать зависимости проекта.

Официальный сайт: npmjs.com.

Установка.

npm устанавливается вместе с Node.js.

Также вместе с Node.js устанавливается npx, позволяющий запускать исполняемые файлы без установки: npx create-react-app my-app.

Проверка установки:

node --version | -v npm --version | -v 

Обновление:

npm i -g npm@latest 

Список доступных команд:

npm help npm help [command-name] 

Инициализация проекта:

npm init  // auto npm init --yes | -y 

Установка зависимостей

npm install | i  // проверка конкретной зависимости npm explore [package-name]  // проверка всех зависимостей npm doctor  // очистка npm ci 

Принудительная переустановка зависимостей:

npm i --force | -f 

Установка только продакшн-пакетов:

npm i --only=production | --only=prod 

Добавление зависимости:

npm i [package-name] npm i [package-name@version]  // пример npm i express 

Добавление зависимости для разработки:

npm i --save-dev | -D [package-name]  // пример npm i -D nodemon 

Обновление зависимости:

npm update | up [package-name] 

Удаление зависимости:

// dependency npm remove | rm | r [package-name]  // devDependency npm r -D [package-name] 

Глобальная установка/обновление/удаление пакета:

npm i/up/r -g [package-name]  // пример npm i -g create-react-app // использование create-react-app my-app 

Определение устаревших пакетов:

npm outdated npm outdated [package-name] 

Список установленных зависимостей:

npm list | ls  // top level npm ls --depth=0 | --depth 0  // global + top level npm ls -g --depth 0 

Информация о пакете:

npm view | v [package-name]  // пример npm v react npm v react.description 

Запуск скрипта/выполнение команды:

npm run [script]  // пример // package.json: "scripts": { "dev": "nodemon server.js" } npm run dev // script start или node server.js npm start npm stop 

Удаление дублирующихся пакетов:

npm dedupe | ddp 

Удаление посторонних пакетов:

npm prune 

Обнаружение уязвимостей (угроз безопасности):

npm audit // json npm audit --json // plain text npm audit --parseable 

Автоматическое исправление уязвимостей:

npm audit fix 

yarn

yarn, как и npm, представляет собой пакетный менеджер, позволяющий устанавливать зависимости проекта.

Официальный сайт: yarnpkg.com.

Установка:

npm i -g yarn 

Команда «yarn dlx» позволяет запускать исполняемые файлы без установки: yarn dlx create-react-app my-app. Для этого yarn необходимо обновить до второй версии: yarn set version berry.

Проверка установки:

yarn --version | -v 

Обновление:

yarn set version latest 

Список доступных команд:

yarn help yarn help [command-name] 

Инициализация проекта:

yarn init  // auto yarn init --yes | -y  // "private": true в package.json yarn init --private | -p  // auto + private yarn init -yp 

Установка зависимостей:

yarn // или yarn install  // тихая установка yarn install --silent | -s  // проверка yarn --check-files 

Принудительная переустановка зависимостей:

yarn install --force 

Установка только продакшн-пакетов:

yarn install --production | --prod 

Добавление зависимости:

yarn add [package-name] yarn add [package-name@version]  // пример yarn add express  // тихая установка yarn add --silent // или yarn add -s 

Добавление зависимости для разработки:

yarn add --dev | -D [package-name]  // пример yarn add -D nodemon 

Обновление зависимости:

yarn upgrade [package-name] 

Удаление зависимости:

yarn remove [package-name] 

Глобальная установка/обновление/удаление пакета:

yarn global add/upgrade/remove [package-name]  // пример yarn global add create-react-app // использование create-react-app my-app 

Список установленных зависимостей:

yarn list  // top level yarn list --depth=0 | --depth 0 

Информация о пакете:

yarn info [package-name] // или yarn why [package-name]  // пример yarn info react yarn info react description yarn why webpack 

Запуск скрипта/выполнение команды:

yarn [script] // или yarn run [script]  // пример // package.json: "scripts": { "dev": "nodemon server.js" } yarn dev 

package.json

{   "name": "my-app",   "version": "1.0.0",   "description": "my awesome app",   "keywords": [     "amazing",     "awesome",     "best"   ],   "private": true,   "main": "server.js",   "license": "MIT",   "homepage": "https://my-website.com",   "repository": {     "type": "git",     "url": "https://github.com/user/repo.git"   },   "repository": "github:user/repo",   "author": {     "name": "My Name",     "email": "myemail@example.com",     "url": "https://my-website.com"   },   "author": "My Name <myemail@example.com> (https://my-website.com)",   "contributers": [     {       "name": "Friend Name",       "email": "friendemail@example.com",       "url": "https://friend-website.com"     }   ],   "contributors": "Friend Name <friendemail.com> (https://friend-website.com)",   "dependencies": {     "express": "^4.17.1"   },   "devDependencies": {     "nodemon": "^2.0.4"   },   "scripts": {     "start": "react-scripts start",     "dev": "nodemon server.js"   } } 

  • name — название проекта
  • version — версия проекта (см. версионирование)
  • description — описание проекта (зачем нужен пакет?)
  • keywords — ключевые слова (облегчает поиск в реестре npm)
  • private — установка значения в true предотвращает случайную публикацию пакета в реестре npm
  • main — основная точка входа для функционирования проекта
  • repository — ссылка на репозиторий (один из вариантов)
  • author — автор проекта (один из вариантов)
  • contributors — участники проекта (люди, внесшие вклад в проект)
  • dependencies — зависимости проекта (пакеты, без которых приложение не будет работать)
  • devDependencies — зависимости для разработки (пакеты, без которых приложение будет работать)
  • scripts — команды (выполняемые сценарии, задачи), предназначенные для автоматизации, например, команда «yarn dev» запустит скрипт «nodemon server.js»

Полный список доступных полей файла «package.json»: npm-package.json

Файлы «package-lock.json» и «yarn.lock» содержат более полную информацию об установленных пакетах, чем package.json, например, конкретные версии пакетов вместо диапазона допустимых версий.

Версионирование

Каждый пакет имеет версию, состоящую из трех цифр (например, 1.0.0), где первая цифра — мажорная версия (major), вторая — минорная версия (minor), третья — патчевая версия (патч, patch). Новые версии называются релизами.

Увеличение каждой из этих цифр согласно правилам семантического версионирования (semver) означает следующее:

  • major — внесение несовместимых с предыдущей версией изменений
  • minor — новая функциональность, совместимая с предыдущей версией
  • patch — исправление ошибок, незначительные улучшения

Диапазоны версий или допустимые релизы определяются с помощью следующих операторов (компараторов):

  • * — любая версия (аналогично пустой строке)
  • <1.0.0 — любая версия, которая меньше 1.0.0
  • <=1.0.0 — любая версия, которая меньше или равна 1.0.0
  • >1.0.0 — любая версия, которая больше 1.0.0
  • >=1.0.0 — любая версия, которая больше или равна 1.0.0
  • =1.0.0 — только версия 1.0.0 (оператор "=" можно опустить)
  • >=1.0.0 <2.0.0 — больше или равно 1.0.0 и меньше 2.0.0
  • 1.0.0-2.0.0 — набор версий включительно
  • ^1.0.0 — минорные и патчевые релизы (>=1.0.0 <2.0.0)
  • ~.1.0.0 — только патчевые релизы (>=1.0.0 <1.1.0)

Подробные сведения о semver: node-semver.

Благодарю за внимание.

ссылка на оригинал статьи https://habr.com/ru/post/522078/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *