it-swarm-vi.com

Vòng lặp thông qua các tập tin với không gian trong tên?

Tôi đã viết kịch bản sau đây để tìm ra kết quả đầu ra của hai đạo diễn với tất cả các tệp giống nhau trong đó:

#!/bin/bash

for file in `find . -name "*.csv"`  
do
     echo "file = $file";
     diff $file /some/other/path/$file;
     read char;
done

Tôi biết có nhiều cách khác để đạt được điều này. Mặc dù tò mò, tập lệnh này thất bại khi các tập tin có không gian trong đó. Làm thế nào tôi có thể đối phó với điều này?

Ví dụ đầu ra của find:

./zQuery - abc - Do Not Prompt for Date.csv
160
Amir Afghani

Câu trả lời ngắn (gần nhất với câu trả lời của bạn, nhưng xử lý khoảng trắng)

OIFS="$IFS"
IFS=$'\n'
for file in `find . -type f -name "*.csv"`  
do
     echo "file = $file"
     diff "$file" "/some/other/path/$file"
     read line
done
IFS="$OIFS"

Câu trả lời tốt hơn (cũng xử lý ký tự đại diện và dòng mới trong tên tệp)

find . -type f -name "*.csv" -print0 | while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
done

Câu trả lời hay nhất (dựa trên câu trả lời của Gilles )

find . -type f -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read line </dev/tty
' {} ';'

Hoặc thậm chí tốt hơn, để tránh chạy một sh trên mỗi tệp:

find . -type f -name '*.csv' -exec sh -c '
  for file do
    echo "$file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
  done
' sh {} +

Câu trả lời dài

Bạn có ba vấn đề:

  1. Theo mặc định, Shell phân chia đầu ra của lệnh trên khoảng trắng, tab và dòng mới
  2. Tên tệp có thể chứa các ký tự đại diện sẽ được mở rộng
  3. Điều gì xảy ra nếu có một thư mục có tên kết thúc bằng *.csv?

1. Chỉ tách trên các dòng mới

Để tìm ra cái gì cần đặt file, Shell phải lấy đầu ra của find và giải thích nó bằng cách nào đó, nếu không thì file sẽ chỉ là toàn bộ đầu ra của find.

Shell đọc biến IFS, được đặt thành <space><tab><newline> Theo mặc định.

Sau đó, nó xem xét từng ký tự trong đầu ra của find. Ngay khi nó nhìn thấy bất kỳ ký tự nào trong IFS, nó nghĩ rằng nó đánh dấu sự kết thúc của tên tệp, vì vậy nó đặt file thành bất kỳ ký tự nào nó thấy cho đến bây giờ và chạy vòng lặp. Sau đó, nó bắt đầu ở nơi nó rời đi để lấy tên tệp tiếp theo và chạy vòng lặp tiếp theo, v.v., cho đến khi đến cuối đầu ra.

Vì vậy, nó thực sự hiệu quả trong việc này:

for file in "zquery" "-" "abc" ...

Để yêu cầu nó chỉ phân chia đầu vào trên dòng mới, bạn cần phải làm

IFS=$'\n'

trước lệnh for ... find của bạn.

Điều đó đặt IFS thành một dòng mới, do đó, nó chỉ phân chia trên dòng mới, chứ không phải khoảng trắng và tab.

Nếu bạn đang sử dụng sh hoặc dash thay vì ksh93, bash hoặc zsh, bạn cần viết IFS=$'\n' thay vào đó như thế này:

IFS='
'

Điều đó có lẽ là đủ để kịch bản của bạn hoạt động, nhưng nếu bạn quan tâm để xử lý một số trường hợp góc khác đúng cách, hãy đọc tiếp ...

2. Mở rộng $file Không có ký tự đại diện

Bên trong vòng lặp nơi bạn làm

diff $file /some/other/path/$file

shell cố gắng mở rộng $file (một lần nữa!).

Nó có thể chứa dấu cách, nhưng vì chúng ta đã đặt IFS ở trên, điều đó sẽ không thành vấn đề ở đây.

Nhưng nó cũng có thể chứa các ký tự đại diện như * Hoặc ?, Điều này sẽ dẫn đến hành vi không thể đoán trước. (Cảm ơn Gilles đã chỉ ra điều này.)

Để yêu cầu Shell không mở rộng các ký tự đại diện, hãy đặt biến trong dấu ngoặc kép, ví dụ:.

diff "$file" "/some/other/path/$file"

Vấn đề tương tự cũng có thể cắn chúng ta

for file in `find . -name "*.csv"`

Ví dụ: nếu bạn có ba tệp này

file1.csv
file2.csv
*.csv

(rất khó xảy ra, nhưng vẫn có thể)

Nó sẽ như thể bạn đã chạy

for file in file1.csv file2.csv *.csv

sẽ được mở rộng đến

for file in file1.csv file2.csv *.csv file1.csv file2.csv

khiến file1.csvfile2.csv được xử lý hai lần.

Thay vào đó, chúng ta phải làm

find . -name "*.csv" -print | while IFS= read -r file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
done

read đọc các dòng từ đầu vào tiêu chuẩn, chia dòng thành các từ theo IFS và lưu trữ chúng trong các tên biến mà bạn chỉ định.

Ở đây, chúng tôi đang bảo nó không chia dòng thành các từ và lưu trữ dòng trong $file.

Cũng lưu ý rằng read line Đã đổi thành read line </dev/tty.

Điều này là do bên trong vòng lặp, đầu vào tiêu chuẩn đến từ find thông qua đường ống.

Nếu chúng ta vừa thực hiện read, thì nó sẽ tiêu thụ một phần hoặc toàn bộ tên tệp và một số tệp sẽ bị bỏ qua.

/dev/tty Là thiết bị đầu cuối nơi người dùng đang chạy tập lệnh từ đó. Lưu ý rằng điều này sẽ gây ra lỗi nếu tập lệnh được chạy qua cron, nhưng tôi cho rằng điều này không quan trọng trong trường hợp này.

Sau đó, nếu một tên tập tin chứa dòng mới thì sao?

Chúng tôi có thể xử lý điều đó bằng cách thay đổi -print Thành -print0 Và sử dụng read -d '' Ở cuối đường ống:

find . -name "*.csv" -print0 | while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read char </dev/tty
done

Điều này làm cho find đặt một byte null ở cuối mỗi tên tệp. Null byte là các ký tự duy nhất không được phép trong tên tệp, do đó, điều này sẽ xử lý tất cả các tên tệp có thể, bất kể kỳ lạ như thế nào.

Để lấy tên tệp ở phía bên kia, chúng tôi sử dụng IFS= read -r -d ''.

Trường hợp chúng tôi đã sử dụng read ở trên, chúng tôi đã sử dụng dấu phân cách dòng mặc định của dòng mới, nhưng bây giờ, find đang sử dụng null làm dấu phân cách dòng. Trong bash, bạn không thể chuyển một ký tự NUL trong một đối số cho một lệnh (ngay cả các hàm dựng sẵn), nhưng bash hiểu -d '' Là nghĩa Phân cách NUL . Vì vậy, chúng tôi sử dụng -d '' Để tạo read sử dụng cùng một dấu phân cách dòng là find. Lưu ý rằng -d $'\0', Tình cờ, cũng hoạt động, bởi vì bash không hỗ trợ byte NUL coi nó là chuỗi rỗng.

Để chính xác, chúng tôi cũng thêm -r, Nói rằng không xử lý dấu gạch chéo ngược trong tên tệp đặc biệt. Ví dụ: không có -r, \<newline> Sẽ bị xóa và \n Được chuyển đổi thành n.

Một cách dễ dàng hơn để viết cái này không yêu cầu bash hoặc zsh hoặc ghi nhớ tất cả các quy tắc trên về byte null (một lần nữa, nhờ Gilles):

find . -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read char </dev/tty
' {} ';'

3. Bỏ qua các thư mục có tên kết thúc bằng * .csv

find . -name "*.csv"

cũng sẽ khớp với các thư mục được gọi là something.csv.

Để tránh điều này, hãy thêm -type f Vào lệnh find.

find . -type f -name '*.csv' -exec sh -c '
  file="$0"
  echo "$file"
  diff "$file" "/some/other/path/$file"
  read line </dev/tty
' {} ';'

glenn jackman chỉ ra, trong cả hai ví dụ này, các lệnh để thực thi cho mỗi tệp đang được chạy trong một mạng con, vì vậy nếu bạn thay đổi bất kỳ biến nào trong vòng lặp, chúng sẽ bị quên.

Nếu bạn cần đặt biến và đặt chúng ở cuối vòng lặp, bạn có thể viết lại để sử dụng thay thế quy trình như sau:

i=0
while IFS= read -r -d '' file; do
    echo "file = $file"
    diff "$file" "/some/other/path/$file"
    read line </dev/tty
    i=$((i+1))
done < <(find . -type f -name '*.csv' -print0)
echo "$i files processed"

Lưu ý rằng nếu bạn thử sao chép và dán phần này vào dòng lệnh, read line Sẽ tiêu tốn echo "$i files processed", Vì vậy lệnh sẽ không được chạy.

Để tránh điều này, bạn có thể xóa read line </dev/tty Và gửi kết quả đến một máy nhắn tin như less.


[~ # ~] ghi chú [~ # ~]

Tôi đã xóa dấu chấm phẩy (;) Bên trong vòng lặp. Bạn có thể đặt chúng trở lại nếu bạn muốn, nhưng chúng không cần thiết.

Ngày nay, $(command) phổ biến hơn `command`. Điều này chủ yếu là vì việc viết $(command1 $(command2)) dễ dàng hơn `command1 \`command2\``.

read char Không thực sự đọc một ký tự. Nó đọc toàn bộ một dòng vì vậy tôi đã thay đổi nó thành read line.

218
Mikel

Tập lệnh này không thành công nếu bất kỳ tên tệp nào chứa khoảng trắng hoặc ký tự toàn cầu Shell \[?*. Lệnh find xuất ra một tên tệp trên mỗi dòng. Sau đó, lệnh thay thế `find …` Được Shell đánh giá như sau:

  1. Thực hiện lệnh find, lấy đầu ra của nó.
  2. Tách đầu ra find thành các từ riêng biệt. Bất kỳ ký tự khoảng trắng là một dấu tách Word.
  3. Đối với mỗi Word, nếu đó là mẫu hình cầu, hãy mở rộng nó thành danh sách các tệp mà nó khớp.

Ví dụ: giả sử có ba tệp trong thư mục hiện tại, được gọi là `foo* bar.csv, foo 1.txtfoo 2.txt.

  1. Lệnh find trả về ./foo* bar.csv.
  2. Shell tách chuỗi này tại khoảng trắng, tạo ra hai từ: ./foo*bar.csv.
  3. ./foo* Chứa một metacharacter toàn cầu, nên nó được mở rộng thành danh sách các tệp phù hợp: ./foo 1.txt./foo 2.txt.
  4. Do đó, vòng lặp for được thực hiện liên tiếp với ./foo 1.txt, ./foo 2.txtbar.csv.

Bạn có thể tránh hầu hết các vấn đề ở giai đoạn này bằng cách giảm bớt việc tách Word và tắt tính năng toàn cầu. Để giảm âm lượng tách Word, hãy đặt biến IFS thành một ký tự dòng mới; bằng cách này, đầu ra của find sẽ chỉ được phân chia ở dòng mới và khoảng trắng sẽ vẫn còn. Để tắt tính năng toàn cầu, hãy chạy set -f. Sau đó, phần này của mã sẽ hoạt động miễn là không có tên tệp nào chứa ký tự dòng mới.

IFS='
'
set -f
for file in $(find . -name "*.csv"); do …

(Đây không phải là một phần của vấn đề của bạn, nhưng tôi khuyên bạn nên sử dụng $(…) over `…`. Chúng có cùng ý nghĩa, nhưng phiên bản backquote có quy tắc trích dẫn kỳ lạ.)

Có một vấn đề khác bên dưới: diff $file /some/other/path/$file Nên

diff "$file" "/some/other/path/$file"

Mặt khác, giá trị của $file Được chia thành các từ và các từ được coi là mẫu hình cầu, giống như với lệnh thay thế ở trên. Nếu bạn phải nhớ một điều về lập trình Shell, hãy nhớ điều này: luôn luôn sử dụng dấu ngoặc kép xung quanh các mở rộng biến ($foo) Và thay thế lệnh ($(bar)), trừ khi bạn biết bạn muốn chia tay. (Ở trên, chúng tôi biết rằng chúng tôi muốn chia đầu ra find thành các dòng.)

Một cách gọi đáng tin cậy find là bảo nó chạy lệnh cho mỗi tệp mà nó tìm thấy:

find . -name '*.csv' -exec sh -c '
  echo "$0"
  diff "$0" "/some/other/path/$0"
' {} ';'

Trong trường hợp này, một cách tiếp cận khác là so sánh hai thư mục, mặc dù bạn phải loại trừ một cách rõ ràng tất cả các tập tin nhàm chán.

diff -r -x '*.txt' -x '*.ods' -x '*.pdf' … . /some/other/path

Tôi ngạc nhiên khi không thấy readarray được đề cập. Nó làm cho điều này rất dễ dàng khi được sử dụng kết hợp với <<< nhà điều hành:

$ touch oneword "two words"

$ readarray -t files <<<"$(ls)"

$ for file in "${files[@]}"; do echo "|$file|"; done
|oneword|
|two words|

Sử dụng <<<"$expansion" construc cũng cho phép bạn phân tách các biến chứa dòng mới thành mảng, như:

$ string=$(dmesg)
$ readarray -t lines <<<"$string"
$ echo "${lines[0]}"
[    0.000000] Initializing cgroup subsys cpuset

readarray đã ở Bash từ nhiều năm nay, vì vậy đây có lẽ nên là cách thức kinh điển để làm điều này trong Bash.

6
blujay

Lặp lại bất kỳ tệp nào ( bất kỳ ký tự đặc biệt nào ) với tìm hoàn toàn an toàn (xem liên kết để biết tài liệu):

exec 9< <( find "$absolute_dir_path" -type f -print0 )
while IFS= read -r -d '' -u 9
do
    file_path="$(readlink -fn -- "$REPLY"; echo x)"
    file_path="${file_path%x}"
    echo "START${file_path}END"
done
6
l0b0

Afaik find có tất cả những gì bạn cần.

find . -okdir diff {} /some/other/path/{} ";"

find tự chăm sóc để gọi các chương trình một cách tiết kiệm. -okdir sẽ nhắc bạn trước diff (bạn có chắc là có/không).

Không có Shell liên quan, không có Globing, joker, pi, pa, po.

Là một sidenote: Nếu bạn kết hợp find với for/while/do/xargs, trong hầu hết các trường hợp, bạn đã làm sai. :)

4
user unknown

Tôi ngạc nhiên không ai đề cập đến giải pháp zsh rõ ràng ở đây:

for file (**/*.csv(ND.)) {
  do-something-with $file
}

((D) cũng bao gồm các tệp ẩn, (N) để tránh lỗi nếu không có kết quả khớp, (.) để hạn chế thông thường tệp.)

bash4.3 và ở trên bây giờ cũng hỗ trợ nó một phần:

shopt -s globstar nullglob dotglob
for file in **/*.csv; do
  [ -f "$file" ] || continue
  [ -L "$file" ] && continue
  do-something-with "$file"
done
4
Stéphane Chazelas

Tên tệp có khoảng trắng trong chúng trông giống như nhiều tên trên dòng lệnh nếu chúng không được trích dẫn. Nếu tệp của bạn được đặt tên là "Hello World.txt", dòng diff sẽ mở rộng thành:

diff Hello World.txt /some/other/path/Hello World.txt

trông giống như bốn tên tập tin. Chỉ cần đặt dấu ngoặc kép xung quanh các đối số:

diff "$file" "/some/other/path/$file"
2
Ross Smith

Trích dẫn đôi là bạn của bạn.

diff "$file" "/some/other/path/$file"

Mặt khác, nội dung của biến được chia Word.

1
geekosaur

Với bash4, bạn cũng có thể sử dụng hàm mapfile dựng sẵn để đặt một mảng chứa mỗi dòng và lặp lại trên mảng này.

$ tree 
.
├── a
│   ├── a 1
│   └── a 2
├── b
│   ├── b 1
│   └── b 2
└── c
    ├── c 1
    └── c 2

3 directories, 6 files
$ mapfile -t files < <(find -type f)
$ for file in "${files[@]}"; do
> echo "file: $file"
> done
file: ./a/a 2
file: ./a/a 1
file: ./b/b 2
file: ./b/b 1
file: ./c/c 2
file: ./c/c 1
1
kitekat75